diff --git a/README.md b/README.md index 2ca1bb66..4c1927c1 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2009 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2010 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/viewport/VideoLibrary.ts b/src/viewport/VideoLibrary.ts index 926f9ea8..f5ad2c80 100644 --- a/src/viewport/VideoLibrary.ts +++ b/src/viewport/VideoLibrary.ts @@ -33,6 +33,8 @@ const END_SLACK = 1 / 60; const IDLE_MS = 2000; /** Closer than this a seek that waited for the one before it is not worth another. */ const SEEK_SLACK = 1 / 120; +/** How long after `seeked` a seek counts as landed when Chrome hands over no frame for it. */ +const LAND_MS = 150; /** 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; @@ -204,27 +206,14 @@ export class VideoLibrary { } /** - * Seeks, or, while a seek is still running, remembers the newest wish for when it is done. The - * seek has landed with the first frame Chrome hands over once all of them are done: at `seeked` - * the picture was not there yet, and Pixi, which loads a paused video's picture only then, kept - * the one before it for a frame. + * Seeks, or, while a seek is still running, remembers the newest wish for when it is done. It has + * landed once the last of them is done, see `chase`. */ private seek(player: Player, drive: Drive, time: number): void { const { element } = player; if (element.seeking) drive.wanted = time; else element.currentTime = time; - if (drive.landing) return; drive.landing = true; - const land = () => { - if (player.gone) return; - if (element.seeking || drive.wanted !== null) { - element.requestVideoFrameCallback(land); - return; - } - player.texture?.source.update(); - drive.landing = false; - }; - element.requestVideoFrameCallback(land); } /** Lets every player go: a session that ends takes its videos along. */ @@ -266,6 +255,8 @@ export class VideoLibrary { seeking: boolean; /** How fast its wearer's time runs, as read for a driven player; 0 for a free one. */ speed: number; + /** Whether a driven player still waits for the picture of a seek, see `seek`. */ + landing: boolean; }[] { return [...this.players].map(([key, { element, texture, drive }]) => ({ key, @@ -276,6 +267,7 @@ export class VideoLibrary { rate: element.playbackRate, seeking: element.seeking, speed: drive?.reading?.speed ?? 0, + landing: drive?.landing ?? false, })); } @@ -313,14 +305,33 @@ export class VideoLibrary { return player; } - /** Once a seek is done, the newest one asked for meanwhile follows. */ + /** + * Once a seek is done, the newest one asked for meanwhile follows. When none does, the seek lands + * with the next frame Chrome hands over: at `seeked` the picture was not there yet, and Pixi, which + * loads a paused video's picture only then, kept the one before it for a frame. Chrome hands over + * no frame for some seeks, though, and a player waiting for one stayed empty until the next jump; + * after `LAND_MS` it lands without. + */ private chase(player: Player, drive: Drive): void { const { element } = player; + let round = 0; element.addEventListener('seeked', () => { + if (player.gone) return; const wanted = drive.wanted; drive.wanted = null; - if (!player.gone && wanted !== null && Math.abs(wanted - element.currentTime) > SEEK_SLACK) + if (wanted !== null && Math.abs(wanted - element.currentTime) > SEEK_SLACK) { element.currentTime = wanted; + return; + } + if (!drive.landing) return; + const now = ++round; + const land = () => { + if (player.gone || now !== round || element.seeking || !drive.landing) return; + player.texture?.source.update(); + drive.landing = false; + }; + element.requestVideoFrameCallback(land); + setTimeout(land, LAND_MS); }); } diff --git a/tests/viewport/video-library.test.ts b/tests/viewport/video-library.test.ts index 021da8e7..33086f71 100644 --- a/tests/viewport/video-library.test.ts +++ b/tests/viewport/video-library.test.ts @@ -1,3 +1,4 @@ +import type * as Pixi from 'pixi.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /** @@ -6,6 +7,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; * 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(); + 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; @@ -155,6 +167,52 @@ describe('VideoLibrary driven players (#404)', () => { 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 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', false).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', false); + expect(videos.showsAsked(key)).toBe(false); + seekDone(); + vi.advanceTimersByTime(200); + vi.useRealTimers(); + expect(videos.showsAsked(key)).toBe(true); + }); + 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 c93977bf..8491a068 100644 --- a/tools/e2e/66-video.mjs +++ b/tools/e2e/66-video.mjs @@ -533,6 +533,29 @@ export async function video(t) { JSON.stringify(cutFrames), ); + // 66r. Cut in and out fast, at times a hair apart: a seek that brought no new frame of the video + // left its player waiting for one for good, and the sprite stayed empty until the next jump. + const cuts = [0.7, 1.31, 0.05, 1.62, 0.4, 1.9, 1.05, 0.25, 1.48, 0.9]; + for (const [i, time] of cuts.entries()) { + await cutLate(false, 0.2); + await sleep([0, 30, 90][i % 3]); + await cutLate(true, time); + await sleep([60, 0, 140][i % 3]); + await cutLate(true, time + 0.01); + await sleep([20, 120, 0][i % 3]); + } + await cutLate(true, 1.2); + await sleep(40); + await cutLate(true, 1.21); + await sleep(1500); + const settled = (await json('__gm.videos()')).filter((v) => v.key.includes(LATE)); + const settledColor = await colorAt(lateRect); + check( + 'cut in and out fast, a driven sprite still ends on its picture and no player waits on', + settledColor === COLORS.blue && settled.length > 0 && settled.every((v) => !v.landing), + JSON.stringify({ settledColor, settled }), + ); + await mutate(` const p = __gm.state.project; const gone = ['${OBJECT}', '${EARLY}', '${LATE}'];