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

+79
View File
@@ -16,6 +16,7 @@ import {
elapsedText,
fitPicture,
FrameGate,
FramePace,
i420Layout,
isKeyFrame,
pictureTransform,
@@ -165,6 +166,84 @@ describe('FrameGate', () => {
it('takes about 60 a second from a 144 Hz screen', () => {
expect(Math.abs(taken(60, 1000 / 144, 10) - 600)).toBeLessThanOrEqual(6);
});
const gateAfter = (fps: number, gaps: number[], seconds: number) => {
const gate = new FrameGate(fps);
for (let i = 0, ms = 0; ms < seconds * 1000; ms += gaps[i++ % gaps.length]!) gate.take(ms);
return gate;
};
it('counts no frame skipped while the screen keeps up', () => {
expect(gateAfter(60, [1000 / 60], 10).skipped).toBe(0);
expect(gateAfter(30, [1000 / 60], 10).skipped).toBe(0);
expect(gateAfter(60, [1000 / 144], 10).skipped).toBeLessThanOrEqual(1);
});
it('counts the frames a slow screen skips, as many as the recording misses', () => {
// As in a 4K recording on the user's machine: 18 and 35 ms in turn, about 38 a second.
const slow = gateAfter(60, [18, 35], 10);
expect(slow.slots).toBeGreaterThanOrEqual(599);
expect(slow.slots).toBeLessThanOrEqual(601);
expect(slow.skipped).toBe(slow.slots - 378);
// A 50 Hz screen leaves out one in six of 60, however evenly the frames come.
const fifty = gateAfter(60, [20], 10);
expect(Math.abs(fifty.skipped - 100)).toBeLessThanOrEqual(2);
});
});
describe('FramePace', () => {
/** What `behind` said, frame by frame, of a recording that misses `missedPerSecond`. */
const said = (fps: number, missedPerSecond: (s: number) => number, seconds: number) => {
const pace = new FramePace(fps);
const told: { at: number; got: number }[] = [];
let missed = 0;
for (let ms = 0; ms <= seconds * 1000; ms += 1000 / fps) {
missed += missedPerSecond(ms / 1000) / fps;
const got = pace.behind(ms, Math.round(missed));
if (got !== null) told.push({ at: Math.round(ms), got });
}
return told;
};
it('says once that a recording falls behind, with the frames a second it gets', () => {
const told = said(60, () => 22, 20);
expect(told).toHaveLength(1);
expect(told[0]!.got).toBe(38);
// It has settled for a second and then judged two.
expect(told[0]!.at).toBeGreaterThanOrEqual(3000);
expect(told[0]!.at).toBeLessThan(3100);
});
it('lets a stall while it starts and a few frames missed pass', () => {
// Half a second lost at the start, then 5 % missed: never more than a tenth over a span.
expect(said(60, (s) => (s < 0.5 ? 60 : 3), 20)).toEqual([]);
});
it('says at the end that a recording stutters only when it missed frames after it settled', () => {
const after = (missedPerSecond: (s: number) => number, seconds: number) => {
const pace = new FramePace(60);
let missed = 0;
for (let ms = 0; ms <= seconds * 1000; ms += 1000 / 60) {
missed += missedPerSecond(ms / 1000) / 60;
pace.behind(ms, Math.round(missed));
}
return pace.stutters;
};
// A stall while it starts, in a short recording too: e2e 67 missed 3 of 37 frames so.
expect(after((s) => (s < 0.3 ? 60 : 0), 1.2)).toBe(false);
expect(after((s) => (s < 0.5 ? 60 : 1), 20)).toBe(false);
expect(after(() => 22, 13.8)).toBe(true);
expect(after(() => 6, 20)).toBe(true);
// Shorter than its first second: nothing to judge.
expect(after(() => 30, 0.9)).toBe(false);
});
it('says it when a recording falls behind only later', () => {
const told = said(30, (s) => (s < 7 ? 0 : 12), 20);
expect(told).toHaveLength(1);
expect(told[0]!.got).toBe(18);
expect(told[0]!.at).toBeGreaterThan(7000);
});
});
describe('pictureTransform', () => {