Say when a recording starts and when it cannot keep up

A 4K60 game view recording on the user's machine had 517 of 827
frames. The rest were skipped without a word: when the editor's tick
came late, FrameGate let the due slots pass, and only frames lost to a
busy encoder or read back counted as dropped. The file kept its time
but stuttered, and nothing said so.

- FrameGate counts the frames it took and the slots the recording's
  time has room for. The difference is skipped, next to dropped in
  __gm.recording().
- FramePace judges the missed frames over spans of 2 s. The first
  time more than 10 % are missed, the log warns once with the frames
  a second the recording really got.
- At the end, when more than 5 % are missed, a warning gives the
  frames of the whole file and what helps (30 fps or a smaller
  resolution). It comes after the upload's notice, which would cover
  it at once in the editor.
- The first second counts for neither warning. Without that, a short
  e2e recording warned about 3 frames lost while starting.
- A recording that starts logs an info line with its size, fps, codec
  and sound. TakeHost.log takes a level for it. The editor recording
  keeps the line to the console, its notice would be in the video.
- consoleLine is the one place with an eslint exception for
  console.info, so the console panel shows the line plain.

Found along the way: headless Chrome in WSL records the editor with
real stutter in e2e 67 (1280x800@60 27 %, 2304x1440@30 12-14 %). That
is the software encoder there, and only now shown.

Tests:
- Gate on melektaus-atx: green, 309 files, 4810 tests.
- Unit recording.test.ts: 36 green, 6 new for FrameGate.skipped and
  FramePace. With skipped stuck at 0 the test turns red.
- e2e 67 on melektaus-atx: ALL PASSED, 15 checks, run twice. The new
  67b-ii slows the editor to about 20 fps: a 640x360@60 recording
  says "about 19 of 60 fps" and saves with 95 of 192 frames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-14 11:21:46 +02:00
1 parent 4d0a696327
commit 77365f17d3
9 files changed
+283 -17

No files matched your search

+76
View File
@@ -250,6 +250,9 @@ export class RecordingClock {
*/
export class FrameGate {
private next = 0;
/** Frames taken so far, and the recording's time of the last. */
private taken = 0;
private lastMs = 0;
constructor(private readonly fps: number) {}
@@ -257,8 +260,81 @@ export class FrameGate {
const period = 1000 / this.fps;
if (ms < this.next - period / 4) return false;
this.next = Math.max(this.next + period, ms + period / 2);
this.taken++;
this.lastMs = ms;
return true;
}
/** The frames the recording's time up to the last one taken has room for. */
get slots(): number {
return this.taken === 0 ? 0 : Math.max(this.taken, Math.round((this.lastMs * this.fps) / 1000) + 1);
}
/**
* The frames no drawn frame came for: the screen drew too late or too seldom. The video holds the
* frame before over them, so it stutters, though its time stays right.
*/
get skipped(): number {
return this.slots - this.taken;
}
}
/** How long a recording runs before it is judged: its first moments may stall while it starts. */
const PACE_SETTLE_MS = 1000;
/** How much of its time a recording is judged over at once. */
const PACE_SPAN_MS = 2000;
/** The share of frames a recording may miss over a span before it is said to fall behind. */
export const PACE_MOST_MISSED_WHILE = 0.1;
/** The share of frames a whole recording may miss, once settled, before its end says so. */
const PACE_MOST_MISSED = 0.05;
/** Where a recording stood: its time, and the frames it had missed by then. */
interface PacePoint {
ms: number;
missed: number;
}
/**
* Whether a recording keeps up with its fps: told once, the first time it misses more than
* `PACE_MOST_MISSED_WHILE` of the frames over a span, and at its end, when it missed more than
* `PACE_MOST_MISSED` of them. Both leave out its first moments.
*/
export class FramePace {
private settled: PacePoint | null = null;
private last: PacePoint | null = null;
private from: PacePoint | null = null;
private told = false;
constructor(private readonly fps: number) {}
/**
* At the recording's time `ms`, with `missed` frames missed so far: the frames a second it got
* over the span just ended, if that is the first time it fell behind; null otherwise.
*/
behind(ms: number, missed: number): number | null {
if (ms < PACE_SETTLE_MS) return null;
this.settled ??= { ms, missed };
this.last = { ms, missed };
if (this.told) return null;
if (!this.from) {
this.from = { ms, missed };
return null;
}
const span = ms - this.from.ms;
if (span < PACE_SPAN_MS) return null;
const share = (missed - this.from.missed) / ((span * this.fps) / 1000);
this.from = { ms, missed };
if (share <= PACE_MOST_MISSED_WHILE) return null;
this.told = true;
return Math.max(0, Math.round(this.fps * (1 - share)));
}
/** Whether the recording, once settled, missed so many frames that it stutters. */
get stutters(): boolean {
const { settled, last } = this;
if (!settled || !last || last.ms <= settled.ms) return false;
return (last.missed - settled.missed) / (((last.ms - settled.ms) * this.fps) / 1000) > PACE_MOST_MISSED;
}
}
/**
+10 -2
View File
@@ -14,7 +14,13 @@ import {
type RecordingUploadOptions,
} from '../core/recording/recording';
import { renderCount, requestRender, Theme, toHex, ui } from '../ui';
import { type FrameSource, type MadeRecording, Take, type TakeInfo } from '../viewport/RecordingTake';
import {
consoleLine,
type FrameSource,
type MadeRecording,
Take,
type TakeInfo,
} from '../viewport/RecordingTake';
/** What the editor recorder needs of the editor. */
export interface EditorRecorderHost {
@@ -65,7 +71,9 @@ export class EditorRecorder {
const source = new CanvasSource(app.canvas, plan.size, settings.fps, settings.pointer);
const upload = { convert: settings.convert, adopt: settings.convert && settings.adopt };
const host = {
log: (line: string) => this.host.log(line),
// The notice would be in the recording: a line that warns of nothing stays in the console.
log: (line: string, level: 'info' | 'warn' = 'warn') =>
level === 'info' ? consoleLine(line, level) : this.host.log(line),
saved: (recording: MadeRecording) => this.host.saved(recording, upload),
};
const name = recordingName(settings.name, new Date());
+3 -3
View File
@@ -18,7 +18,7 @@ import type { World } from '../runtime/World';
import { onFrame, ui } from '../ui';
import { soundLibrary } from '../viewport/SoundLibrary';
import { GameRecorder } from '../viewport/GameRecorder';
import type { MadeRecording } from '../viewport/RecordingTake';
import { consoleLine, type MadeRecording } from '../viewport/RecordingTake';
import type { RecordingUploadOptions } from '../core/recording/recording';
import { videoLibrary } from '../viewport/VideoLibrary';
import { FrameMeter, type FrameTimes } from './frameMeter';
@@ -94,9 +94,9 @@ export class PlaySession {
) {
this.recorder = new GameRecorder({
view: () => host.gameView,
log: (line) => {
log: (line, level) => {
this.world?.log.push(line);
console.warn('[recording]', line);
consoleLine(line, level);
},
saved: (recording, options) => host.recordingSaved(recording, options),
});
+3 -2
View File
@@ -8,6 +8,7 @@ import { behaviorError } from '../runtime/systems/BehaviorSystem';
import { Input } from '../runtime/systems/Input';
import { downloadBlob, requestRender } from '../ui';
import { GameRecorder } from '../viewport/GameRecorder';
import { consoleLine } from '../viewport/RecordingTake';
import { soundLibrary } from '../viewport/SoundLibrary';
import { videoLibrary } from '../viewport/VideoLibrary';
import type { GameScreen } from './GameScreen';
@@ -33,9 +34,9 @@ export class Player {
screen.onStart = () => this.start();
this.recorder = new GameRecorder({
view: () => screen,
log: (line) => {
log: (line, level) => {
this.run?.world.log.push(line);
console.warn('[recording]', line);
consoleLine(line, level);
},
saved: (recording) => downloadBlob(recording.blob, `${recording.name}.mkv`),
});
+2 -2
View File
@@ -23,7 +23,7 @@ export interface RecordingView {
/** Where the recorder looks, and what it tells; `saved` gets what the node asked to become of it. */
export interface RecordingHost {
view(): RecordingView | null;
log(line: string): void;
log(line: string, level?: 'info' | 'warn'): void;
saved(recording: MadeRecording, options: RecordingUploadOptions): void;
}
@@ -64,7 +64,7 @@ export class GameRecorder implements RecordingSink {
const source = new ViewSource(name, settings, this.host);
const upload = { convert: settings.convert, adopt: settings.convert && settings.adopt };
const host: TakeHost = {
log: (line) => this.host.log(line),
log: (line, level) => this.host.log(line, level),
saved: (recording) => this.host.saved(recording, upload),
};
const take = new Take(
+56 -7
View File
@@ -11,6 +11,7 @@ import {
import {
avcCodec,
FrameGate,
FramePace,
isKeyFrame,
RECORDING_MOST_BYTES,
RecordingClock,
@@ -42,11 +43,22 @@ export interface MadeRecording {
/** What a take tells. */
export interface TakeHost {
/** A line for whoever started it: the log of the run, or the editor's notice. */
log(line: string): void;
/**
* A line for whoever started it: the log of the run, or the editor's notice. An `info` line is
* no warning; the editor keeps it to its console, its notice would be in the recording.
*/
log(line: string, level?: 'info' | 'warn'): void;
saved(recording: MadeRecording): void;
}
/** A line of a recording in the browser's console, and so in the editor's console panel. */
export function consoleLine(line: string, level: 'info' | 'warn' = 'warn'): void {
// An info line is no warning: the console panel shows it plain.
// eslint-disable-next-line no-console
if (level === 'info') console.info('[recording]', line);
else console.warn('[recording]', line);
}
/**
* Where a take gets its pictures: the game view (`GameRecorder`) or the whole editor
* (`EditorRecorder`).
@@ -86,7 +98,10 @@ export interface TakeInfo {
codec: string;
sound: boolean;
frames: number;
/** Frames taken that had nowhere to go: the encoder or the read back was still busy. */
dropped: number;
/** Frames no drawn frame came for, see `FrameGate.skipped`. */
skipped: number;
soundFrames: number;
/** From the first frame to the end of the last, in ms. */
durationMs: number;
@@ -104,6 +119,7 @@ export class Take {
private size = { w: 0, h: 0 };
private readonly clock = new RecordingClock();
private readonly gate: FrameGate;
private readonly pace: FramePace;
private video: VideoEncoder | null = null;
private audio: AudioEncoder | null = null;
private tap: { node: AudioWorkletNode; rate: number; release(): void } | null = null;
@@ -142,6 +158,7 @@ export class Take {
private readonly options: { sound?: boolean } = {},
) {
this.gate = new FrameGate(fps);
this.pace = new FramePace(fps);
}
/**
@@ -164,6 +181,13 @@ export class Take {
() => {
if (this.state !== 'starting') return;
this.state = 'recording';
const { w, h } = this.size;
const codec = this.codec.startsWith('avc1') ? 'H.264' : 'VP9';
const sound = this.audio ? 'with sound' : 'without sound';
this.host.log(
`Recording "${this.name}" started: ${w}x${h} at ${this.fps} fps, ${codec}, ${sound}`,
'info',
);
if (this.stopAsked) this.finish('');
},
(error: unknown) => this.fail(error instanceof Error ? error.message : String(error)),
@@ -308,11 +332,33 @@ export class Take {
if (!this.clock.started) this.clock.start(now);
const ms = this.clock.at(now);
if (!this.gate.take(ms)) return;
if (this.source.busy || video.encodeQueueSize >= MOST_QUEUED || !this.source.take(ms)) {
this.dropped++;
return;
}
this.lastFrameAt = now;
if (this.source.busy || video.encodeQueueSize >= MOST_QUEUED || !this.source.take(ms)) this.dropped++;
else this.lastFrameAt = now;
const got = this.pace.behind(ms, this.missed);
if (got !== null)
this.host.log(
`Recording "${this.name}" can't keep up: about ${got} of ${this.fps} fps, so the video will stutter`,
);
}
/** Frames the recording's time has room for that are not in it. */
private get missed(): number {
return this.gate.skipped + this.dropped;
}
/**
* Says at the end how many frames a recording missed, when it missed so many it stutters; the
* count is the whole file's, its first moments too.
*/
private tellMissed(): void {
if (!this.pace.stutters) return;
const slots = this.gate.slots;
const missed = Math.min(this.missed, slots);
const helps = this.fps > 30 ? '30 fps or a smaller resolution' : 'a smaller resolution';
this.host.log(
`Recording "${this.name}" has ${slots - missed} of ${slots} frames: ${missed} missed ` +
`(${Math.round((missed / slots) * 100)} %), so it stutters – ${helps} helps`,
);
}
private get taking(): boolean {
@@ -433,6 +479,8 @@ export class Take {
width: this.size.w,
height: this.size.h,
});
// After the upload's notice, or the editor's would cover this one at once.
this.tellMissed();
}
/**
@@ -517,6 +565,7 @@ export class Take {
sound: this.audio !== null,
frames: this.frames,
dropped: this.dropped,
skipped: this.gate.skipped,
soundFrames: this.soundFrames,
durationMs: this.frames > 0 ? Math.round(this.lastMs + 1000 / this.fps) : 0,
lastFrameAt: this.lastFrameAt,