Files
feuerwerk/tests/core/recording.test.ts
T
adminandClaude Opus 5.5 77365f17d3 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>
2026-10-14 11:21:46 +02:00

466 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import {
CURSOR_SIZE,
cursorCss,
cursorKeyword,
cursorKeywords,
cursorShape,
cursorShapes,
cursorSvgAt,
readCursor,
} from '../../src/core/recording/cursors';
import { editorRecordingPlan, editorRecordingSettingsOf } from '../../src/core/recording/editorRecording';
import {
avcCodec,
editorRecordingSize,
elapsedText,
fitPicture,
FrameGate,
FramePace,
i420Layout,
isKeyFrame,
pictureTransform,
RecordingClock,
recordingName,
recordingSettingsOf,
recordingSize,
} from '../../src/core/recording/recording';
import { requireNodeDef } from '../../src/core/commands/require';
import { runDef, fakeContext } from '../helpers';
describe('recording settings (#405)', () => {
it('names a recording after its start when no name is given', () => {
const at = new Date(2026, 9, 11, 16, 42, 5);
expect(recordingName('', at)).toBe('Recording 2026-10-11 16:42:05');
expect(recordingName(' ', at)).toBe('Recording 2026-10-11 16:42:05');
expect(recordingName(' Boss fight ', at)).toBe('Boss fight');
});
it('reads what the node hands over, an unknown choice as the default', () => {
expect(recordingSettingsOf(' Run ', '1080p', '30', { sound: false, adopt: false })).toEqual({
name: 'Run',
resolution: '1080p',
fps: 30,
sound: false,
convert: true,
adopt: false,
});
// The flags left out are on, as in "Start recording…".
expect(recordingSettingsOf('', 'huge', 25)).toEqual({
name: '',
resolution: 'view',
fps: 60,
sound: true,
convert: true,
adopt: true,
});
});
it('records the game view at its size on screen, even on both sides', () => {
expect(recordingSize('view', { w: 1279, h: 721 }, { w: 1279, h: 721 })).toEqual({ w: 1280, h: 722 });
// A 5K screen is held to 4K.
expect(recordingSize('view', { w: 5120, h: 2880 }, { w: 5120, h: 2880 })).toEqual({ w: 3840, h: 2160 });
});
it('holds the height of 720p to 4K and takes the width from the camera', () => {
expect(recordingSize('1080p', { w: 800, h: 450 }, { w: 800, h: 450 })).toEqual({ w: 1920, h: 1080 });
expect(recordingSize('720p', { w: 400, h: 400 }, { w: 400, h: 400 })).toEqual({ w: 720, h: 720 });
// 4:3 at 720p, and the camera's whole picture rules where the screen cuts it.
expect(recordingSize('720p', { w: 300, h: 400 }, { w: 640, h: 480 })).toEqual({ w: 960, h: 720 });
expect(recordingSize('1440p', { w: 800, h: 450 }, { w: 800, h: 450 })).toEqual({ w: 2560, h: 1440 });
expect(recordingSize('4k', { w: 800, h: 450 }, { w: 800, h: 450 })).toEqual({ w: 3840, h: 2160 });
expect(recordingSize('4k', { w: 400, h: 400 }, { w: 400, h: 400 })).toEqual({ w: 2160, h: 2160 });
});
it('scales a camera wider than 16:9 down to fit 4K', () => {
// 21:9 at 4K would be 5040 wide.
expect(recordingSize('4k', { w: 2100, h: 900 }, { w: 2100, h: 900 })).toEqual({ w: 3840, h: 1646 });
// At 1440p it still fits: 3360 × 1440.
expect(recordingSize('1440p', { w: 2100, h: 900 }, { w: 2100, h: 900 })).toEqual({ w: 3360, h: 1440 });
});
it('lays the I420 planes of a frame read back from the GPU out on whole texels', () => {
// 1920 is a multiple of 8, 1080 / 2 rows of chroma fill 270 texel rows a plane.
expect(i420Layout(1920, 1080)).toEqual({
stride: 1920,
chromaRows: 270,
texels: { w: 480, h: 1620 },
bytes: 1920 * 1620,
layout: [
{ offset: 0, stride: 1920 },
{ offset: 1920 * 1080, stride: 960 },
{ offset: 1920 * 1350, stride: 960 },
],
});
// 1646 is not: a luma row takes 1648 bytes. 722 / 2 = 361 chroma rows, odd, so the last texel
// row of each plane is half used.
const odd = i420Layout(1646, 722);
expect(odd.stride).toBe(1648);
expect(odd.chromaRows).toBe(181);
expect(odd.texels).toEqual({ w: 412, h: 722 + 362 });
expect(odd.layout[2]!.offset - odd.layout[1]!.offset).toBe(1648 * 181);
expect(odd.layout[1]!.stride * 361).toBeLessThanOrEqual(1648 * 181);
expect(odd.bytes).toBe(odd.layout[2]!.offset + 1648 * 181);
});
it('picks the lowest H.264 level that takes the frames', () => {
expect(avcCodec(1280, 720, 30)).toBe('avc1.64001f');
expect(avcCodec(1920, 1080, 30)).toBe('avc1.640028');
expect(avcCodec(1920, 1080, 60)).toBe('avc1.64002a');
expect(avcCodec(2560, 1440, 60)).toBe('avc1.640033');
expect(avcCodec(3840, 2160, 60)).toBe('avc1.640034');
});
it('starts a group every two seconds', () => {
expect([0, 1, 119, 120, 240].map((i) => isKeyFrame(i, 60))).toEqual([true, false, false, true, true]);
expect(isKeyFrame(60, 30)).toBe(true);
});
});
describe('RecordingClock', () => {
it('counts from the start and leaves the editor pause out', () => {
const clock = new RecordingClock();
expect(clock.at(500)).toBe(0);
clock.start(1000);
expect(clock.at(1500)).toBe(500);
clock.pause(2000);
expect(clock.at(5000)).toBe(1000);
clock.resume(6000);
expect(clock.at(6500)).toBe(1500);
// Pausing twice and resuming once still takes out the whole pause.
clock.pause(7000);
clock.pause(7500);
clock.resume(8000);
expect(clock.at(8000)).toBe(2000);
});
it('leaves a hold before the first frame out of the time, not off it', () => {
const clock = new RecordingClock();
clock.pause(0);
clock.resume(2000);
clock.start(2000);
expect(clock.at(2500)).toBe(500);
});
});
describe('FrameGate', () => {
const taken = (fps: number, frameMs: number, seconds: number, jitter = 0) => {
const gate = new FrameGate(fps);
let count = 0;
for (let i = 0; i * frameMs < seconds * 1000; i++) {
const wobble = jitter ? ((i * 7919) % 11) / 10 - 0.5 : 0;
if (gate.take(Math.max(0, i * frameMs + wobble * jitter))) count++;
}
return count;
};
it('takes every frame at 60 on a 60 Hz screen, however unevenly they come', () => {
expect(taken(60, 1000 / 60, 10)).toBe(600);
expect(taken(60, 1000 / 60, 10, 6)).toBe(600);
});
it('takes every other at 30', () => {
expect(taken(30, 1000 / 60, 10)).toBe(300);
});
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', () => {
it('maps the picture onto the whole frame', () => {
// A picture of 640x360 CSS pixels centred at (400, 300), recorded at 1080p.
const area = { x: 400, y: 300, w: 640, h: 360 };
const m = pictureTransform(area, { w: 1920, h: 1080 });
const at = (x: number, y: number) => ({ x: m.a * x + m.tx, y: m.d * y + m.ty });
expect(at(80, 120)).toEqual({ x: 0, y: 0 });
expect(at(720, 480)).toEqual({ x: 1920, y: 1080 });
expect(at(400, 300)).toEqual({ x: 960, y: 540 });
});
});
describe('the recording nodes', () => {
it('Start recording hands its settings over and flows on', () => {
const ctx = fakeContext();
const result = runDef('StartRecording', ctx, { name: 'Intro', resolution: '720p', fps: '30' }, {});
expect(result.next).toBe('exec');
expect(ctx.recordings).toEqual([
{ name: 'Intro', resolution: '720p', fps: 30, sound: true, convert: true, adopt: true },
]);
});
it('Start recording asks for the game view at 60, with sound, converted and imported by default', () => {
const ctx = fakeContext();
runDef('StartRecording', ctx, {}, {});
expect(ctx.recordings).toEqual([
{ name: '', resolution: 'view', fps: 60, sound: true, convert: true, adopt: true },
]);
});
it('Start recording hands its flags over', () => {
const ctx = fakeContext();
runDef('StartRecording', ctx, { sound: false, convert: true, adopt: false }, {});
expect(ctx.recordings).toEqual([
{ name: '', resolution: 'view', fps: 60, sound: false, convert: true, adopt: false },
]);
});
it('Start recording offers the flags of "Start recording…", all on', () => {
const fields = requireNodeDef('StartRecording').inputs.flatMap((port) =>
port.field ? [port.field] : [],
);
expect(fields.slice(3).map((f) => [f.label, f.default])).toEqual([
['Sound', true],
['Convert', true],
['Import after conversion', true],
]);
});
it('Stop recording asks for the stop and flows on', () => {
const ctx = fakeContext();
expect(runDef('StopRecording', ctx, {}, {}).next).toBe('exec');
expect(ctx.recordings).toEqual(['stop']);
});
});
describe('recording the editor', () => {
it('records the whole canvas, an odd row or column cut off', () => {
expect(editorRecordingSize({ w: 2560, h: 1369 })).toEqual({ w: 2560, h: 1368 });
expect(editorRecordingSize({ w: 1281, h: 721 })).toEqual({ w: 1280, h: 720 });
});
it('puts the canvas where it is while its size is the one it started with', () => {
expect(fitPicture({ w: 1281, h: 720 }, { w: 1280, h: 720 })).toEqual({ x: 0, y: 0, w: 1281, h: 720 });
});
it('fits a resized window in whole and centres it', () => {
expect(fitPicture({ w: 1000, h: 720 }, { w: 1280, h: 720 })).toEqual({ x: 140, y: 0, w: 1000, h: 720 });
expect(fitPicture({ w: 2560, h: 720 }, { w: 1280, h: 720 })).toEqual({ x: 0, y: 180, w: 1280, h: 360 });
});
it('records the screen as it is, at the resolution it is drawn at', () => {
expect(editorRecordingPlan({ w: 1280, h: 685 }, 1.25, 'screen')).toEqual({
size: { w: 1600, h: 856 },
resolution: 1.25,
});
});
it('draws sharper for a larger frame, fitted into it with the window’s shape', () => {
expect(editorRecordingPlan({ w: 1920, h: 1080 }, 1, '4k')).toEqual({
size: { w: 3840, h: 2160 },
resolution: 2,
});
expect(editorRecordingPlan({ w: 2560, h: 1305 }, 1, '4k')).toEqual({
size: { w: 3840, h: 1958 },
resolution: 1.5,
});
expect(editorRecordingPlan({ w: 1280, h: 800 }, 1, '1440p')).toEqual({
size: { w: 2304, h: 1440 },
resolution: 1.8,
});
});
it('keeps the screen’s resolution for a smaller frame, and draws at most four times as fine', () => {
expect(editorRecordingPlan({ w: 2560, h: 1440 }, 1, '1080p')).toEqual({
size: { w: 1920, h: 1080 },
resolution: 1,
});
expect(editorRecordingPlan({ w: 640, h: 360 }, 1, '4k')).toEqual({
size: { w: 3840, h: 2160 },
resolution: 4,
});
});
it('starts on the defaults, keeps what was chosen and never a name', () => {
expect(editorRecordingSettingsOf(null)).toEqual({
name: '',
resolution: 'screen',
fps: 60,
pointer: true,
sound: true,
convert: true,
adopt: true,
});
expect(
editorRecordingSettingsOf({
name: 'Old',
resolution: '4k',
fps: 30,
pointer: false,
sound: false,
convert: false,
adopt: 'no',
}),
).toEqual({
name: '',
resolution: '4k',
fps: 30,
pointer: false,
sound: false,
convert: false,
adopt: true,
});
expect(editorRecordingSettingsOf({ resolution: '8k', fps: 25 })).toMatchObject({
resolution: 'screen',
fps: 60,
});
});
it('shows the time it runs in minutes and seconds, hours once there are any', () => {
expect(elapsedText(0)).toBe('0:00');
expect(elapsedText(7999)).toBe('0:07');
expect(elapsedText(765_000)).toBe('12:45');
expect(elapsedText(3_723_000)).toBe('1:02:03');
});
it('draws the pointer the canvas shows, the arrow for what it does not know', () => {
expect(cursorShape('')).toBe(cursorShape('default'));
expect(cursorShape('inherit')).toBe(cursorShape('default'));
expect(cursorShape('ew-resize')).toEqual(cursorShape('col-resize'));
expect(cursorShape('ns-resize')!.turn).toBe(90);
expect(cursorShape('url(a.png) 4 4, grab')).toBe(cursorShape('grab'));
expect(cursorShape('grab')).not.toBe(cursorShape('default'));
expect(cursorShape('none')).toBeNull();
});
it('reads a cursor file: its viewBox is the picture, data-hot the point on the pointer', () => {
expect(cursorShape('pointer')).toMatchObject({
box: { x: -2, y: -2, w: 18, h: 22 },
hot: { x: 5, y: 0 },
});
expect(cursorShapes()).toHaveLength(11);
// As an editor might save it: other attributes first, commas, size in millimetres.
const edited = readCursor(
'<?xml version="1.0"?>\n<svg width="10mm" height="12mm" data-hot=" 3,4 " viewBox="1, 2, 30, 40" ' +
'xmlns="http://www.w3.org/2000/svg"><path d="M0 0" /></svg>',
45,
);
expect(edited).toMatchObject({ box: { x: 1, y: 2, w: 30, h: 40 }, hot: { x: 3, y: 4 }, turn: 45 });
expect(readCursor('<svg><path /></svg>')).toMatchObject({
box: { x: 0, y: 0, w: 24, h: 24 },
hot: { x: 0, y: 0 },
});
// Drawn at the size it is needed, whatever size the file names.
const sized = cursorSvgAt(edited, 2.5);
expect(sized).toContain('<svg width="75" height="100" data-hot=" 3,4 " viewBox="1, 2, 30, 40"');
expect(sized).not.toContain('mm"');
expect(sized).toContain('<path d="M0 0" />');
});
it('gives the editor each cursor file as a CSS cursor, turned where the shape is turned', () => {
const svgOf = (css: string): string =>
decodeURIComponent(/^url\("data:image\/svg\+xml,([^"]*)"\)/.exec(css)![1]!);
// The arrow at a unit per CSS pixel: its own picture, the hotspot counted from the picture's corner.
const arrow = cursorCss('default', undefined, 1);
const { box, hot } = cursorShape('default')!;
expect(arrow).toMatch(new RegExp(` ${hot.x - box.x} ${hot.y - box.y}, default$`));
expect(cursorKeyword(arrow)).toBe('default');
expect(cursorKeywords()).toEqual(expect.arrayContaining(['default', 'pointer', 'grab', 'nesw-resize']));
// The resize arrow, 26 × 14 around its middle, turned upright: 14 × 26, the hotspot in the middle.
const upright = cursorCss('ns-resize', undefined, 1);
expect(upright).toMatch(/ 7 13, ns-resize$/);
expect(svgOf(upright)).toContain('width="14" height="26" viewBox="-7 -13 14 26"');
expect(svgOf(upright)).toContain(
'<g transform="rotate(90 0 0)"><svg x="-13" y="-7" width="26" height="14"',
);
// Turned by 45°, the picture grows to hold the corners.
expect(cursorCss('nwse-resize', undefined, 1)).toMatch(/ 15 15, nwse-resize$/);
// Shown at CURSOR_SIZE: the arrow's 16 × 23 units are 12.8 × 18.4 pixels, rounded outwards from
// the corner at -1.6 to 14 × 19, and the viewBox still counts in the file's units.
expect(CURSOR_SIZE).toBe(0.8);
expect(cursorCss('default')).toMatch(/ 2 2, default$/);
expect(svgOf(cursorCss('default'))).toContain('width="14" height="19" viewBox="-2.5 -2.5 17.5 23.75"');
expect(cursorCss('ns-resize')).toMatch(/ 6 11, ns-resize$/);
// An edited file: what stands before its root is left out, the root is placed where its viewBox says.
const edited = readCursor(
'<?xml version="1.0"?>\n<!-- drawn by hand -->\n<svg x="5" viewBox="1 2 30 40" data-hot="3 4" ' +
'xmlns="http://www.w3.org/2000/svg"><path d="M0 0" /></svg>',
);
const css = cursorCss('grab', edited, 1);
expect(css).toMatch(/ 2 2, grab$/);
const svg = svgOf(css);
expect(svg).not.toContain('<?xml');
expect(svg).not.toContain('drawn by hand');
expect(svg).toContain('<svg x="1" y="2" width="30" height="40" viewBox="1 2 30 40"');
});
});