Keep a recording behind another tab from dropping its frames
feuerwerk / gate (push) Successful in 4m52s
feuerwerk / suite (push) Successful in 19m5s

In CI run 699 check 68k (real time behind a tab) got 21 of 120 frames.
The beat ticked 100 times, but 83 frames were dropped as busy. Late in
a long browser session the GPU behind a hidden tab took up to 1.5 s to
finish a read back, so all three read-back buffers were still on their
way when the next frame was due. Locally on melektaus-atx this showed
only in the full run of modules 01 to 68, and not in every one.

- The render loop no longer draws the screen while the page is hidden.
  Nobody sees it there, and the drawing was competing with the read
  backs for the slow GPU.
- A recording of the editor copies the canvas, so it turns the drawing
  back on with `drawWhileHidden` while it runs.
- `PictureReader.collect` reads the oldest buffer at once when the page
  is hidden and no buffer is free, instead of dropping the frame. The
  main thread waits for the GPU there, which nobody sees either.
- More buffers are no fix: with 30 of them the frames piled up at the
  encoder instead (45 frames, 56 dropped).

Tests:
- render.test.ts: 18/18, the new test is red without the hidden check
- Full run of modules 01 to 68 on melektaus-atx with a counting probe:
  read at once only 18 frames, 63 dropped; both parts 106 frames,
  2 dropped
- e2e 67 and 68 on melektaus-atx without probe: 68k green, 97 frames,
  0 dropped. Red there only "a clip that starts later", the known
  timing wobble from task 697
- Gate on melektaus-atx: CHECK EXIT 0, 313 files, 4841 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-15 08:02:08 +02:00
1 parent 406839baea
commit 2f13f9a0b9
5 files changed
+53 -2

No files matched your search

+3 -1
View File
@@ -13,7 +13,7 @@ import {
recordingName,
type RecordingUploadOptions,
} from '../core/recording/recording';
import { renderCount, requestRender, Theme, toHex, ui } from '../ui';
import { drawWhileHidden, renderCount, requestRender, Theme, toHex, ui } from '../ui';
import {
consoleLine,
type FrameSource,
@@ -101,6 +101,7 @@ export class EditorRecorder {
app.ticker.add(this.tick, this, UPDATE_PRIORITY.UTILITY);
this.beat ??= new BackgroundBeat(app.ticker);
this.beat.start(settings.fps);
drawWhileHidden(true);
requestRender();
}
@@ -118,6 +119,7 @@ export class EditorRecorder {
const app = ui().app;
app.ticker.remove(this.tick, this);
this.beat?.stop();
drawWhileHidden(false);
if (this.screenResolution !== null) {
app.renderer.resize(Math.round(app.screen.width), Math.round(app.screen.height), this.screenResolution);
this.screenResolution = null;
+1
View File
@@ -24,6 +24,7 @@ export { initUI, ui, type UIContext } from './UIContext';
export {
afterFrames,
cancelFrameWork,
drawWhileHidden,
initRenderLoop,
onFrame,
isRendering,
+13
View File
@@ -22,6 +22,7 @@ const WAKE_MS = 200;
let deadline = 0;
let rendered = 0;
let renderMs = 0;
let drawHidden = false;
/** Draw, and keep drawing for a moment. Cheap enough to call from every event. */
export function requestRender(): void {
@@ -43,6 +44,16 @@ export function lastRenderTime(): number {
return renderMs;
}
/**
* Whether the screen is drawn while the page is hidden. It is not, by default: behind another tab
* only a recording's beat ticks the page, nobody sees the canvas, and late in a long session the
* GPU there was slow enough that drawing it cost a recording in real time most of its frames. A
* recording of the editor copies the canvas, so it keeps the drawing on.
*/
export function drawWhileHidden(on: boolean): void {
drawHidden = on;
}
/** Work put off to the next frame by `onFrame`, one entry per key. */
let frameWork = new Map<string, () => void>();
@@ -114,6 +125,7 @@ export function initRenderLoop(app: Application): void {
frameWork = new Map();
for (const fn of due.values()) fn();
}
if (!drawHidden && typeof document !== 'undefined' && document.hidden) return;
rendered += 1;
const start = performance.now();
app.render();
@@ -134,6 +146,7 @@ export function resetRenderLoop(): void {
deadline = 0;
rendered = 0;
renderMs = 0;
drawHidden = false;
frameWaiters = [];
frameWork = new Map();
}
+9 -1
View File
@@ -180,12 +180,20 @@ export class PictureReader {
/**
* Hands over each frame the GPU has finished, oldest first, as I420 in `layout`. The bytes are
* reused for the next frame, so `take` copies what it keeps.
*
* Behind another tab, late in a long session, the GPU can take over a second to finish a read
* back; with every buffer still on its way, nearly each frame due was dropped. There the oldest is
* read at once when no buffer is free: the main thread waits for the GPU, but nobody sees it.
*/
collect(take: (bytes: Uint8Array, ms: number) => void): void {
const gl = this.gl;
let hurry = document.hidden && this.free.length === 0;
while (this.inFlight.length > 0) {
const next = this.inFlight[0]!;
if (gl.clientWaitSync(next.fence, 0, 0) === gl.TIMEOUT_EXPIRED) return;
if (gl.clientWaitSync(next.fence, 0, 0) === gl.TIMEOUT_EXPIRED) {
if (!hurry) return;
hurry = false;
}
gl.deleteSync(next.fence);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, next.buffer);
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, this.bytes);