Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions packages/core/src/runtime/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1280,6 +1280,67 @@ describe("syncRuntimeMedia", () => {
expect(clip.el.play).not.toHaveBeenCalled();
});

it("seeks a held video tail once, though the browser lands short of the source end", () => {
// WebKit clamps a seek to the end onto the last frame, a few ms before the duration.
const clip = createMockClip({ start: 0, end: 5, duration: 5, sourceDuration: 0.25 });
let time = 0;
const seeks: number[] = [];
Object.defineProperty(clip.el, "currentTime", {
get: () => time,
set: (value: number) => {
seeks.push(value);
time = Math.min(value, 0.2);
},
configurable: true,
});
for (const timeSeconds of [2, 2.02, 2.04, 2.06, 2.08])
syncRuntimeMedia({ clips: [clip], timeSeconds, playing: true, playbackRate: 1 });
expect(seeks).toHaveLength(1);
});

it("seeks a held video tail again when something else moved the element off it", () => {
const clip = createMockClip({ start: 0, end: 5, duration: 5, sourceDuration: 0.25 });
let time = 0;
const seeks: number[] = [];
Object.defineProperty(clip.el, "currentTime", {
get: () => time,
set: (value: number) => {
seeks.push(value);
time = Math.min(value, 0.2);
},
configurable: true,
});
for (const timeSeconds of [2, 2.02, 2.04]) {
syncRuntimeMedia({ clips: [clip], timeSeconds, playing: true, playbackRate: 1 });
}
time = 0; // a reload resets the element
syncRuntimeMedia({ clips: [clip], timeSeconds: 2.06, playing: true, playbackRate: 1 });
expect(seeks).toHaveLength(2);
});

it("seeks a held video tail on every paused pass, as a render's prepare relies on", () => {
const clip = createMockClip({ start: 0, end: 5, duration: 5, sourceDuration: 0.25 });
let time = 0;
const seeks: number[] = [];
Object.defineProperty(clip.el, "currentTime", {
get: () => time,
set: (value: number) => {
seeks.push(value);
time = Math.min(value, 0.2);
},
configurable: true,
});
for (const timeSeconds of [2, 2, 2])
syncRuntimeMedia({
clips: [clip],
timeSeconds,
playing: false,
playbackRate: 1,
forceSync: true,
});
expect(seeks).toHaveLength(3);
});

it("holds a video that runs to the composition end on its last frame at the terminal time", () => {
const clip = createMockClip({ start: 2.5, end: 5, duration: 2.5, sourceDuration: 10 });
const seek = {
Expand Down
17 changes: 16 additions & 1 deletion packages/core/src/runtime/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ function frozenAudioFor(el: HTMLMediaElement, ct: number, now: number): number {
}
return now - m.at;
}
// Where a video held on its tail was last sent, and where the browser put it: a browser lands a seek to the end
// short of it, so asking again for the same time each tick would seek every frame.
const heldTailSeekedTo = new WeakMap<HTMLMediaElement, { target: number; landed: number }>();
const HELD_TAIL_MOVED_SECONDS = 0.05;
const releaseHeldVideo = new WeakMap<HTMLMediaElement, () => void>();

// A seeking video still paints its previous frame, and one still fetching its first data paints none (its seek
Expand Down Expand Up @@ -309,6 +313,7 @@ function clampVolume(volume: number): number {
* element at a different file without ever leaving its active window.
*/
export function evictMediaSyncState(el: HTMLMediaElement): void {
heldTailSeekedTo.delete(el);
lastMoved.delete(el);
lastOffset.delete(el);
lastRelativeTime.delete(el);
Expand Down Expand Up @@ -816,7 +821,14 @@ export function syncRuntimeMedia(params: {
// ignore unsupported playbackRate
swallow("runtime.media.site1", err);
}
if (hardSync || strictSync || forceSync || (steerSeeks && !dipped)) {
const heldTail = heldTailSeekedTo.get(el);
// Only while playing: a paused pass (a seek, a render's prepare) always lands the frame it asks for.
const tailSettled =
isHeldVideoTail &&
params.playing &&
heldTail?.target === relTime &&
Math.abs(el.currentTime - heldTail.landed) < HELD_TAIL_MOVED_SECONDS;
if (!tailSettled && (hardSync || strictSync || forceSync || (steerSeeks && !dipped))) {
// Skip the per-tick seek (and the `el.load()` drift-recovery retry
// below) for `<video>` elements that have a sibling
// `<img id="__render_frame_<id>__">`. The sibling is created only
Expand All @@ -835,6 +847,9 @@ export function syncRuntimeMedia(params: {
} catch (err) {
swallow("runtime.media.site2", err);
}
if (isHeldVideoTail)
heldTailSeekedTo.set(el, { target: relTime, landed: el.currentTime });
else heldTailSeekedTo.delete(el);
if (Math.abs(el.currentTime - relTime) > 0.5 && !seekLoadRetried.has(el)) {
seekLoadRetried.add(el);
el.load();
Expand Down
Loading