diff --git a/packages/core/src/runtime/init.buffering.test.ts b/packages/core/src/runtime/init.buffering.test.ts index 306d1d6899a..e7693a7c496 100644 --- a/packages/core/src/runtime/init.buffering.test.ts +++ b/packages/core/src/runtime/init.buffering.test.ts @@ -1,5 +1,6 @@ import { afterEach, expect, it, vi } from "vitest"; import { initSandboxRuntimeModular } from "./init"; +import { heldPosters } from "./proxySrc"; import { createMockTimeline, resetRuntimeFixtureDom } from "./runtimeSeekFixture.test-helpers"; function audio(id: string, duration: number, readyState: { value: number; bufferedTo?: number }) { @@ -26,12 +27,12 @@ function audio(id: string, duration: number, readyState: { value: number; buffer return el; } -function playPodcast(voiceStart = 0) { +function playPodcast(voiceStart = 0, voiceTag = "audio") { resetRuntimeFixtureDom(); document.body.innerHTML = '
' + '' + - `` + + `<${voiceTag} id="voice" data-start="${voiceStart}" data-duration="4" src="/voice.m4a">` + "
"; const voiceReady: { value: number; bufferedTo?: number } = { value: 1 }; const music = audio("music", 10, { value: 4 }); @@ -80,6 +81,26 @@ it("a track not ready pauses everything with Buffering, and resumes when it can expect(states.at(-1)).toEqual({ isPlaying: true, buffering: false }); }); +it("the music plays on while a video shows a still across a copy swap", () => { + const { music, voice, states, playFor } = playPodcast(0, "video"); + heldPosters.set(voice, { poster: null, since: performance.now() }); + + playFor(1); + expect(window.__player!.getTime()).toBeGreaterThan(0.9); + expect(music.paused).toBe(false); + expect(states.at(-1)).toEqual({ isPlaying: true, buffering: false }); +}); + +it("a copy that never lands stops standing in, and the film waits for it", () => { + 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); + expect(music.paused).toBe(true); + 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); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index c2c9ceaa0e5..ce8bcd832f4 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -158,7 +158,7 @@ import { forgetPositionEdit, installPositionEditsSeekReapply, } from "./positionEdits"; -import { unheldPoster, unproxiedSrc } from "./proxySrc"; +import { stillStandsIn, unheldPoster, unproxiedSrc } from "./proxySrc"; import { applyVariableBindings, unproxiedMediaSrc } from "./applyVariableBindings"; import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading"; import { COLOR_GRADING_AUTHORED_OPACITY_ATTR } from "../colorGrading"; @@ -5694,6 +5694,7 @@ export function initSandboxRuntimeModular(): void { isInClipWindow(t, start, end) && !hasDataAhead(el) && isPlayableTrack(el) && + !stillStandsIn(el) && !webAudio.ownsElement(el), ) .map(({ el }) => el); diff --git a/packages/core/src/runtime/mediaProxy.ts b/packages/core/src/runtime/mediaProxy.ts index a1418d8ed1a..4c0bbda2c7b 100644 --- a/packages/core/src/runtime/mediaProxy.ts +++ b/packages/core/src/runtime/mediaProxy.ts @@ -235,12 +235,12 @@ function holdShownFrame(el: HTMLMediaElement): void { if (!isVideoElement(el) || heldPosters.has(el)) return; const still = stillOf(el); if (!still) return; - heldPosters.set(el, el.getAttribute("poster")); + heldPosters.set(el, { poster: el.getAttribute("poster"), since: performance.now() }); el.setAttribute("poster", still); const letGo = () => { for (const type of RELEASE_EVENTS) el.removeEventListener(type, release); letGoOfStill.delete(el); - const authored = heldPosters.get(el) ?? null; + const authored = heldPosters.get(el)?.poster ?? null; heldPosters.delete(el); if (authored === null) el.removeAttribute("poster"); else el.setAttribute("poster", authored); diff --git a/packages/core/src/runtime/proxySrc.ts b/packages/core/src/runtime/proxySrc.ts index c0b3060fc08..82f62315c90 100644 --- a/packages/core/src/runtime/proxySrc.ts +++ b/packages/core/src/runtime/proxySrc.ts @@ -5,9 +5,17 @@ export function unproxiedSrc(el: Element): string | null { return swappedElements.has(el) ? (swappedElements.get(el) ?? null) : el.getAttribute("src"); } -/** Videos showing a still of their last frame as poster across a copy swap, each with the poster it had before. */ -export const heldPosters = new WeakMap(); +/** Videos showing a still of their last frame as poster across a copy swap: the poster they had before, and when. */ +export const heldPosters = new WeakMap(); export function unheldPoster(el: Element): string | null { - return heldPosters.has(el) ? (heldPosters.get(el) ?? null) : el.getAttribute("poster"); + return heldPosters.has(el) ? (heldPosters.get(el)?.poster ?? null) : el.getAttribute("poster"); +} + +const STILL_STANDS_IN_MS = 2000; + +/** The video shows a still while its new copy loads, so the film need not wait for it. */ +export function stillStandsIn(el: Element): boolean { + const held = heldPosters.get(el); + return held !== undefined && performance.now() - held.since < STILL_STANDS_IN_MS; }