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:
1 parent
87b2ee9402
commit
abb8fa51d8
10 files changed
+474
-94
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user