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:
1 parent
65d7bf6a1f
commit
2ba41c1bd0
9 files changed
+151
-51
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { RECORDING_FPS, RECORDING_RESOLUTIONS, recordingSettingsOf } from '../../../recording/recording';
|
||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||
import { asString } from '../../values';
|
||||
import { exec } from '../shared';
|
||||
import { asBoolean, asString } from '../../values';
|
||||
import { checkPort, exec } from '../shared';
|
||||
|
||||
const choicePort = (
|
||||
key: string,
|
||||
@@ -23,11 +23,14 @@ export const StartRecording: NodeDef = {
|
||||
'Records the game view as a video, with what the master plays: only the picture of the camera, ' +
|
||||
'none of what lies over it. "name" is what the recording is saved as; left empty, it is named ' +
|
||||
'after the moment it starts. "resolution" Game view records at the size the picture has on ' +
|
||||
'screen when it starts, 720p and 1080p at that height with the width of the camera. The recording ' +
|
||||
'goes on through Load scene and Restart game, holds while the editor pauses the run, and ends with ' +
|
||||
'Stop recording or with the run. In the editor it becomes a video asset of the project, once the ' +
|
||||
'server has converted it; a released game downloads it as an MKV file. A second start while one ' +
|
||||
'records is ignored and says so in the log.',
|
||||
'screen when it starts, 720p, 1080p, 1440p and 4K at that height with the width of the camera, ' +
|
||||
'both at most 3840 × 2160: a wider picture is scaled down to fit. "sound" off records the ' +
|
||||
'picture alone. The recording goes on through Load scene and Restart game, holds while the ' +
|
||||
'editor pauses the run, and ends with Stop recording or with the run. In the editor it is ' +
|
||||
'always uploaded: "convert" has the server make an MP4 of it, "import" makes that a video asset ' +
|
||||
'of the project once it is ready; without them the video picker has it. A released game ' +
|
||||
'downloads it as an MKV file and takes neither. A second start while one records is ignored and ' +
|
||||
'says so in the log.',
|
||||
inputs: [
|
||||
exec(),
|
||||
{
|
||||
@@ -38,12 +41,21 @@ export const StartRecording: NodeDef = {
|
||||
},
|
||||
choicePort('resolution', 'Resolution', RECORDING_RESOLUTIONS),
|
||||
choicePort('fps', 'FPS', RECORDING_FPS),
|
||||
checkPort('sound', 'sound', 'Sound', true),
|
||||
checkPort('convert', 'convert', 'Convert', true),
|
||||
checkPort('adopt', 'import', 'Import after conversion', true),
|
||||
],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
// Graphs from before these flags have no value for them: on, as they recorded then.
|
||||
const flag = (key: string) => (inputs[key] === undefined ? true : asBoolean(inputs[key]));
|
||||
ctx.startRecording(
|
||||
recordingSettingsOf(asString(inputs.name), asString(inputs.resolution), asString(inputs.fps)),
|
||||
recordingSettingsOf(asString(inputs.name), asString(inputs.resolution), asString(inputs.fps), {
|
||||
sound: flag('sound'),
|
||||
convert: flag('convert'),
|
||||
adopt: flag('adopt'),
|
||||
}),
|
||||
);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
|
||||
@@ -4,20 +4,36 @@
|
||||
* The browser's side, encoders and canvas, is `viewport/GameRecorder.ts`.
|
||||
*/
|
||||
|
||||
export type RecordingResolution = 'view' | '720p' | '1080p';
|
||||
export type RecordingResolution = 'view' | '720p' | '1080p' | '1440p' | '4k';
|
||||
|
||||
export interface RecordingSettings {
|
||||
/** Empty: named after the moment it starts (`recordingName`). */
|
||||
name: string;
|
||||
resolution: RecordingResolution;
|
||||
fps: 30 | 60;
|
||||
/** Whether the master is recorded with it. */
|
||||
sound: boolean;
|
||||
/** Whether the server converts the upload into an MP4 a project can use; else it is only kept. */
|
||||
convert: boolean;
|
||||
/** Whether the converted video becomes an asset of the project; only with `convert`. */
|
||||
adopt: boolean;
|
||||
}
|
||||
|
||||
export const RECORDING_RESOLUTIONS: readonly { value: RecordingResolution; label: string }[] = [
|
||||
{ value: 'view', label: 'Game view' },
|
||||
{ value: '720p', label: '720p' },
|
||||
{ value: '1080p', label: '1080p' },
|
||||
];
|
||||
/** What becomes of a recording on the server, as Start recording asked; a released game downloads it instead. */
|
||||
export interface RecordingUploadOptions {
|
||||
convert: boolean;
|
||||
adopt: boolean;
|
||||
}
|
||||
|
||||
/** `height` is the height a fixed choice records at; `Game view` takes the picture's. */
|
||||
export const RECORDING_RESOLUTIONS: readonly { value: RecordingResolution; label: string; height: number }[] =
|
||||
[
|
||||
{ value: 'view', label: 'Game view', height: 0 },
|
||||
{ value: '720p', label: '720p', height: 720 },
|
||||
{ value: '1080p', label: '1080p', height: 1080 },
|
||||
{ value: '1440p', label: '1440p', height: 1440 },
|
||||
{ value: '4k', label: '4K', height: 2160 },
|
||||
];
|
||||
|
||||
export const RECORDING_FPS = [
|
||||
{ value: '60', label: '60' },
|
||||
@@ -33,20 +49,29 @@ export interface RecordingSink {
|
||||
/** The upload takes 2 GB (`MAX_VIDEO_BYTES`); a recording stops a little before, its head still to come. */
|
||||
export const RECORDING_MOST_BYTES = 2048 * 1024 * 1024 - 16 * 1024 * 1024;
|
||||
|
||||
/** What `Game view` records at most: a 4K picture, larger ones are scaled down to it. */
|
||||
/** What a recording is at most: a 4K picture, larger ones are scaled down to it. */
|
||||
const MOST_WIDTH = 3840;
|
||||
const MOST_HEIGHT = 2160;
|
||||
/** Bits per pixel of a frame: 10 Mbit/s at 1080p60. Flat game graphics need less than film. */
|
||||
const BITS_PER_PIXEL = 0.08;
|
||||
const KEY_FRAME_SECONDS = 2;
|
||||
|
||||
/** The flags left out are on, as in "Start recording…" of the main menu. */
|
||||
export function recordingSettingsOf(
|
||||
name: string,
|
||||
resolution: string,
|
||||
fps: string | number,
|
||||
flags: Partial<Pick<RecordingSettings, 'sound' | 'convert' | 'adopt'>> = {},
|
||||
): RecordingSettings {
|
||||
const known = RECORDING_RESOLUTIONS.find((r) => r.value === resolution);
|
||||
return { name: name.trim(), resolution: known?.value ?? 'view', fps: Number(fps) === 30 ? 30 : 60 };
|
||||
return {
|
||||
name: name.trim(),
|
||||
resolution: known?.value ?? 'view',
|
||||
fps: Number(fps) === 30 ? 30 : 60,
|
||||
sound: flags.sound ?? true,
|
||||
convert: flags.convert ?? true,
|
||||
adopt: flags.adopt ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
/** The name a recording is saved under: the one asked for, else `Recording 2026-10-11 16:42:05`. */
|
||||
@@ -61,19 +86,18 @@ export function recordingName(asked: string, at: Date): string {
|
||||
* The size of the frames, even on both sides as the encoders want it. `shown` is the camera's
|
||||
* picture as it lies on screen and `whole` all of it, both in device pixels: they differ only while
|
||||
* a fixed scale cuts the picture to the view. `Game view` records what is shown at its size; 720p
|
||||
* and 1080p hold the height and take the width from the camera.
|
||||
* to 4K hold the height and take the width from the camera. Either is scaled down to fit 4K, so a
|
||||
* camera wider than 16:9 comes out at 4K a little lower.
|
||||
*/
|
||||
export function recordingSize(
|
||||
resolution: RecordingResolution,
|
||||
shown: { w: number; h: number },
|
||||
whole: { w: number; h: number },
|
||||
): { w: number; h: number } {
|
||||
if (resolution === 'view') {
|
||||
const fit = Math.min(1, MOST_WIDTH / shown.w, MOST_HEIGHT / shown.h);
|
||||
return { w: even(shown.w * fit), h: even(shown.h * fit) };
|
||||
}
|
||||
const h = resolution === '720p' ? 720 : 1080;
|
||||
return { w: even((h * whole.w) / whole.h), h };
|
||||
const height = RECORDING_RESOLUTIONS.find((r) => r.value === resolution)?.height ?? 0;
|
||||
const size = height > 0 ? { w: (height * whole.w) / whole.h, h: height } : shown;
|
||||
const fit = Math.min(1, MOST_WIDTH / size.w, MOST_HEIGHT / size.h);
|
||||
return { w: even(size.w * fit), h: even(size.h * fit) };
|
||||
}
|
||||
|
||||
function even(value: number): number {
|
||||
|
||||
@@ -83,6 +83,7 @@ import { EditorRecorder } from './EditorRecorder';
|
||||
import { EditorRecordingDialog } from './EditorRecordingDialog';
|
||||
import { editorRecordingSettings, keepEditorRecordingSettings } from './editorRecordingSettings';
|
||||
import type { MadeRecording } from '../viewport/RecordingTake';
|
||||
import type { RecordingUploadOptions } from '../core/recording/recording';
|
||||
import { ProjectSession } from './ProjectSession';
|
||||
import { ParticlesEditor } from './particles/ParticlesEditor';
|
||||
import { ShaderEditor } from './shader/ShaderEditor';
|
||||
@@ -1573,7 +1574,7 @@ export class Editor implements PlayHost {
|
||||
};
|
||||
}
|
||||
|
||||
recordingSaved(recording: MadeRecording): void {
|
||||
this.recordings.add(recording);
|
||||
recordingSaved(recording: MadeRecording, options: RecordingUploadOptions): void {
|
||||
this.recordings.add(recording, options);
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,15 @@ import {
|
||||
type CursorShape,
|
||||
} from '../core/recording/cursors';
|
||||
import { editorRecordingPlan, type EditorRecordingSettings } from '../core/recording/editorRecording';
|
||||
import { elapsedText, fitPicture, recordingName } from '../core/recording/recording';
|
||||
import {
|
||||
elapsedText,
|
||||
fitPicture,
|
||||
recordingName,
|
||||
type RecordingUploadOptions,
|
||||
} from '../core/recording/recording';
|
||||
import { renderCount, requestRender, Theme, toHex, ui } from '../ui';
|
||||
import { type FrameSource, type MadeRecording, Take, type TakeInfo } from '../viewport/RecordingTake';
|
||||
|
||||
/** What becomes of a recording on the server, as "Start recording…" asked. */
|
||||
export interface RecordingUploadOptions {
|
||||
convert: boolean;
|
||||
adopt: boolean;
|
||||
}
|
||||
|
||||
/** What the editor recorder needs of the editor. */
|
||||
export interface EditorRecorderHost {
|
||||
/** A line for the editor's notice. */
|
||||
|
||||
@@ -19,6 +19,7 @@ import { onFrame, ui } from '../ui';
|
||||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||||
import { GameRecorder } from '../viewport/GameRecorder';
|
||||
import type { MadeRecording } from '../viewport/RecordingTake';
|
||||
import type { RecordingUploadOptions } from '../core/recording/recording';
|
||||
import { videoLibrary } from '../viewport/VideoLibrary';
|
||||
import { FrameMeter, type FrameTimes } from './frameMeter';
|
||||
import { saveArea } from './saves';
|
||||
@@ -58,8 +59,8 @@ export interface PlayHost {
|
||||
showGame(): void;
|
||||
/** Leave the game view if it is the one on screen. */
|
||||
leaveGame(): void;
|
||||
/** A recording of the game view is made (#405): it goes into the project. */
|
||||
recordingSaved(recording: MadeRecording): void;
|
||||
/** A recording of the game view is made (#405): it goes up, and into the project as the node asked. */
|
||||
recordingSaved(recording: MadeRecording, options: RecordingUploadOptions): void;
|
||||
}
|
||||
|
||||
export class PlaySession {
|
||||
@@ -97,7 +98,7 @@ export class PlaySession {
|
||||
this.world?.log.push(line);
|
||||
console.warn('[recording]', line);
|
||||
},
|
||||
saved: (recording) => host.recordingSaved(recording),
|
||||
saved: (recording, options) => host.recordingSaved(recording, options),
|
||||
});
|
||||
// An object picked during a run is looked at now: the hierarchy has to list it to show it picked.
|
||||
state.on('selection', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { UploadedVideo } from '../core/store/VideoStore';
|
||||
import type { MadeRecording } from '../viewport/RecordingTake';
|
||||
import type { RecordingUploadOptions } from './EditorRecorder';
|
||||
import type { RecordingUploadOptions } from '../core/recording/recording';
|
||||
import { adoptVideo, type VideoImportDeps } from './videoImport';
|
||||
|
||||
/** How often the server is asked whether a recording is converted, as the video picker does. */
|
||||
@@ -24,8 +24,8 @@ export interface RecordingUpload {
|
||||
* Brings a recording of the game view (#405) or of the editor into the project: up to the server,
|
||||
* which converts it like any video, then an asset named after the recording once it is ready. Only
|
||||
* while the editor is not playing: a run plays a copy of the project that is thrown away, and so
|
||||
* would the asset be. An editor recording may stop short of either step ("Start recording…"): it
|
||||
* is always uploaded, and the video picker has it.
|
||||
* would the asset be. A recording may stop short of either step, as "Start recording…" or the node
|
||||
* asked: it is always uploaded, and the video picker has it.
|
||||
*/
|
||||
export class RecordingUploads {
|
||||
private readonly uploads: RecordingUpload[] = [];
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
type RecordingSettings,
|
||||
type RecordingSink,
|
||||
recordingSize,
|
||||
type RecordingUploadOptions,
|
||||
} from '../core/recording/recording';
|
||||
import { ui } from '../ui';
|
||||
import { PictureReader } from './PictureReader';
|
||||
import { type FrameSource, Take, type TakeHost, type TakeInfo } from './RecordingTake';
|
||||
import { type FrameSource, type MadeRecording, Take, type TakeHost, type TakeInfo } from './RecordingTake';
|
||||
import type { PictureArea } from './WorldViewport';
|
||||
|
||||
/** The view a recording takes its frames from (`WorldViewport`). */
|
||||
@@ -19,9 +20,11 @@ export interface RecordingView {
|
||||
missesVideo(): boolean;
|
||||
}
|
||||
|
||||
/** Where the recorder looks, and what it tells. */
|
||||
export interface RecordingHost extends TakeHost {
|
||||
/** Where the recorder looks, and what it tells; `saved` gets what the node asked to become of it. */
|
||||
export interface RecordingHost {
|
||||
view(): RecordingView | null;
|
||||
log(line: string): void;
|
||||
saved(recording: MadeRecording, options: RecordingUploadOptions): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,9 +49,21 @@ export class GameRecorder implements RecordingSink {
|
||||
return `Start recording is ignored: "${this.take.name}" is recording already – Stop recording ends it`;
|
||||
const name = recordingName(settings.name, new Date());
|
||||
const source = new ViewSource(name, settings, this.host);
|
||||
const take = new Take(name, settings.fps, source, this.host, () => {
|
||||
if (this.take === take) this.take = null;
|
||||
});
|
||||
const upload = { convert: settings.convert, adopt: settings.convert && settings.adopt };
|
||||
const host: TakeHost = {
|
||||
log: (line) => this.host.log(line),
|
||||
saved: (recording) => this.host.saved(recording, upload),
|
||||
};
|
||||
const take = new Take(
|
||||
name,
|
||||
settings.fps,
|
||||
source,
|
||||
host,
|
||||
() => {
|
||||
if (this.take === take) this.take = null;
|
||||
},
|
||||
{ sound: settings.sound },
|
||||
);
|
||||
this.take = take;
|
||||
this.takes.push(take);
|
||||
if (this.paused) take.setPaused(true);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user