The editor recording had fixed settings: the canvas size at 60 fps,
always converted and imported. Recording in 4K needs a choice, and the
server shrank every video to 1080p anyway. Start recording... now opens
a dialog, and a recording always goes to the server as it is.
- The dialog holds name, resolution (Screen, 1080p, 1440p, 4K), fps
(60 or 30), mouse pointer, sound, Convert and Import after
conversion. Import is greyed out while Convert is off. The browser
keeps the last choice, not the name.
- A larger resolution makes the editor draw finer while it records,
so 4K is sharp, not stretched. It is never below the screen's own
resolution and goes back after Stop.
- Stop always uploads. Without Convert the file only stays on the
server. Without Import it is converted but no asset is added. Both
are found in the video picker.
- The server keeps the original next to each MP4 for every upload, so
Download in the video tab and the picker gives the original. Videos
from before have none, they still download their MP4.
- A file left unconverted shows in the picker as not converted, with
a Convert button. POST videos/{file}/convert queues it, and upload
takes convert=0.
- The worker now caps a video at 3840x2160 instead of 1920x1080. Videos
converted before stay as they are.
Tests:
- PHP videos.test.php: 81 checks green, with the original, convert=0,
a later Convert and the 4K cap.
- Unit recording: 5 new, green. Red without the rule that a recording
is never below the screen's resolution.
- e2e 67e-ii: the video tab downloads the .source.mkv, a Matroska
file. 67f: 1440p from a 1280x800 window gives 2304x1440 at 30 fps
without sound, kept and not imported, then converted in the picker.
Both red with the MP4 download and without the finer drawing.
- The README names 2045 e2e checks.
- Gate on melektaus-atx: 304 files, 4699 tests green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
247 lines
8.9 KiB
TypeScript
247 lines
8.9 KiB
TypeScript
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();
|
||
});
|
||
});
|