Files
feuerwerk/tests/core/recording.test.ts
T
adminandClaude Opus 5.5 e191dc4b2e
game-maker / gate (push) Successful in 4m14s
game-maker / suite (push) Successful in 17m7s
Record the game view from a behavior into a video asset (#405)
A game can now film itself. A behavior starts and stops a take, and
the editor turns it into a video asset with the run's picture and the
sound the player hears. The exported game hands the file over as a
download, since there is no project to keep it in.

- New action nodes `Start recording` (Name, Resolution, FPS) and
  `Stop recording`. An empty name becomes `Recording <timestamp>`.
- Resolution is `Game view`, `720p` or `1080p`. For the fixed sizes
  the height is set and the width follows the camera.
- The picture is the camera only, without hints, overlays or the
  letterbox. It is drawn into a render texture of the target size,
  so a smaller window still gives full 1080p.
- `PictureReader` reads the texture back through pixel buffers with
  a fence. The editor never waits on the GPU, and a frame with all
  three buffers busy is dropped and counted.
- Nothing of the take reaches the visible canvas. A first version
  drew tiles into it and copied them out, and with the low-latency
  canvas that midway state flickered on screen.
- The sound is tapped behind the master limiter in the FX worklet
  and encoded as Opus.
- H.264 is chosen when the browser has it, VP9 otherwise. A small
  own Matroska writer packs both tracks, no new dependency. It names
  the frame rate and BT.709 so ffmpeg keeps both.
- A second `Start recording` is ignored with a warning in the log.
  A take goes on through `Load scene` and a restart, pauses with the
  editor and is saved when the run ends. It stops at 2 GB.
- The editor uploads the take on the existing video route and makes
  the asset once the run is over. The video tab gets a Download
  button for the converted MP4.
- `downloadBlob` in `src/ui` replaces the song editor's own copy.

Findings:
- `On start` runs before the game screen knows the world. A take
  therefore fixes its size on the first frame that has a camera.
- Without a frame rate in the file, ffmpeg turned 30 fps into 59.94.
- The worker's poster JPEG is off in colour for every upload (red
  255 comes out as 229, BT.601). Not touched here.

Tests:
- Unit recording + matroska: 21 green, plus the node catalog cases.
  The Matroska test reads the file back with its own EBML reader.
- e2e 67 (new) on melektaus-atx: ALL PASSED. 1080p30 from a
  1280x800 window gives a 1920x1080 MP4 with sound and no dropped
  frame. Every 4th pixel is checked, a green quarter catches an
  upside-down picture. A take ended by Esc is saved with the
  timestamp name.
- e2e 60j: the exported game downloads an `.mkv` with EBML magic.
- The flicker is gone on the user's machine without `?vsync`.
- Gate on melektaus-atx: CHECK EXIT 0, 299 files, 4586 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:11:05 +02:00

133 lines
5.0 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
avcCodec,
FrameGate,
isKeyFrame,
pictureTransform,
RecordingClock,
recordingName,
recordingSettingsOf,
recordingSize,
} from '../../src/core/recording/recording';
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')).toEqual({
name: 'Run',
resolution: '1080p',
fps: 30,
});
expect(recordingSettingsOf('', 'huge', 25)).toEqual({ name: '', resolution: 'view', fps: 60 });
});
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 and 1080p 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 });
});
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);
});
});
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 }]);
});
it('Start recording asks for the game view at 60 by default', () => {
const ctx = fakeContext();
runDef('StartRecording', ctx, {}, {});
expect(ctx.recordings).toEqual([{ name: '', resolution: 'view', fps: 60 }]);
});
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']);
});
});