Keep a playing video when the window comes back to the front (#402)

Chrome draws no frames for a hidden page, so the scene stopped asking
for its videos. The sweeper of the VideoLibrary kept running and
dropped every player after 2 s. Back in the window, the video started
over, and Pixi warned that a texture source was destroyed while still
bound to a shader.

- The sweeper skips a hidden page. The time hidden does not count as
  idle, the 2 s start once the page is shown again.
- A dropped player only unloads its video source instead of destroying
  it. A shader that still holds the source no longer warns.
- CI serves videos through a new router for php -S. The built-in
  server answers no Range requests, so Chrome could not seek. e2e 66
  had 4 red checks in the suite job of d48f174 because of it. nginx on
  stein2 hid this.

Tests:
- Unit video-library.test.ts (new): 3 tests
- e2e 66 (atx): ALL PASSED, 18 checks
- Router checked with curl: 206 with the right bytes
- Counter-check: the old sweep fails both hidden-page tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-11 12:32:59 +02:00
1 parent d48f174caf
commit c815d2c35f
4 files changed
+154 -8

No files matched your search

+21 -6
View File
@@ -36,6 +36,15 @@ export class VideoLibrary {
private heard = false;
private paused = false;
private sweeper: ReturnType<typeof setInterval> | 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 {
+86
View File
@@ -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([]);
});
});
+45
View File
@@ -0,0 +1,45 @@
<?php
// Router of the CI jobs' dev server (see serve.sh). `php -S` answers no Range requests, and Chrome
// cannot seek in a video served without them (#402): a clip stayed at the video's first frame. On
// stein2 nginx does this. Here the converted videos get their ranges; everything else is left to
// the server as before.
$path = rawurldecode((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH));
if (!preg_match('#^/game-maker/data/videos/[0-9a-z]+/[0-9a-f]{64}\.mp4$#', $path)) {
return false;
}
$file = $_SERVER['DOCUMENT_ROOT'] . $path;
if (!is_file($file)) {
return false;
}
$size = filesize($file);
$start = 0;
$end = $size - 1;
header('Content-Type: video/mp4');
header('Accept-Ranges: bytes');
if (preg_match('/^bytes=(\d*)-(\d*)$/', $_SERVER['HTTP_RANGE'] ?? '', $range)) {
if ($range[1] === '') {
$start = max(0, $size - (int) $range[2]);
} else {
$start = (int) $range[1];
if ($range[2] !== '') {
$end = min($end, (int) $range[2]);
}
}
if ($start > $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;
+2 -2
View File
@@ -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