The Start recording node only offered the game view, 720p and 1080p, and always recorded with sound, converted and imported. The recording from the main menu could already leave those out. - Resolution has 1440p and 4K. As with 720p and 1080p the height is fixed and the width follows the camera, so 16:9 gives 2560x1440 and 3840x2160. - Every resolution is capped at 3840x2160, as the game view already was. A 21:9 camera in 4K would be 5040 px wide, which the encoder levels are not made for. It is scaled down to 3840x1646. - The node has Sound, Convert and Import after conversion, all on by default as in "Start recording...". Import only counts with Convert. An old graph without the inputs records as before. - A released game ignores Convert and Import and downloads the MKV. - Both recordings now hand their upload options the same way, from GameRecorder over PlaySession to RecordingUploads. RecordingUploadOptions moved to core/recording/recording.ts. Tests: - tsc, eslint, Prettier clean - Unit recording and node-catalog: 451 green, recording 29 with 3 new tests and 1 extended - e2e 67 on melektaus-atx: ALL PASSED, 10 checks - Not checked in the browser: a real 4K recording, and the node with its switches off. - The gate ran once on this commit and the next one together. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
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,
|
||
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('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(
|
||
'<?xml version="1.0"?>\n<svg width="10mm" height="12mm" data-hot=" 3,4 " viewBox="1, 2, 30, 40" ' +
|
||
'xmlns="http://www.w3.org/2000/svg"><path d="M0 0" /></svg>',
|
||
45,
|
||
);
|
||
expect(edited).toMatchObject({ box: { x: 1, y: 2, w: 30, h: 40 }, hot: { x: 3, y: 4 }, turn: 45 });
|
||
expect(readCursor('<svg><path /></svg>')).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('<svg width="75" height="100" data-hot=" 3,4 " viewBox="1, 2, 30, 40"');
|
||
expect(sized).not.toContain('mm"');
|
||
expect(sized).toContain('<path d="M0 0" />');
|
||
});
|
||
|
||
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(
|
||
'<g transform="rotate(90 0 0)"><svg x="-13" y="-7" width="26" height="14"',
|
||
);
|
||
// Turned by 45°, the picture grows to hold the corners.
|
||
expect(cursorCss('nwse-resize', undefined, 1)).toMatch(/ 15 15, nwse-resize$/);
|
||
// Shown at CURSOR_SIZE: the arrow's 16 × 23 units are 12.8 × 18.4 pixels, rounded outwards from
|
||
// the corner at -1.6 to 14 × 19, and the viewBox still counts in the file's units.
|
||
expect(CURSOR_SIZE).toBe(0.8);
|
||
expect(cursorCss('default')).toMatch(/ 2 2, default$/);
|
||
expect(svgOf(cursorCss('default'))).toContain('width="14" height="19" viewBox="-2.5 -2.5 17.5 23.75"');
|
||
expect(cursorCss('ns-resize')).toMatch(/ 6 11, ns-resize$/);
|
||
// An edited file: what stands before its root is left out, the root is placed where its viewBox says.
|
||
const edited = readCursor(
|
||
'<?xml version="1.0"?>\n<!-- drawn by hand -->\n<svg x="5" viewBox="1 2 30 40" data-hot="3 4" ' +
|
||
'xmlns="http://www.w3.org/2000/svg"><path d="M0 0" /></svg>',
|
||
);
|
||
const css = cursorCss('grab', edited, 1);
|
||
expect(css).toMatch(/ 2 2, grab$/);
|
||
const svg = svgOf(css);
|
||
expect(svg).not.toContain('<?xml');
|
||
expect(svg).not.toContain('drawn by hand');
|
||
expect(svg).toContain('<svg x="1" y="2" width="30" height="40" viewBox="1 2 30 40"');
|
||
});
|
||
});
|