The pointer drawn into an editor recording lived as paths in code, so it could not be looked at or edited by hand. The shapes are now SVG files, and the editor can show the same pointer on screen, so what is recorded is what was seen. - src/core/recording/cursors/ holds one file per shape: arrow, hand, open-hand, fist, move, crosshair, text and resize. Resize is turned for the vertical and diagonal directions. - data-hot="x y" on the <svg> is the hotspot in viewBox units. Width and height in the file are ignored, and anything before <svg>, such as <?xml ...> from a drawing program, is cut off. - The open hand had six fingers. It has a thumb and four now, the middle one longest. - CURSOR_SIZE = 0.8 scales every pointer the same on screen and in a recording, so the files keep their units. - General settings has "Custom mouse pointer", on by default. It hangs the files into Pixi's cursorStyles as CSS cursors with the keyword as fallback, so the system still draws the pointer and the canvas does not. Off gives Pixi's own values back at once. - A recording draws the pointer from the same files at its own size, so it stays sharp in 4K. It still reads the shape from the canvas when that holds a url(...) cursor. Tests: - Unit recording and view-tween: 3 new, green. They read viewBox and data-hot in an edited form, the boxes of turned shapes, the default size and old settings without the switch. Red without data-hot and without cutting before <svg>. - e2e 46i-ii: the canvas cursor is a url(...) ending in default and the image loads. Off gives inherit and stores false, on brings the file back. Red without useCursorFiles. - e2e 67e: the recording is still black at the mouse position. - The README names 2046 e2e checks. - Gate on melektaus-atx: 304 files, 4702 tests green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
317 lines
12 KiB
TypeScript
317 lines
12 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 { 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();
|
||
});
|
||
|
||
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"');
|
||
});
|
||
});
|