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( '\n', 45, ); expect(edited).toMatchObject({ box: { x: 1, y: 2, w: 30, h: 40 }, hot: { x: 3, y: 4 }, turn: 45 }); expect(readCursor('')).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(''); }); 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( '\n\n', ); const css = cursorCss('grab', edited, 1); expect(css).toMatch(/ 2 2, grab$/); const svg = svgOf(css); expect(svg).not.toContain('