diff --git a/src/viewport/VideoLibrary.ts b/src/viewport/VideoLibrary.ts index a22a4bf6..fcc4df4d 100644 --- a/src/viewport/VideoLibrary.ts +++ b/src/viewport/VideoLibrary.ts @@ -36,6 +36,18 @@ const SEEK_SLACK = 1 / 120; /** How long a run waits at its start for the driven videos it shows (#404) before it goes anyway. */ export const VIDEO_WAIT_MS = 3000; +/** + * When the frame being drawn began, in milliseconds like `performance.now()`. The ticker moves every + * clock on by the step between these, so a driven video's time does too; `performance.now()` read + * while drawing lags it by whatever the frame did before. A busy page lags more in some frames + * than in others, and 45 ms more at one end of a window of 500 ms reads a 1x time as 1.09. + */ +function frameTime(): number { + const now = + typeof document !== 'undefined' && 'timeline' in document ? document.timeline.currentTime : null; + return typeof now === 'number' ? now : performance.now(); +} + /** What the wearer of a driven player (#404) asks of it, see `wear`. */ interface Drive { /** Seconds into the clip. */ @@ -108,7 +120,7 @@ export class VideoLibrary { const drive = player.drive; drive.time = time; drive.heard = heard; - drive.reading = readSpeed(drive.reading, time, performance.now()); + drive.reading = readSpeed(drive.reading, time, frameTime()); if (player.texture) this.follow(player, drive); return { ...ref, file: key }; } @@ -223,8 +235,10 @@ export class VideoLibrary { ready: boolean; rate: number; seeking: boolean; + /** How fast its wearer's time runs, as read for a driven player; 0 for a free one. */ + speed: number; }[] { - return [...this.players].map(([key, { element, texture }]) => ({ + return [...this.players].map(([key, { element, texture, drive }]) => ({ key, time: element.currentTime, paused: element.paused, @@ -232,6 +246,7 @@ export class VideoLibrary { ready: texture !== null, rate: element.playbackRate, seeking: element.seeking, + speed: drive?.reading?.speed ?? 0, })); } diff --git a/tests/viewport/video-library.test.ts b/tests/viewport/video-library.test.ts index 8b7ba53b..021da8e7 100644 --- a/tests/viewport/video-library.test.ts +++ b/tests/viewport/video-library.test.ts @@ -107,7 +107,10 @@ describe('VideoLibrary driven players (#404)', () => { vi.stubGlobal('document', { hidden: false, addEventListener: () => {}, createElement: fakeVideo }); }); - afterEach(() => vi.unstubAllGlobals()); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); const REF = { file: KEY, smooth: true, rect: null, sourceWidth: 320, sourceHeight: 180, slice: null }; @@ -128,6 +131,30 @@ describe('VideoLibrary driven players (#404)', () => { ]); }); + it('reads the speed by the time of the frame, not by when the frame got round to it', async () => { + const frame = { at: 0 }; + vi.stubGlobal('document', { + hidden: false, + addEventListener: () => {}, + createElement: fakeVideo, + timeline: { + get currentTime() { + return frame.at; + }, + }, + }); + // The first frames are drawn 45 ms late, as on a busy page; the time runs at 1x by the frames. + let late = 45; + vi.spyOn(performance, 'now').mockImplementation(() => frame.at + late); + const videos = await freshLibrary(); + for (let i = 0; i <= 36; i++) { + frame.at = (i * 1000) / 60; + if (i === 7) late = 0; + videos.wear(REF, { videoClock: 'driven', videoTime: i / 60 }, 'view1:a', false); + } + expect(videos.debugInfo()[0]!.speed).toBeCloseTo(1, 6); + }); + it('tells a view whether one of its driven players still waits for its first picture', async () => { const videos = await freshLibrary(); videos.get(KEY); diff --git a/tools/e2e/66-video.mjs b/tools/e2e/66-video.mjs index b30d773d..c01efec6 100644 --- a/tools/e2e/66-video.mjs +++ b/tools/e2e/66-video.mjs @@ -348,9 +348,17 @@ export async function video(t) { } await click(playCut.x, playCut.y); const gap = (a, b) => Math.min(Math.abs(a - b), 2 - Math.abs(a - b)); + // The speed read is 1x; the rate is that, nudged by at most a tenth towards the asked time. check( 'playing the animation plays the video along at 1x and lets it be heard', - along.every((p) => !p.paused && !p.muted && Math.abs(p.rate - 1) <= 0.1 && gap(p.time, p.asked) < 0.15), + along.every( + (p) => + !p.paused && + !p.muted && + Math.abs(p.speed - 1) <= 0.1 && + Math.abs(p.rate / p.speed - 1) <= 0.1 + 1e-9 && + gap(p.time, p.asked) < 0.15, + ), JSON.stringify(along), );