diff --git a/src/core/graph/nodes/action/recordingNodes.ts b/src/core/graph/nodes/action/recordingNodes.ts index 03e7dc65..7f996233 100644 --- a/src/core/graph/nodes/action/recordingNodes.ts +++ b/src/core/graph/nodes/action/recordingNodes.ts @@ -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' }; }, diff --git a/src/core/recording/recording.ts b/src/core/recording/recording.ts index ff8fc51c..0afcffc9 100644 --- a/src/core/recording/recording.ts +++ b/src/core/recording/recording.ts @@ -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> = {}, ): 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 { diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 011cbbcf..82a2edd7 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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); } } diff --git a/src/editor/EditorRecorder.ts b/src/editor/EditorRecorder.ts index d5f603b8..8725f769 100644 --- a/src/editor/EditorRecorder.ts +++ b/src/editor/EditorRecorder.ts @@ -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. */ diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 5e0eb177..131cdf1a 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -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', () => { diff --git a/src/editor/recordingUploads.ts b/src/editor/recordingUploads.ts index d6f330a0..a4c6fff5 100644 --- a/src/editor/recordingUploads.ts +++ b/src/editor/recordingUploads.ts @@ -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[] = []; diff --git a/src/viewport/GameRecorder.ts b/src/viewport/GameRecorder.ts index 20431f52..656a81f3 100644 --- a/src/viewport/GameRecorder.ts +++ b/src/viewport/GameRecorder.ts @@ -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); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 8c6ba930..894048c9 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -2429,7 +2429,10 @@ const CASES: Record = { 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: [ diff --git a/tests/core/recording.test.ts b/tests/core/recording.test.ts index b7c9de46..eb6e4b84 100644 --- a/tests/core/recording.test.ts +++ b/tests/core/recording.test.ts @@ -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', () => {