A game can now film itself. A behavior starts and stops a take, and the editor turns it into a video asset with the run's picture and the sound the player hears. The exported game hands the file over as a download, since there is no project to keep it in. - New action nodes `Start recording` (Name, Resolution, FPS) and `Stop recording`. An empty name becomes `Recording <timestamp>`. - Resolution is `Game view`, `720p` or `1080p`. For the fixed sizes the height is set and the width follows the camera. - The picture is the camera only, without hints, overlays or the letterbox. It is drawn into a render texture of the target size, so a smaller window still gives full 1080p. - `PictureReader` reads the texture back through pixel buffers with a fence. The editor never waits on the GPU, and a frame with all three buffers busy is dropped and counted. - Nothing of the take reaches the visible canvas. A first version drew tiles into it and copied them out, and with the low-latency canvas that midway state flickered on screen. - The sound is tapped behind the master limiter in the FX worklet and encoded as Opus. - H.264 is chosen when the browser has it, VP9 otherwise. A small own Matroska writer packs both tracks, no new dependency. It names the frame rate and BT.709 so ffmpeg keeps both. - A second `Start recording` is ignored with a warning in the log. A take goes on through `Load scene` and a restart, pauses with the editor and is saved when the run ends. It stops at 2 GB. - The editor uploads the take on the existing video route and makes the asset once the run is over. The video tab gets a Download button for the converted MP4. - `downloadBlob` in `src/ui` replaces the song editor's own copy. Findings: - `On start` runs before the game screen knows the world. A take therefore fixes its size on the first frame that has a camera. - Without a frame rate in the file, ffmpeg turned 30 fps into 59.94. - The worker's poster JPEG is off in colour for every upload (red 255 comes out as 229, BT.601). Not touched here. Tests: - Unit recording + matroska: 21 green, plus the node catalog cases. The Matroska test reads the file back with its own EBML reader. - e2e 67 (new) on melektaus-atx: ALL PASSED. 1080p30 from a 1280x800 window gives a 1920x1080 MP4 with sound and no dropped frame. Every 4th pixel is checked, a green quarter catches an upside-down picture. A take ended by Esc is saved with the timestamp name. - e2e 60j: the exported game downloads an `.mkv` with EBML magic. - The flicker is gone on the user's machine without `?vsync`. - Gate on melektaus-atx: CHECK EXIT 0, 299 files, 4586 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
85 lines
3.1 KiB
TypeScript
85 lines
3.1 KiB
TypeScript
import type { UploadedVideo } from '../core/store/VideoStore';
|
||
import type { MadeRecording } from '../viewport/GameRecorder';
|
||
import { adoptVideo, type VideoImportDeps } from './videoImport';
|
||
|
||
/** How often the server is asked whether a recording is converted, as the video picker does. */
|
||
const POLL_MS = 1000;
|
||
|
||
type UploadState = 'uploading' | 'converting' | 'added' | 'elsewhere' | 'failed';
|
||
|
||
/** What became of a recording on its way into the project (test hook). */
|
||
export interface RecordingUpload {
|
||
name: string;
|
||
state: UploadState;
|
||
/** The name the server gave it, once it is up. */
|
||
file: string;
|
||
assetId: string;
|
||
bytes: number;
|
||
error: string;
|
||
}
|
||
|
||
/**
|
||
* Brings a recording of the game view into the project (#405): 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.
|
||
*/
|
||
export class RecordingUploads {
|
||
private readonly uploads: RecordingUpload[] = [];
|
||
|
||
constructor(private readonly deps: VideoImportDeps) {}
|
||
|
||
add(recording: MadeRecording): void {
|
||
const upload: RecordingUpload = {
|
||
name: recording.name,
|
||
state: 'uploading',
|
||
file: '',
|
||
assetId: '',
|
||
bytes: recording.blob.size,
|
||
error: '',
|
||
};
|
||
this.uploads.push(upload);
|
||
void this.bring(recording, upload).catch((error: unknown) => {
|
||
upload.state = 'failed';
|
||
upload.error = error instanceof Error ? error.message : String(error);
|
||
this.deps.state.setNotice(`Could not save the recording "${recording.name}": ${upload.error}`, 'error');
|
||
});
|
||
}
|
||
|
||
info(): RecordingUpload[] {
|
||
return this.uploads.map((upload) => ({ ...upload }));
|
||
}
|
||
|
||
private async bring(recording: MadeRecording, upload: RecordingUpload): Promise<void> {
|
||
const { state, videos } = this.deps;
|
||
const projectId = state.project.id;
|
||
state.setNotice(`Uploading the recording "${recording.name}"…`);
|
||
let video: UploadedVideo = await videos.upload(
|
||
recording.blob,
|
||
'application/octet-stream',
|
||
`${recording.name}.mkv`,
|
||
);
|
||
upload.file = video.file;
|
||
upload.state = 'converting';
|
||
while (video.status !== 'ready') {
|
||
if (video.status === 'failed') throw new Error(video.error ?? 'the server could not convert it');
|
||
await wait(POLL_MS);
|
||
video = (await videos.list()).find((v) => v.file === upload.file) ?? video;
|
||
}
|
||
while (state.mode !== 'edit') await wait(POLL_MS);
|
||
if (state.project.id !== projectId) {
|
||
upload.state = 'elsewhere';
|
||
state.setNotice(`The recording "${recording.name}" is ready – the video picker has it.`);
|
||
return;
|
||
}
|
||
const outcome = adoptVideo(video, recording.name, this.deps);
|
||
if (!outcome.assetId) throw new Error(outcome.error ?? 'the project did not take it');
|
||
upload.state = 'added';
|
||
upload.assetId = outcome.assetId;
|
||
state.setNotice(`Added the recording "${recording.name}" to the project.`);
|
||
}
|
||
}
|
||
|
||
function wait(ms: number): Promise<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|