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>
83 lines
3.0 KiB
TypeScript
83 lines
3.0 KiB
TypeScript
import { CreateVideoCommand } from '../core/commands/assetCommands';
|
||
import type { UploadedVideo, VideoStore } from '../core/store/VideoStore';
|
||
import { adopt, type ImportKind, type ImportOutcome, importStored } from './assetImport';
|
||
import type { EditorState } from './state';
|
||
|
||
/** What the file dialog offers and a drop accepts; ffmpeg on the server has the last word. */
|
||
export const VIDEO_ACCEPT = '.mp4,.mov,.m4v,.webm,.mkv,.avi';
|
||
|
||
const VIDEO_EXTENSION = /\.(mp4|mov|m4v|webm|mkv|avi)$/i;
|
||
|
||
export function isVideoFileName(name: string): boolean {
|
||
return VIDEO_EXTENSION.test(name);
|
||
}
|
||
|
||
export interface VideoImportDeps {
|
||
state: EditorState;
|
||
videos: VideoStore;
|
||
}
|
||
|
||
/**
|
||
* A video becomes an asset only once the worker has made it (#402): the asset copies its size,
|
||
* length and frame rate, which are not known before.
|
||
*/
|
||
const VIDEO_KIND: ImportKind<UploadedVideo, VideoImportDeps> = {
|
||
type: 'video',
|
||
noun: 'video',
|
||
fallbackName: 'Video',
|
||
store: (deps) => deps.videos,
|
||
prepare: (file) =>
|
||
isVideoFileName(file.name)
|
||
? Promise.resolve({ blob: file, type: 'application/octet-stream' })
|
||
: Promise.reject(new Error('Not a video file – use MP4, MOV, WebM, MKV or AVI.')),
|
||
create: (name, video) =>
|
||
new CreateVideoCommand(name, video.file, {
|
||
width: video.width ?? 0,
|
||
height: video.height ?? 0,
|
||
duration: video.duration ?? 0,
|
||
fps: video.fps ?? 0,
|
||
audio: video.audio ?? false,
|
||
}),
|
||
};
|
||
|
||
export function importStoredVideo(video: UploadedVideo, deps: VideoImportDeps): ImportOutcome {
|
||
return importStored(VIDEO_KIND, video, deps);
|
||
}
|
||
|
||
/** Makes an asset named `name` of a video the server has converted, without opening it (#405). */
|
||
export function adoptVideo(video: UploadedVideo, name: string, deps: VideoImportDeps): ImportOutcome {
|
||
return adopt(VIDEO_KIND, video, name, name, deps);
|
||
}
|
||
|
||
/**
|
||
* Sends the files up one after the other and says how it went; what is done already is made an
|
||
* asset at once. The rest is converted on the server – `waiting` gets their names, for the picker
|
||
* to watch and adopt when they are ready.
|
||
*/
|
||
export async function uploadVideos(
|
||
files: readonly File[],
|
||
deps: VideoImportDeps,
|
||
waiting: (files: string[]) => void,
|
||
): Promise<void> {
|
||
const pending: string[] = [];
|
||
const failed: string[] = [];
|
||
for (const file of files) {
|
||
try {
|
||
const { blob, type } = await VIDEO_KIND.prepare(file);
|
||
const uploaded = await deps.videos.upload(blob, type, file.name);
|
||
if (uploaded.status === 'ready') importStoredVideo(uploaded, deps);
|
||
else pending.push(uploaded.file);
|
||
} catch (error) {
|
||
failed.push(`"${file.name}": ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
}
|
||
if (pending.length > 0) waiting(pending);
|
||
if (failed.length > 0) deps.state.setNotice(`Could not upload ${failed.join(', ')}`, 'error');
|
||
else if (pending.length > 0)
|
||
deps.state.setNotice(
|
||
pending.length === 1
|
||
? 'Converting the video on the server…'
|
||
: `Converting ${pending.length} videos on the server…`,
|
||
);
|
||
}
|