Files
feuerwerk/tests/core/recording.test.ts
T
adminandClaude Opus 5.5 30b618d9a9 Show game view recordings in their true colours in other players
A game view recording looked darker and its colours off in VLC, and
each round of recording a recording made it worse. Chrome's encoder,
fed RGB frames, wrote full-range H.264 and left the colour out of the
stream. Only our Matroska head said "full range", and VLC reads only
the stream. Chrome itself plays the converted MP4, which was right.

- The GPU now turns the game view into I420, limited range BT.709,
  before it is read back (PictureReader). The encoder gets the planes
  with that colour, as it does for a recording of the whole editor.
- Where the planes lie in the read-back picture is a plain function,
  i420Layout in recording.ts.
- Only 1.5 instead of 4 bytes a pixel come back from the GPU.
- avcColour.ts writes the colour into the SPS of the encoder's avcC as
  well, so a player that reads only the stream knows it. An SPS with a
  VUI only gets its colour block replaced. One without gets a VUI that
  holds the colour. An SPS it cannot read stays as the encoder made it.
- A first try sent the frames through a 2D canvas. That gave limited
  range too, but only 18 to 20 of 33 frames at 1080p30.

Found on the way:

- With the colour in the stream alone, VLC 3.0.8 still showed the
  full-range picture too dark. It ignores the range.
- The one old full-range original on the server got the same SPS fix
  without loss: the clusters are byte-equal, the decoded frames are
  equal (framemd5), backup in /root/config-backup-20261013/videos/.
- Older game view recordings stay full range. Fixing that in VLC would
  mean encoding them again.

Tests:
- tsc, eslint, Prettier clean.
- Gate on melektaus-atx: CHECK EXIT 0, 308 files, 4772 tests.
- avc-colour.test.ts new (9), expectations read back with ffmpeg
  trace_headers. Counter-check: the old bug in the no-VUI path turns
  it red.
- e2e 67: ALL PASSED, 12 checks. 67d-ii reads tv/bt709 from the file
  and from the bare H.264 stream, red with the old code.
- 1080p30 34 frames, 640x360@60 68 frames, none dropped (was 33/47).
- Checked by the user: a new recording looks right in VLC 3.0.8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:48:44 +02:00

387 lines
15 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,
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);
});
});
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"');
});
});