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,
FramePace,
i420Layout,
isKeyFrame,
pictureTransform,
RecordingClock,
recordingName,
recordingSettingsOf,
recordingSize,
SteadyPace,
} 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,
realTime: true,
});
// 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,
realTime: true,
});
expect(recordingSettingsOf('', '4k', '60', { realTime: false }).realTime).toBe(false);
});
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);
});
const gateAfter = (fps: number, gaps: number[], seconds: number) => {
const gate = new FrameGate(fps);
for (let i = 0, ms = 0; ms < seconds * 1000; ms += gaps[i++ % gaps.length]!) gate.take(ms);
return gate;
};
it('counts no frame skipped while the screen keeps up', () => {
expect(gateAfter(60, [1000 / 60], 10).skipped).toBe(0);
expect(gateAfter(30, [1000 / 60], 10).skipped).toBe(0);
expect(gateAfter(60, [1000 / 144], 10).skipped).toBeLessThanOrEqual(1);
});
it('counts the frames a slow screen skips, as many as the recording misses', () => {
// As in a 4K recording on the user's machine: 18 and 35 ms in turn, about 38 a second.
const slow = gateAfter(60, [18, 35], 10);
expect(slow.slots).toBeGreaterThanOrEqual(599);
expect(slow.slots).toBeLessThanOrEqual(601);
expect(slow.skipped).toBe(slow.slots - 378);
// A 50 Hz screen leaves out one in six of 60, however evenly the frames come.
const fifty = gateAfter(60, [20], 10);
expect(Math.abs(fifty.skipped - 100)).toBeLessThanOrEqual(2);
});
});
describe('FramePace', () => {
/** What `behind` said, frame by frame, of a recording that misses `missedPerSecond`. */
const said = (fps: number, missedPerSecond: (s: number) => number, seconds: number) => {
const pace = new FramePace(fps);
const told: { at: number; got: number }[] = [];
let missed = 0;
for (let ms = 0; ms <= seconds * 1000; ms += 1000 / fps) {
missed += missedPerSecond(ms / 1000) / fps;
const got = pace.behind(ms, Math.round(missed));
if (got !== null) told.push({ at: Math.round(ms), got });
}
return told;
};
it('says once that a recording falls behind, with the frames a second it gets', () => {
const told = said(60, () => 22, 20);
expect(told).toHaveLength(1);
expect(told[0]!.got).toBe(38);
// It has settled for a second and then judged two.
expect(told[0]!.at).toBeGreaterThanOrEqual(3000);
expect(told[0]!.at).toBeLessThan(3100);
});
it('lets a stall while it starts and a few frames missed pass', () => {
// Half a second lost at the start, then 5 % missed: never more than a tenth over a span.
expect(said(60, (s) => (s < 0.5 ? 60 : 3), 20)).toEqual([]);
});
it('says at the end that a recording stutters only when it missed frames after it settled', () => {
const after = (missedPerSecond: (s: number) => number, seconds: number) => {
const pace = new FramePace(60);
let missed = 0;
for (let ms = 0; ms <= seconds * 1000; ms += 1000 / 60) {
missed += missedPerSecond(ms / 1000) / 60;
pace.behind(ms, Math.round(missed));
}
return pace.stutters;
};
// A stall while it starts, in a short recording too: e2e 67 missed 3 of 37 frames so.
expect(after((s) => (s < 0.3 ? 60 : 0), 1.2)).toBe(false);
expect(after((s) => (s < 0.5 ? 60 : 1), 20)).toBe(false);
expect(after(() => 22, 13.8)).toBe(true);
expect(after(() => 6, 20)).toBe(true);
// Shorter than its first second: nothing to judge.
expect(after(() => 30, 0.9)).toBe(false);
});
it('says it when a recording falls behind only later', () => {
const told = said(30, (s) => (s < 7 ? 0 : 12), 20);
expect(told).toHaveLength(1);
expect(told[0]!.got).toBe(18);
expect(told[0]!.at).toBeGreaterThan(7000);
});
});
describe('SteadyPace (#423)', () => {
it('moves the run one frame at a time, each frame timed by its number', () => {
const pace = new SteadyPace(60);
const times: number[] = [];
for (let frame = 0; frame < 120; frame++) {
expect(pace.step(true)).toBeCloseTo(1 / 60, 12);
times.push(pace.ms);
pace.soundMade(pace.soundTo);
pace.took();
}
expect(pace.frames).toBe(120);
expect(pace.seconds).toBe(2);
times.forEach((ms, i) => expect(ms).toBeCloseTo((i * 1000) / 60, 9));
});
it('holds the run while a frame is owed, its sound not made, or the host is not ready', () => {
const pace = new SteadyPace(30);
expect(pace.step(false)).toBe(0);
expect(pace.step(true)).toBeCloseTo(1 / 30, 12);
expect(pace.owes).toBe(true);
// The frame drawn is not taken yet.
expect(pace.step(true)).toBe(0);
expect(pace.soundTo).toBeCloseTo(1 / 30, 12);
pace.took();
expect(pace.owes).toBe(false);
// Its sound is not made up to the next frame yet.
expect(pace.step(true)).toBe(0);
pace.soundMade(0.02);
expect(pace.step(true)).toBe(0);
// A suspend lands a little after the frame, on the next block of 128.
pace.soundMade(1664 / 48000);
expect(pace.step(true)).toBeCloseTo(1 / 30, 12);
expect(pace.ms).toBeCloseTo(1000 / 30, 9);
});
it('takes a frame owed only once', () => {
const pace = new SteadyPace(60);
pace.took();
expect(pace.frames).toBe(0);
pace.step(true);
pace.took();
pace.took();
expect(pace.frames).toBe(1);
});
});
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, realTime: 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, realTime: true },
]);
});
it('Start recording hands its flags over', () => {
const ctx = fakeContext();
runDef('StartRecording', ctx, { sound: false, convert: true, adopt: false, realTime: false }, {});
expect(ctx.recordings).toEqual([
{ name: '', resolution: 'view', fps: 60, sound: false, convert: true, adopt: false, realTime: 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],
['Real time', 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(
'