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">${voiceTag}>` +
"
";
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;
}