diff --git a/src/viewport/VideoLibrary.ts b/src/viewport/VideoLibrary.ts index 1bd6305f..bed248a4 100644 --- a/src/viewport/VideoLibrary.ts +++ b/src/viewport/VideoLibrary.ts @@ -36,6 +36,15 @@ export class VideoLibrary { private heard = false; private paused = false; private sweeper: ReturnType | null = null; + /** When the page was last shown, by `performance.now()`; see `sweep`. */ + private shown = 0; + + constructor() { + if (typeof document === 'undefined') return; + document.addEventListener('visibilitychange', () => { + if (!document.hidden) this.shown = performance.now(); + }); + } /** Runs `fn` whenever a player has its first picture; returns the unsubscribe. */ onChange(fn: () => void): () => void { @@ -85,12 +94,16 @@ export class VideoLibrary { this.stopSweeping(); } - /** Drops the players nobody asked for lately; a paused run asks for nothing and keeps them. */ + /** + * Drops the players nobody asked for lately; a paused run asks for nothing and keeps them. So + * does a hidden page: Chrome draws no frames for it while its timers go on, and a sprite that + * still wears a video would lose it. The time hidden does not count as idle. + */ private sweep(): void { - if (this.paused) return; + if (this.paused || document.hidden) return; const now = performance.now(); for (const [key, player] of this.players) - if (now - player.asked > IDLE_MS) { + if (now - Math.max(player.asked, this.shown) > IDLE_MS) { this.drop(player); this.players.delete(key); } @@ -181,12 +194,14 @@ export class VideoLibrary { player.element.load(); // The texture itself stays whole: a wearer that has not synced since still draws it, and a // destroyed one has no source and threw in the batcher. It shows nothing until the wearer asks - // again; only the video behind it is let go. + // again. The video source is only unloaded, not destroyed: a shader may still hold it until + // its wearer syncs, and Pixi warns about every destroyed source still bound to one. const texture = player.texture; if (!texture) return; - const video = texture.source; + const video = texture.source as VideoSource; texture.source = Texture.EMPTY.source; - video.destroy(); + video.autoUpdate = false; + video.unload(); } private notify(): void { diff --git a/tests/viewport/video-library.test.ts b/tests/viewport/video-library.test.ts new file mode 100644 index 00000000..3eb3cb89 --- /dev/null +++ b/tests/viewport/video-library.test.ts @@ -0,0 +1,86 @@ +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`; + +function fakeVideo() { + 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([]); + }); +}); diff --git a/tools/ci/router.php b/tools/ci/router.php new file mode 100644 index 00000000..6238181f --- /dev/null +++ b/tools/ci/router.php @@ -0,0 +1,45 @@ + $end) { + http_response_code(416); + header("Content-Range: bytes */$size"); + return true; + } + http_response_code(206); + header("Content-Range: bytes $start-$end/$size"); +} +header('Content-Length: ' . ($end - $start + 1)); +if ($_SERVER['REQUEST_METHOD'] === 'HEAD') { + return true; +} +$in = fopen($file, 'rb'); +$out = fopen('php://output', 'wb'); +stream_copy_to_stream($in, $out, $end - $start + 1, $start); +return true; diff --git a/tools/ci/serve.sh b/tools/ci/serve.sh index b847d2bc..3a967f10 100644 --- a/tools/ci/serve.sh +++ b/tools/ci/serve.sh @@ -40,8 +40,8 @@ game_maker_serve() { # game-maker-video, here a process beside the server. php "$root/tools/video-worker.php" "$root/data" >/tmp/video-worker.log 2>&1 & # Without workers the single-threaded dev server stalls: the editor fetches project and - # textures in parallel. - PHP_CLI_SERVER_WORKERS=8 php -S 0.0.0.0:8080 -t /srv >/tmp/php-server.log 2>&1 & + # textures in parallel. The router serves videos with Range requests, see there. + PHP_CLI_SERVER_WORKERS=8 php -S 0.0.0.0:8080 -t /srv "$root/tools/ci/router.php" >/tmp/php-server.log 2>&1 & for _ in $(seq 60); do curl -sf "${GAME_MAKER_BASE_URL}api/index.php?r=auth/session" >/dev/null 2>&1 && break