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>
133 lines
5.0 KiB
TypeScript
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']);
|
|
});
|
|
});
|