From abb8fa51d8021a8e9b74c0c932fd1d4bcb9c29a1 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 14 Oct 2026 13:56:56 +0200 Subject: [PATCH] Keep a recording going behind another browser tab Chrome stops calling requestAnimationFrame for a hidden tab, and Pixi's ticker hangs on it. The run, the drawing and the recording all stood still as soon as the user switched to another tab. A plain run keeps standing still there, so a forgotten game does not play on unheard. - backgroundBeat.ts ticks the editor from a worker at the recording's fps while a recording goes and the tab is hidden, or the page has not drawn a frame of its own for 250 ms. Once the page draws again, the beat keeps out of the way. - The worker looks every 4 ms whether a tick is due. A 16 ms interval came in bursts behind a busy page, and the recording threw away the second tick of each burst. - backgroundTimers.ts and timerWorker.ts give the short waits of a recording timers in a worker. Chrome slows the page's own timers to once a second in a hidden tab. This covers the waits while saving and the wait for a video's picture after a seek. - Behind a tab Chrome does not call requestVideoFrameCallback, but the picture is there at seeked. The wait there is 10 ms instead of 150 ms while the tab is hidden. - The beat runs for Start recording, the exported game and Start recording... in the main menu. Tests: - tsc, eslint, Prettier clean - tests/viewport: 374 green, 3 new in background-beat.test.ts - e2e 68k (3 checks) on melektaus-atx with the tab really hidden: not in real time all 120 frames, colour changes every 30 frames, the blip 8 ms from green; in real time 97 of 120 frames (73 with the tab in front) - Counter-check without the beat: all three 68k checks red, 3 frames in 60 s not in real time, 7 frames in real time - Gate on melektaus-atx: green, 311 files, 4821 tests Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/editor/EditorRecorder.ts | 6 + src/viewport/GameRecorder.ts | 15 +- src/viewport/RecordingTake.ts | 16 +- src/viewport/backgroundBeat.ts | 73 +++++++ src/viewport/backgroundTimers.ts | 56 +++++ src/viewport/timerWorker.ts | 30 +++ src/viewport/videoSeek.ts | 8 +- tests/viewport/background-beat.test.ts | 83 ++++++++ tools/e2e/68-composition.mjs | 279 +++++++++++++++++-------- 10 files changed, 474 insertions(+), 94 deletions(-) create mode 100644 src/viewport/backgroundBeat.ts create mode 100644 src/viewport/backgroundTimers.ts create mode 100644 src/viewport/timerWorker.ts create mode 100644 tests/viewport/background-beat.test.ts diff --git a/README.md b/README.md index 9e8818d0..21ee1a05 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, 2083 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2086 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/editor/EditorRecorder.ts b/src/editor/EditorRecorder.ts index 7aad90b2..ca685a58 100644 --- a/src/editor/EditorRecorder.ts +++ b/src/editor/EditorRecorder.ts @@ -21,6 +21,7 @@ import { Take, type TakeInfo, } from '../viewport/RecordingTake'; +import { BackgroundBeat } from '../viewport/backgroundBeat'; /** What the editor recorder needs of the editor. */ export interface EditorRecorderHost { @@ -44,6 +45,8 @@ export interface EditorRecorderHost { export class EditorRecorder { private take: Take | null = null; private source: CanvasSource | null = null; + /** Ticks the editor on while it is behind another tab and records. */ + private beat: BackgroundBeat | null = null; /** Every recording of this page, newest last (test hook). */ private readonly takes: Take[] = []; private seen = -1; @@ -96,6 +99,8 @@ export class EditorRecorder { this.host.showBadge(true); // After the render loop (`UPDATE_PRIORITY.LOW`): the canvas holds this tick's picture then. app.ticker.add(this.tick, this, UPDATE_PRIORITY.UTILITY); + this.beat ??= new BackgroundBeat(app.ticker); + this.beat.start(settings.fps); requestRender(); } @@ -112,6 +117,7 @@ export class EditorRecorder { this.source = null; const app = ui().app; app.ticker.remove(this.tick, this); + this.beat?.stop(); if (this.screenResolution !== null) { app.renderer.resize(Math.round(app.screen.width), Math.round(app.screen.height), this.screenResolution); this.screenResolution = null; diff --git a/src/viewport/GameRecorder.ts b/src/viewport/GameRecorder.ts index 573f03d5..091b5464 100644 --- a/src/viewport/GameRecorder.ts +++ b/src/viewport/GameRecorder.ts @@ -9,6 +9,7 @@ import { } from '../core/recording/recording'; import type { AudioSwitch } from '../runtime/audioSwitch'; import { ui } from '../ui'; +import { BackgroundBeat } from './backgroundBeat'; import { PictureReader } from './PictureReader'; import { type FrameSource, type MadeRecording, Take, type TakeHost, type TakeInfo } from './RecordingTake'; import { type HeardVideo, SteadySound } from './SteadySound'; @@ -46,6 +47,8 @@ export class GameRecorder implements RecordingSink { private paused = false; /** Every recording of this page, newest last (test hook). */ private readonly takes: Take[] = []; + /** Ticks the page on while it is behind another tab and records. */ + private beat: BackgroundBeat | null = null; constructor(private readonly host: RecordingHost) {} @@ -86,6 +89,7 @@ export class GameRecorder implements RecordingSink { host, () => { if (this.take === take) this.take = null; + if (!this.take) this.beat?.stop(); if (!steady) return; videoLibrary.setStepped(false); audio?.restore(); @@ -97,6 +101,8 @@ export class GameRecorder implements RecordingSink { if (this.paused) take.setPaused(true); if (steady) videoLibrary.setStepped(true); if (audio && sound) audio.divert(sound.library); + this.beat ??= new BackgroundBeat(ui().app.ticker); + this.beat.start(settings.fps); return ''; } @@ -142,8 +148,13 @@ export class GameRecorder implements RecordingSink { this.stop(); } - info(): { recording: boolean; takes: TakeInfo[] } { - return { recording: this.isRecording, takes: this.takes.map((take) => take.info()) }; + /** `beats`: the ticks given behind another tab, of every recording so far. */ + info(): { recording: boolean; takes: TakeInfo[]; beats: number } { + return { + recording: this.isRecording, + takes: this.takes.map((take) => take.info()), + beats: this.beat?.ticks ?? 0, + }; } } diff --git a/src/viewport/RecordingTake.ts b/src/viewport/RecordingTake.ts index 5490d0a9..32f21479 100644 --- a/src/viewport/RecordingTake.ts +++ b/src/viewport/RecordingTake.ts @@ -20,6 +20,7 @@ import { videoBitrate, vp9Codec, } from '../core/recording/recording'; +import { after, pause } from './backgroundTimers'; import { soundLibrary } from './SoundLibrary'; import type { HeardVideo, SteadySound } from './SteadySound'; @@ -623,7 +624,7 @@ export class Take { for (;;) { this.source.collect((frame) => this.encode(frame)); if (!this.source.pending || performance.now() > until) return; - await new Promise((resolve) => setTimeout(resolve, 4)); + await pause(4); } } @@ -640,15 +641,16 @@ export class Take { this.soundLimit = Math.round(pace.seconds * tap.rate); sound.renderTo(pace.seconds); const until = performance.now() + LAST_SOUND_MS; - while (sound.made < pace.seconds && performance.now() < until) - await new Promise((resolve) => setTimeout(resolve, 4)); + while (sound.made < pace.seconds && performance.now() < until) await pause(4); } return new Promise((resolve) => { - const timer = setTimeout(done, LAST_SOUND_MS); - function done(): void { - clearTimeout(timer); + let waits = true; + const done = (): void => { + if (!waits) return; + waits = false; resolve(); - } + }; + after(LAST_SOUND_MS, done); this.soundEnded = done; tap.node.port.postMessage('stop'); }).finally(() => { diff --git a/src/viewport/backgroundBeat.ts b/src/viewport/backgroundBeat.ts new file mode 100644 index 00000000..4cd1823c --- /dev/null +++ b/src/viewport/backgroundBeat.ts @@ -0,0 +1,73 @@ +import { type Ticker, UPDATE_PRIORITY } from 'pixi.js'; +import { every } from './backgroundTimers'; + +/** + * How long the page may go without a frame of its own before the beat takes over. A window behind + * others stops the frames too, and need not say the page is hidden. + */ +export const STALL_MS = 250; +/** + * How often the beat looks whether a tick is due. A worker's interval of a whole frame came in + * bursts behind a busy page, two ticks at once and then none, and a recording in real time missed + * the frames between. + */ +const POLL_MS = 4; + +/** Whether the beat ticks now: the page is hidden, or has drawn no frame of its own for a while. */ +export function beatDue(hidden: boolean, sinceOwnFrame: number): boolean { + return hidden || sinceOwnFrame > STALL_MS; +} + +/** + * Keeps a ticker going while the page draws no frames of its own. Behind another tab Chrome stops + * `requestAnimationFrame`, and with it the run, the drawing and a recording of them; a worker's beat + * ticks it instead, at the recording's rate. Once the page draws again, the beat stands aside. + */ +export class BackgroundBeat { + private stopBeat: (() => void) | null = null; + private ownFrameAt = 0; + private period = 0; + private nextAt = 0; + private driving = false; + /** The ticks the beat gave (test hook). */ + ticks = 0; + + constructor(private readonly ticker: Ticker) {} + + private readonly seen = (): void => { + if (!this.driving) this.ownFrameAt = performance.now(); + }; + + start(fps: number): void { + this.stop(); + this.ownFrameAt = performance.now(); + this.period = 1000 / fps; + this.ticker.add(this.seen, undefined, UPDATE_PRIORITY.UTILITY); + this.stopBeat = every(POLL_MS, () => this.beat()); + } + + stop(): void { + if (!this.stopBeat) return; + this.stopBeat(); + this.stopBeat = null; + this.ticker.remove(this.seen); + } + + private beat(): void { + const now = performance.now(); + if (!beatDue(document.hidden, now - this.ownFrameAt)) { + this.nextAt = now; + return; + } + if (now < this.nextAt) return; + // A frame on from the last; a page that fell behind starts afresh rather than catching up at once. + this.nextAt = Math.max(this.nextAt + this.period, now + this.period / 2); + this.driving = true; + try { + this.ticker.update(now); + } finally { + this.driving = false; + } + this.ticks += 1; + } +} diff --git a/src/viewport/backgroundTimers.ts b/src/viewport/backgroundTimers.ts new file mode 100644 index 00000000..b47ff75f --- /dev/null +++ b/src/viewport/backgroundTimers.ts @@ -0,0 +1,56 @@ +import TimerWorker from './timerWorker.ts?worker'; + +/** + * Timers for what has to go on behind another tab, a recording above all. Chrome holds a hidden + * page's own timers to once a second, and a chain of them to once a minute after five; a worker's + * keep their pace (#332 found it for the music, see `wake.ts`). Where there are no workers, the + * page's own. + */ +let worker: Worker | null = null; +const waiting = new Map void>(); +let lastId = 0; + +function timerWorker(): Worker | null { + if (typeof Worker === 'undefined') return null; + if (!worker) { + worker = new TimerWorker(); + worker.onmessage = (event: MessageEvent) => waiting.get(event.data)?.(); + } + return worker; +} + +/** Calls `fn` every `ms` until the function it returns is called. */ +export function every(ms: number, fn: () => void): () => void { + const timers = timerWorker(); + if (!timers) { + const timer = setInterval(fn, ms); + return () => clearInterval(timer); + } + const id = ++lastId; + waiting.set(id, fn); + timers.postMessage({ id, ms, repeat: true }); + return () => { + if (!waiting.delete(id)) return; + timers.postMessage({ id, ms: 0, repeat: false }); + }; +} + +/** Calls `fn` once, `ms` from now. */ +export function after(ms: number, fn: () => void): void { + const timers = timerWorker(); + if (!timers) { + setTimeout(fn, ms); + return; + } + const id = ++lastId; + waiting.set(id, () => { + waiting.delete(id); + fn(); + }); + timers.postMessage({ id, ms: Math.max(1, ms), repeat: false }); +} + +/** Resolves `ms` from now. */ +export function pause(ms: number): Promise { + return new Promise((resolve) => after(ms, resolve)); +} diff --git a/src/viewport/timerWorker.ts b/src/viewport/timerWorker.ts new file mode 100644 index 00000000..2b2d8364 --- /dev/null +++ b/src/viewport/timerWorker.ts @@ -0,0 +1,30 @@ +/** + * The timers of `backgroundTimers.ts`: a worker's keep their pace in a hidden tab, where the page's + * own are held to once a second. `{ id, ms, repeat }` starts one, `{ id, ms: 0 }` ends it; each time + * it fires, its id comes back. + */ +const timers = new Map>(); + +self.onmessage = (event: MessageEvent<{ id: number; ms: number; repeat: boolean }>) => { + const { id, ms, repeat } = event.data; + const running = timers.get(id); + if (running !== undefined) { + clearTimeout(running); + clearInterval(running); + timers.delete(id); + } + if (ms <= 0) return; + if (repeat) + timers.set( + id, + setInterval(() => self.postMessage(id), ms), + ); + else + timers.set( + id, + setTimeout(() => { + timers.delete(id); + self.postMessage(id); + }, ms), + ); +}; diff --git a/src/viewport/videoSeek.ts b/src/viewport/videoSeek.ts index ec9d4e66..c81c7aa0 100644 --- a/src/viewport/videoSeek.ts +++ b/src/viewport/videoSeek.ts @@ -1,3 +1,5 @@ +import { after } from './backgroundTimers'; + /** * Seeking a `