Record 1440p and 4K, and choose sound and conversion in the node

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-13 10:08:54 +02:00
1 parent 65d7bf6a1f
commit 2ba41c1bd0
9 files changed
+151 -51

No files matched your search

+4 -1
View File
@@ -2429,7 +2429,10 @@ const CASES: Record<string, Case[]> = {
name: 'asks for a recording and flows on',
inputs: { name: 'Take', resolution: '1080p', fps: '60' },
next: 'exec',
after: (ctx) => expect(ctx.recordings).toEqual([{ name: 'Take', resolution: '1080p', fps: 60 }]),
after: (ctx) =>
expect(ctx.recordings).toEqual([
{ name: 'Take', resolution: '1080p', fps: 60, sound: true, convert: true, adopt: true },
]),
},
],
StopRecording: [
+51 -6
View File
@@ -23,6 +23,7 @@ import {
recordingSettingsOf,
recordingSize,
} from '../../src/core/recording/recording';
import { requireNodeDef } from '../../src/core/commands/require';
import { runDef, fakeContext } from '../helpers';
describe('recording settings (#405)', () => {
@@ -34,12 +35,23 @@ describe('recording settings (#405)', () => {
});
it('reads what the node hands over, an unknown choice as the default', () => {
expect(recordingSettingsOf(' Run ', '1080p', '30')).toEqual({
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,
});
expect(recordingSettingsOf('', 'huge', 25)).toEqual({ name: '', resolution: 'view', fps: 60 });
});
it('records the game view at its size on screen, even on both sides', () => {
@@ -48,11 +60,21 @@ describe('recording settings (#405)', () => {
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', () => {
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', () => {
@@ -137,13 +159,36 @@ describe('the recording nodes', () => {
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 }]);
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 by default', () => {
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 }]);
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', () => {