A sprite whose Video clock is free played its video on after it was destroyed, heard in a game, until the player was let go 2 to 4 s later. A driven one was already held and quiet after 100 ms. - The library holds and quiets a free player nothing asked for in HUSH_MS, as it did a driven one. That covers Destroy, a scene load, the end of a run and a sprite switched off. - Asked for again before it is let go, it plays on from where it was held, heard only in a run. A hidden page leaves it alone, as the sweep does. - A sprite only out of view keeps its free video playing and heard. Whether a video is heard should not hang on the camera. Before, it ran 2 to 4 s and was then let go. Now it plays on, which opens its player even where it was never in view. Tests: - Unit: video-library 1 new. A free player losing its wearer is held and quiet 200 ms later, stays so through a new run, plays on heard when asked again with the same element, and runs on on a hidden page. - e2e 66 on melektaus-atx: ALL PASSED, 36 checks, 66g-ii new. Heard 31 frames before Destroy, held and muted in every frame read after. - Counter-probe without hushing free players: the unit test and only 66g-ii fail, the player plays on heard (paused false, muted false). - Gate on melektaus-atx: 309 files, 4797 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
import type * as Pixi from 'pixi.js';
|
|
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.
|
|
*/
|
|
// Pixi's own video source reads the page as it is made; a plain source stands in for it.
|
|
vi.mock('pixi.js', async (load) => {
|
|
const pixi = await load<typeof Pixi>();
|
|
class VideoSource extends pixi.TextureSource {
|
|
constructor() {
|
|
super({ width: 320, height: 180 });
|
|
}
|
|
}
|
|
return { ...pixi, VideoSource };
|
|
});
|
|
|
|
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', 'none');
|
|
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', 'none')).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', 'none');
|
|
const b = videos.wear(REF, { videoClock: 'driven', videoTime: 1.25 }, 'view1:b', 'none');
|
|
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', 'none');
|
|
}
|
|
expect(videos.debugInfo()[0]!.speed).toBeCloseTo(1, 6);
|
|
});
|
|
|
|
it('lands a seek that Chrome hands over no frame for', async () => {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearInterval'] });
|
|
const events = new Map<string, (() => void)[]>();
|
|
let frames: (() => void)[] = [];
|
|
const element = {
|
|
...fakeVideo(),
|
|
seeking: false,
|
|
ended: false,
|
|
duration: 2,
|
|
playbackRate: 1,
|
|
readyState: 4,
|
|
videoWidth: 320,
|
|
videoHeight: 180,
|
|
at: 0,
|
|
get currentTime() {
|
|
return this.at;
|
|
},
|
|
set currentTime(time: number) {
|
|
this.at = time;
|
|
this.seeking = true;
|
|
},
|
|
addEventListener: (type: string, listener: () => void) =>
|
|
events.set(type, [...(events.get(type) ?? []), listener]),
|
|
requestVideoFrameCallback: (callback: () => void) => frames.push(callback),
|
|
};
|
|
const fire = (type: string) => events.get(type)?.forEach((listener) => listener());
|
|
const seekDone = () => {
|
|
element.seeking = false;
|
|
fire('seeked');
|
|
};
|
|
vi.stubGlobal('document', { hidden: false, addEventListener: () => {}, createElement: () => element });
|
|
const videos = await freshLibrary();
|
|
const key = videos.wear(REF, { videoClock: 'driven', videoTime: 0.5 }, 'view1:a', 'none').file;
|
|
fire('loadeddata');
|
|
seekDone();
|
|
[frames] = [[], ...frames.map((frame) => frame())];
|
|
expect(videos.showsAsked(key)).toBe(true);
|
|
// Switched on at 1.2 s: the seek is done, and no frame follows it.
|
|
videos.wear(REF, { videoClock: 'driven', videoTime: 1.2 }, 'view1:a', 'none');
|
|
expect(videos.showsAsked(key)).toBe(false);
|
|
seekDone();
|
|
vi.advanceTimersByTime(200);
|
|
vi.useRealTimers();
|
|
expect(videos.showsAsked(key)).toBe(true);
|
|
});
|
|
|
|
it('holds a driven player quiet as soon as its wearer is gone, as an ended composition item', async () => {
|
|
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'performance'] });
|
|
const events = new Map<string, () => void>();
|
|
let frames: (() => void)[] = [];
|
|
const element = {
|
|
...fakeVideo(),
|
|
ended: false,
|
|
duration: 10,
|
|
playbackRate: 1,
|
|
seeking: false,
|
|
readyState: 4,
|
|
videoWidth: 320,
|
|
videoHeight: 180,
|
|
play() {
|
|
this.paused = false;
|
|
return Promise.resolve();
|
|
},
|
|
pause() {
|
|
this.paused = true;
|
|
},
|
|
addEventListener: (type: string, listener: () => void) => events.set(type, listener),
|
|
requestVideoFrameCallback: (callback: () => void) => frames.push(callback),
|
|
};
|
|
vi.stubGlobal('document', { hidden: false, addEventListener: () => {}, createElement: () => element });
|
|
const { soundLibrary } = await import('../../src/viewport/SoundLibrary');
|
|
vi.spyOn(soundLibrary, 'mediaInput').mockReturnValue(null);
|
|
const videos = await freshLibrary();
|
|
const props = { videoClock: 'driven', videoTime: 0 };
|
|
videos.wear(REF, props, 'view1:a', 'game');
|
|
events.get('loadeddata')!();
|
|
[frames] = [[], ...frames.map((frame) => frame())];
|
|
// Worn at 1x for a second, heard.
|
|
for (let ms = 0; ms <= 1000; ms += 1000 / 60) {
|
|
element.currentTime = ms / 1000;
|
|
videos.wear(REF, { ...props, videoTime: ms / 1000 }, 'view1:a', 'game');
|
|
vi.advanceTimersByTime(1000 / 60);
|
|
}
|
|
expect([element.paused, element.muted]).toEqual([false, false]);
|
|
// The item ends: nothing wears the player any more. Let go only after 2 s, it played on heard.
|
|
vi.advanceTimersByTime(200);
|
|
expect([element.paused, element.muted]).toEqual([true, true]);
|
|
expect(videos.debugInfo()).toHaveLength(1);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('holds a free player quiet once no sprite wears it, and plays it on when one does (#420)', async () => {
|
|
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'performance'] });
|
|
const page = { hidden: false };
|
|
const events = new Map<string, () => void>();
|
|
let frames: (() => void)[] = [];
|
|
const element = {
|
|
...fakeVideo(),
|
|
play() {
|
|
this.paused = false;
|
|
return Promise.resolve();
|
|
},
|
|
pause() {
|
|
this.paused = true;
|
|
},
|
|
addEventListener: (type: string, listener: () => void) => events.set(type, listener),
|
|
requestVideoFrameCallback: (callback: () => void) => frames.push(callback),
|
|
};
|
|
vi.stubGlobal('document', {
|
|
get hidden() {
|
|
return page.hidden;
|
|
},
|
|
addEventListener: () => {},
|
|
createElement: () => element,
|
|
});
|
|
const { soundLibrary } = await import('../../src/viewport/SoundLibrary');
|
|
vi.spyOn(soundLibrary, 'mediaInput').mockReturnValue(null);
|
|
const videos = await freshLibrary();
|
|
const drawFor = (ms: number) => {
|
|
for (let at = 0; at < ms; at += 1000 / 60) {
|
|
videos.get(KEY);
|
|
vi.advanceTimersByTime(1000 / 60);
|
|
}
|
|
};
|
|
videos.get(KEY);
|
|
events.get('loadeddata')!();
|
|
[frames] = [[], ...frames.map((frame) => frame())];
|
|
videos.startRun();
|
|
drawFor(1000);
|
|
const state = () => [element.paused, element.muted];
|
|
expect(state()).toEqual([false, false]);
|
|
// Its sprite is destroyed: held and quiet at once, not heard until it is let go 2-4 s later.
|
|
vi.advanceTimersByTime(200);
|
|
expect(state()).toEqual([true, true]);
|
|
// A run started meanwhile takes it back to its start, still quiet.
|
|
videos.startRun();
|
|
vi.advanceTimersByTime(200);
|
|
expect(state()).toEqual([true, true]);
|
|
// Worn again before it is let go: it plays on, heard, the same video.
|
|
videosMade = 0;
|
|
drawFor(500);
|
|
expect([...state(), videosMade]).toEqual([false, false, 0]);
|
|
// A hidden page draws nothing, and asks for nothing: it plays on as before.
|
|
page.hidden = true;
|
|
vi.advanceTimersByTime(1000);
|
|
expect(state()).toEqual([false, false]);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
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', 'none');
|
|
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,
|
|
]);
|
|
});
|
|
});
|