import { describe, expect, it } from 'vitest'; import { cursorShape } from '../../src/core/recording/cursors'; import { editorRecordingPlan, editorRecordingSettingsOf } from '../../src/core/recording/editorRecording'; import { avcCodec, editorRecordingSize, elapsedText, fitPicture, 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']); }); }); 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(); }); });