A driven video measures how fast its wearer's time runs and sets its playbackRate from that. The time moves on with the ticker, but the reading took its clock from performance.now() while drawing. A frame that had more to do before the draw shifted that clock against the time, and a 1x animation read as up to 1.09. Outside 0.9 to 1.1 the video goes mute, so the sound could drop out for a moment. In CI this made e2e 66k red now and then. - VideoLibrary reads the speed with the timestamp of the frame (document.timeline.currentTime), the same clock the time runs on. It falls back to performance.now() where there is no document. - debugInfo() reports the measured speed of a driven player. - e2e 66k checks the measured speed and the correction of the rate apart. The rate alone may differ from 1 by up to 10 percent on purpose, so checking it at +-0.1 was too strict. Tests: - Unit video-library: 8 tests green, 1 new. With performance.now() back in place the new test is red and reads 1.089. - e2e 66 on melektaus-atx: all passed, 27 checks, speed 1.000 in all 5 readings. - Gate on melektaus-atx: CHECK EXIT 0, 297 files, 4562 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
5.7 KiB
TypeScript
172 lines
5.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
/**
|
|
* When the video library lets a player go (#402): after 2 s nobody asked for it, but never while
|
|
* the page is hidden. Chrome draws no frames for a hidden page and so nothing asks, while the
|
|
* sweeper's timer goes on; a sprite still wearing the video lost it and Pixi warned about the
|
|
* destroyed source on the way back.
|
|
*/
|
|
const KEY = `video:a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4#0~0~1~1`;
|
|
|
|
let videosMade = 0;
|
|
|
|
function fakeVideo() {
|
|
videosMade += 1;
|
|
return {
|
|
muted: false,
|
|
playsInline: false,
|
|
preload: '',
|
|
src: '',
|
|
currentTime: 0,
|
|
loop: false,
|
|
paused: true,
|
|
addEventListener: () => {},
|
|
requestVideoFrameCallback: () => 0,
|
|
play: () => Promise.resolve(),
|
|
pause: () => {},
|
|
removeAttribute: () => {},
|
|
load: () => {},
|
|
};
|
|
}
|
|
|
|
const page = { hidden: false, listeners: [] as (() => void)[] };
|
|
|
|
function setHidden(hidden: boolean): void {
|
|
page.hidden = hidden;
|
|
for (const listener of page.listeners) listener();
|
|
}
|
|
|
|
async function freshLibrary() {
|
|
const { VideoLibrary } = await import('../../src/viewport/VideoLibrary');
|
|
return new VideoLibrary();
|
|
}
|
|
|
|
describe('VideoLibrary sweeping', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'performance'] });
|
|
page.hidden = false;
|
|
page.listeners = [];
|
|
vi.stubGlobal('document', {
|
|
get hidden() {
|
|
return page.hidden;
|
|
},
|
|
addEventListener: (_type: string, listener: () => void) => page.listeners.push(listener),
|
|
createElement: fakeVideo,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('lets a player go that nobody asked for in 2 s', async () => {
|
|
const videos = await freshLibrary();
|
|
videos.get(KEY);
|
|
vi.advanceTimersByTime(4100);
|
|
expect(videos.debugInfo()).toEqual([]);
|
|
});
|
|
|
|
it('keeps it while the page is hidden, however long', async () => {
|
|
const videos = await freshLibrary();
|
|
videos.get(KEY);
|
|
setHidden(true);
|
|
vi.advanceTimersByTime(60_000);
|
|
expect(videos.debugInfo()).toHaveLength(1);
|
|
});
|
|
|
|
it('does not count the time hidden as idle once the page is back', async () => {
|
|
const videos = await freshLibrary();
|
|
videos.get(KEY);
|
|
setHidden(true);
|
|
vi.advanceTimersByTime(60_000);
|
|
setHidden(false);
|
|
vi.advanceTimersByTime(1900);
|
|
expect(videos.debugInfo()).toHaveLength(1);
|
|
vi.advanceTimersByTime(2200);
|
|
expect(videos.debugInfo()).toEqual([]);
|
|
});
|
|
|
|
it('keeps a driven player that is only worn, as by a sprite switched off', async () => {
|
|
const videos = await freshLibrary();
|
|
const ref = { file: KEY, smooth: true, rect: null, sourceWidth: 320, sourceHeight: 180, slice: null };
|
|
const props = { videoClock: 'driven', videoTime: 1.2 };
|
|
videosMade = 0;
|
|
for (let ms = 0; ms < 6000; ms += 100) {
|
|
videos.wear(ref, props, 'view2:a', false);
|
|
vi.advanceTimersByTime(100);
|
|
}
|
|
// Let go and opened again would be a second video, loading while the sprite shows white.
|
|
expect(videosMade).toBe(1);
|
|
expect(videos.debugInfo().map(({ key }) => key)).toEqual([`${KEY}@view2:a`]);
|
|
});
|
|
});
|
|
|
|
describe('VideoLibrary driven players (#404)', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('document', { hidden: false, addEventListener: () => {}, createElement: fakeVideo });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const REF = { file: KEY, smooth: true, rect: null, sourceWidth: 320, sourceHeight: 180, slice: null };
|
|
|
|
it('leaves a sprite with a free clock on the shared player', async () => {
|
|
const videos = await freshLibrary();
|
|
expect(videos.wear(REF, { videoTime: 1 }, 'view1:a', false)).toBe(REF);
|
|
expect(videos.debugInfo()).toEqual([]);
|
|
});
|
|
|
|
it('gives every wearer of a driven video a player of its own, opened at its time', async () => {
|
|
const videos = await freshLibrary();
|
|
const a = videos.wear(REF, { videoClock: 'driven', videoTime: 0.5 }, 'view1:a', false);
|
|
const b = videos.wear(REF, { videoClock: 'driven', videoTime: 1.25 }, 'view1:b', false);
|
|
expect([a.file, b.file]).toEqual([`${KEY}@view1:a`, `${KEY}@view1:b`]);
|
|
expect(videos.debugInfo().map(({ key, time }) => [key, time])).toEqual([
|
|
[`${KEY}@view1:a`, 0.5],
|
|
[`${KEY}@view1:b`, 1.25],
|
|
]);
|
|
});
|
|
|
|
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);
|
|
expect(videos.loading('view12')).toBe(false);
|
|
const worn = videos.wear(REF, { videoClock: 'driven', videoTime: 0 }, 'view12:a', false);
|
|
expect(videos.hasPicture(worn.file)).toBe(false);
|
|
// A run waits only for its own view: view12 is neither view1 nor view2.
|
|
expect([videos.loading('view12'), videos.loading('view1'), videos.loading('view2')]).toEqual([
|
|
true,
|
|
false,
|
|
false,
|
|
]);
|
|
});
|
|
});
|