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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-14 13:56:56 +02:00
1 parent 87b2ee9402
commit abb8fa51d8
10 files changed
+474 -94

No files matched your search

+6
View File
@@ -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;
+13 -2
View File
@@ -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,
};
}
}
+9 -7
View File
@@ -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<void>((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(() => {
+73
View File
@@ -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;
}
}
+56
View File
@@ -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<number, () => void>();
let lastId = 0;
function timerWorker(): Worker | null {
if (typeof Worker === 'undefined') return null;
if (!worker) {
worker = new TimerWorker();
worker.onmessage = (event: MessageEvent<number>) => 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<void> {
return new Promise((resolve) => after(ms, resolve));
}
+30
View File
@@ -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<number, ReturnType<typeof setTimeout>>();
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),
);
};
+7 -1
View File
@@ -1,3 +1,5 @@
import { after } from './backgroundTimers';
/**
* Seeking a `<video>` that is asked for a new moment faster than it can decode one, as while
* scrubbing. Every seek set while one still runs aborts it, and a 1080p video moved by the mouse
@@ -9,6 +11,8 @@
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;
/** The same behind another tab, where Chrome hands over no frame at all and the picture is there at `seeked`. */
const HIDDEN_LAND_MS = 10;
/** One element's seeks, see `seekVideo` and `chaseSeeks`. */
export interface SeekChase {
@@ -76,6 +80,8 @@ export function chaseSeeks(
};
if (early !== null) return land(early);
element.requestVideoFrameCallback((_now, frame) => land(frame.mediaTime));
setTimeout(() => land(null), LAND_MS);
// A worker's timer: behind another tab Chrome holds the page's own to once a second, and a
// recording not in real time waits for every landing.
after(typeof document !== 'undefined' && document.hidden ? HIDDEN_LAND_MS : LAND_MS, () => land(null));
});
}