A Start recording right after Play composition still began with empty frames. The composition's clock waited for its video, but the recording went on and took every frame while the video loaded. A clip that starts later in the video also slipped through, because its player already had one picture while it still sought to the clip's start. - The view now counts every video sprite it leaves out as missing, not only one without any first picture. - GameRecorder takes no frame while the game view misses a video, for at most VIDEO_WAIT_MS (3 s) at a time. The recorded time stands and the sound of that time is dropped, so it stays in sync. - RecordingClock.start forgets a hold from before the first frame. Without that, the first real frames all got the time 0. - A followed player starts at 1x until the first speed reading. Before, it paused and sought every frame for about 100 ms, so the first pictures after a wait stuck and then jumped. A step faster than 4x and a time running backwards do not count as a start. - Test hooks: __gm.gameVideo(id) reads the game view's own player, TakeInfo.lastFrameAt shows when the last frame was taken. Tests: - Unit recording (clock after an early hold), video-follow (2 new, 1 adapted): 449 green in tests/core/recording, tests/viewport and tests/core/composition* - e2e 68e (a clip from 0.8 s shows that picture first and then plays), 68f (a recording that already runs takes no frame while the composition's video loads) - 68e is red without the start at 1x, 68f without the hold - README: 2038 editor checks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
141 lines
5.3 KiB
TypeScript
141 lines
5.3 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);
|
|
});
|
|
|
|
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 }]);
|
|
});
|
|
|
|
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']);
|
|
});
|
|
});
|