Record the game view from a behavior into a video asset (#405)
game-maker / gate (push) Successful in 4m14s
game-maker / suite (push) Successful in 17m7s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-11 21:11:05 +02:00
1 parent 36ffd9a61a
commit e191dc4b2e
35 files changed
+2211 -26

No files matched your search

+374
View File
@@ -0,0 +1,374 @@
/**
* Matroska (`.mkv`), the file a recording of the game view is made into (#405): the encoded frames
* of a `VideoEncoder` and the Opus packets of an `AudioEncoder`, in clusters as they come. Only the
* head waits for the end – it holds what the encoders say about themselves once they ran, and the
* length – so the clusters can leave the writer long before.
*/
import { OPUS_RATE } from '../music/ogg';
export const AVC_CODEC = 'V_MPEG4/ISO/AVC';
export const VP9_CODEC = 'V_VP9';
export const OPUS_CODEC = 'A_OPUS';
/** Milliseconds per tick of a block's time: the usual 1 ms, so a cluster spans 32 s at most. */
const TIMESTAMP_SCALE = 1_000_000;
/** What a cluster may span before the next one starts, inside the ±32767 ticks of a block. */
const CLUSTER_SPAN_MS = 30_000;
const CLUSTER_MOST_BYTES = 8 * 1024 * 1024;
/** How far one track may run ahead of a silent other before its blocks are written anyway. */
const INTERLEAVE_WAIT_MS = 1000;
/** What Opus asks a player to decode before a point it seeks to (RFC 7845: 80 ms). */
const OPUS_PRE_ROLL_NS = 80_000_000;
const ID = {
ebml: 0x1a45dfa3,
ebmlVersion: 0x4286,
ebmlReadVersion: 0x42f7,
ebmlMaxIdLength: 0x42f2,
ebmlMaxSizeLength: 0x42f3,
docType: 0x4282,
docTypeVersion: 0x4287,
docTypeReadVersion: 0x4285,
segment: 0x18538067,
info: 0x1549a966,
timestampScale: 0x2ad7b1,
duration: 0x4489,
muxingApp: 0x4d80,
writingApp: 0x5741,
tracks: 0x1654ae6b,
trackEntry: 0xae,
trackNumber: 0xd7,
trackUid: 0x73c5,
trackType: 0x83,
flagLacing: 0x9c,
codecId: 0x86,
codecPrivate: 0x63a2,
defaultDuration: 0x23e383,
codecDelay: 0x56aa,
seekPreRoll: 0x56bb,
video: 0xe0,
pixelWidth: 0xb0,
pixelHeight: 0xba,
colour: 0x55b0,
matrixCoefficients: 0x55b1,
range: 0x55b9,
transferCharacteristics: 0x55ba,
primaries: 0x55bb,
audio: 0xe1,
samplingFrequency: 0xb5,
channels: 0x9f,
cluster: 0x1f43b675,
timestamp: 0xe7,
simpleBlock: 0xa3,
} as const;
export interface MatroskaVideo {
codec: typeof AVC_CODEC | typeof VP9_CODEC;
width: number;
height: number;
/** H.264 needs its `avcC`, the `description` the encoder hands out with its first frame. */
private: Uint8Array | null;
/** The frame rate asked for: without it a player guesses one from the times of the frames. */
fps: number;
colour: MatroskaColour;
}
/** How the pixels are to be read, by the numbers of ITU-T H.273 that Matroska takes. */
export interface MatroskaColour {
matrix: number;
transfer: number;
primaries: number;
fullRange: boolean;
}
/** What Chrome's encoders make of a canvas when they do not say: BT.709 in the broadcast range. */
const BT709: MatroskaColour = { matrix: 1, transfer: 1, primaries: 1, fullRange: false };
const MATRICES: Record<string, number> = { rgb: 0, bt709: 1, bt470bg: 5, smpte170m: 6, 'bt2020-ncl': 9 };
const TRANSFERS: Record<string, number> = {
bt709: 1,
smpte170m: 6,
linear: 8,
'iec61966-2-1': 13,
pq: 16,
hlg: 18,
};
const PRIMARIES: Record<string, number> = { bt709: 1, bt470bg: 5, smpte170m: 6, bt2020: 9, smpte432: 12 };
/**
* The colour a `VideoColorSpaceInit` names, as a WebCodecs encoder reports it; what it leaves out is
* taken from BT.709. Without it a player falls back on its own guess, and ffmpeg's is BT.601: a red
* of 255 came back as 233.
*/
export function colourOf(
space: {
matrix?: string | null;
transfer?: string | null;
primaries?: string | null;
fullRange?: boolean | null;
} | null,
): MatroskaColour {
return {
matrix: MATRICES[space?.matrix ?? ''] ?? BT709.matrix,
transfer: TRANSFERS[space?.transfer ?? ''] ?? BT709.transfer,
primaries: PRIMARIES[space?.primaries ?? ''] ?? BT709.primaries,
fullRange: space?.fullRange ?? BT709.fullRange,
};
}
export interface MatroskaAudio {
channels: number;
/** The OpusHead, the encoder's `description`. */
private: Uint8Array | null;
/** Samples at 48 kHz the decoder throws away at the start, as the OpusHead says. */
preSkip: number;
}
export interface MatroskaHead {
video: MatroskaVideo;
audio: MatroskaAudio | null;
durationMs: number;
app: string;
}
/** The track a block belongs to: the video is always the first, the sound the second. */
export type MatroskaTrack = 1 | 2;
interface Block {
track: MatroskaTrack;
ms: number;
key: boolean;
data: Uint8Array;
}
/**
* Takes the frames of both tracks as the encoders hand them out and writes them in clusters, in the
* order of their time. The two tracks arrive apart, so a block waits until the other track has come
* as far, or until it is `INTERLEAVE_WAIT_MS` behind what that track already brought.
*/
export class MatroskaWriter {
private readonly waiting: [Block[], Block[]] = [[], []];
/** The latest time each track has brought, null before its first block. */
private readonly latest: [number | null, number | null] = [null, null];
private cluster: { ms: number; blocks: Uint8Array[]; bytes: number } | null = null;
private done: Uint8Array[] = [];
private written = 0;
/** `tracks` 1 is a recording without sound. */
constructor(private readonly tracks: 1 | 2) {}
/** Bytes of the clusters so far, the open one included: what the file is without its head. */
get bytes(): number {
return this.written + (this.cluster?.bytes ?? 0);
}
add(track: MatroskaTrack, timestampUs: number, key: boolean, data: Uint8Array): void {
const ms = Math.round(timestampUs / 1000);
this.waiting[track - 1]!.push({ track, ms, key, data });
const latest = this.latest[track - 1] ?? null;
this.latest[track - 1] = latest === null ? ms : Math.max(latest, ms);
this.interleave(false);
}
/** The clusters finished since the last call, in file order. */
take(): Uint8Array[] {
const done = this.done;
this.done = [];
return done;
}
/** Writes what still waits and closes the last cluster; the head goes in front of everything taken. */
finish(head: MatroskaHead): { head: Uint8Array; rest: Uint8Array[] } {
this.interleave(true);
this.closeCluster();
return { head: headOf(head, this.written), rest: this.take() };
}
private interleave(all: boolean): void {
for (;;) {
const next = this.nextBlock(all);
if (!next) return;
this.write(next);
}
}
/** The earliest waiting block, if it may be written yet. */
private nextBlock(all: boolean): Block | null {
const [first, second] = this.waiting;
const a = first[0];
const b = this.tracks === 2 ? second[0] : undefined;
const earliest = a && b ? (b.ms < a.ms ? b : a) : (a ?? b);
if (!earliest) return null;
if (!all && this.tracks === 2) {
const other = this.latest[earliest.track === 1 ? 1 : 0];
const own = this.latest[earliest.track - 1] ?? earliest.ms;
const caughtUp = other !== null && other >= earliest.ms;
if (!caughtUp && own - earliest.ms < INTERLEAVE_WAIT_MS) return null;
}
this.waiting[earliest.track - 1]!.shift();
return earliest;
}
private write(block: Block): void {
const open = this.cluster;
const startsCluster =
!open ||
(block.track === 1 && block.key && open.blocks.length > 0) ||
Math.abs(block.ms - open.ms) > CLUSTER_SPAN_MS ||
open.bytes > CLUSTER_MOST_BYTES;
if (startsCluster) {
this.closeCluster();
this.cluster = { ms: block.ms, blocks: [], bytes: 0 };
}
const cluster = this.cluster!;
const header = new Uint8Array(4);
header[0] = 0x80 | block.track;
new DataView(header.buffer).setInt16(1, block.ms - cluster.ms);
header[3] = block.key ? 0x80 : 0;
const element = concat([
idBytes(ID.simpleBlock),
sizeBytes(header.length + block.data.length),
header,
block.data,
]);
cluster.blocks.push(element);
cluster.bytes += element.length;
}
private closeCluster(): void {
const cluster = this.cluster;
this.cluster = null;
if (!cluster || cluster.blocks.length === 0) return;
const body = [uint(ID.timestamp, Math.max(0, cluster.ms)), ...cluster.blocks];
const bytes = master(ID.cluster, body);
this.done.push(bytes);
this.written += bytes.length;
}
}
/** The EBML header, and the segment's own head with its size, info and tracks. */
function headOf(head: MatroskaHead, clusterBytes: number): Uint8Array {
const ebml = master(ID.ebml, [
uint(ID.ebmlVersion, 1),
uint(ID.ebmlReadVersion, 1),
uint(ID.ebmlMaxIdLength, 4),
uint(ID.ebmlMaxSizeLength, 8),
text(ID.docType, 'matroska'),
uint(ID.docTypeVersion, 4),
uint(ID.docTypeReadVersion, 2),
]);
const info = master(ID.info, [
uint(ID.timestampScale, TIMESTAMP_SCALE),
float(ID.duration, head.durationMs),
text(ID.muxingApp, head.app),
text(ID.writingApp, head.app),
]);
const { video, audio } = head;
const entries = [
master(ID.trackEntry, [
uint(ID.trackNumber, 1),
uint(ID.trackUid, 1),
uint(ID.trackType, 1),
uint(ID.flagLacing, 0),
text(ID.codecId, video.codec),
...(video.private ? [binary(ID.codecPrivate, video.private)] : []),
uint(ID.defaultDuration, Math.round(1e9 / video.fps)),
master(ID.video, [
uint(ID.pixelWidth, video.width),
uint(ID.pixelHeight, video.height),
master(ID.colour, [
uint(ID.matrixCoefficients, video.colour.matrix),
uint(ID.range, video.colour.fullRange ? 2 : 1),
uint(ID.transferCharacteristics, video.colour.transfer),
uint(ID.primaries, video.colour.primaries),
]),
]),
]),
];
if (audio)
entries.push(
master(ID.trackEntry, [
uint(ID.trackNumber, 2),
uint(ID.trackUid, 2),
uint(ID.trackType, 2),
uint(ID.flagLacing, 0),
text(ID.codecId, OPUS_CODEC),
...(audio.private ? [binary(ID.codecPrivate, audio.private)] : []),
uint(ID.codecDelay, Math.round((audio.preSkip * 1e9) / OPUS_RATE)),
uint(ID.seekPreRoll, OPUS_PRE_ROLL_NS),
master(ID.audio, [float(ID.samplingFrequency, OPUS_RATE), uint(ID.channels, audio.channels)]),
]),
);
const tracks = master(ID.tracks, entries);
const segmentSize = info.length + tracks.length + clusterBytes;
return concat([ebml, idBytes(ID.segment), sizeBytes(segmentSize, 8), info, tracks]);
}
/** An element id as it is written: its own marker bits are part of the number. */
function idBytes(id: number): Uint8Array {
const length = id > 0xffffff ? 4 : id > 0xffff ? 3 : id > 0xff ? 2 : 1;
const out = new Uint8Array(length);
for (let i = 0; i < length; i++) out[i] = Math.floor(id / 256 ** (length - 1 - i)) % 256;
return out;
}
/** A size as an EBML variable-length integer, in the fewest bytes or in `length` of them. */
function sizeBytes(size: number, length = sizeLength(size)): Uint8Array {
const out = new Uint8Array(length);
let rest = size;
for (let i = length - 1; i >= 0; i--) {
out[i] = rest % 256;
rest = Math.floor(rest / 256);
}
out[0]! |= 0x80 >> (length - 1);
return out;
}
/** All ones is "unknown", so a size needs one value more than its bits would hold. */
function sizeLength(size: number): number {
let length = 1;
while (size >= 2 ** (7 * length) - 1) length++;
return length;
}
function element(id: number, data: Uint8Array): Uint8Array {
return concat([idBytes(id), sizeBytes(data.length), data]);
}
function master(id: number, children: readonly Uint8Array[]): Uint8Array {
return element(id, concat(children));
}
function uint(id: number, value: number): Uint8Array {
const bytes: number[] = [];
let rest = Math.max(0, Math.round(value));
do {
bytes.unshift(rest % 256);
rest = Math.floor(rest / 256);
} while (rest > 0);
return element(id, Uint8Array.from(bytes));
}
function float(id: number, value: number): Uint8Array {
const data = new Uint8Array(8);
new DataView(data.buffer).setFloat64(0, value);
return element(id, data);
}
function text(id: number, value: string): Uint8Array {
return element(id, new TextEncoder().encode(value));
}
function binary(id: number, value: Uint8Array): Uint8Array {
return element(id, value);
}
function concat(parts: readonly Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
let at = 0;
for (const part of parts) {
out.set(part, at);
at += part.length;
}
return out;
}
+177
View File
@@ -0,0 +1,177 @@
/**
* Recording the game view (#405): what `Start recording` asks for, and the rules a recording is made
* by – its size, its name, which H.264 it is, when a frame is taken and where the picture lands.
* The browser's side, encoders and canvas, is `viewport/GameRecorder.ts`.
*/
export type RecordingResolution = 'view' | '720p' | '1080p';
export interface RecordingSettings {
/** Empty: named after the moment it starts (`recordingName`). */
name: string;
resolution: RecordingResolution;
fps: 30 | 60;
}
export const RECORDING_RESOLUTIONS: readonly { value: RecordingResolution; label: string }[] = [
{ value: 'view', label: 'Game view' },
{ value: '720p', label: '720p' },
{ value: '1080p', label: '1080p' },
];
export const RECORDING_FPS = [
{ value: '60', label: '60' },
{ value: '30', label: '30' },
];
/** What a run hands its graphs to record with; each answer is a line for the log, '' for none. */
export interface RecordingSink {
start(settings: RecordingSettings): string;
stop(): string;
}
/** 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. */
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;
export function recordingSettingsOf(
name: string,
resolution: string,
fps: string | number,
): RecordingSettings {
const known = RECORDING_RESOLUTIONS.find((r) => r.value === resolution);
return { name: name.trim(), resolution: known?.value ?? 'view', fps: Number(fps) === 30 ? 30 : 60 };
}
/** The name a recording is saved under: the one asked for, else `Recording 2026-10-11 16:42:05`. */
export function recordingName(asked: string, at: Date): string {
if (asked.trim()) return asked.trim();
const two = (n: number) => String(n).padStart(2, '0');
const day = `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())}`;
return `Recording ${day} ${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())}`;
}
/**
* 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.
*/
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 };
}
function even(value: number): number {
return Math.max(2, Math.round(value / 2) * 2);
}
/** H.264 levels by the frame size and macroblocks a second they allow (ITU-T H.264, table A-1). */
const AVC_LEVELS: readonly { level: number; frame: number; rate: number }[] = [
{ level: 0x1f, frame: 3600, rate: 108_000 },
{ level: 0x20, frame: 5120, rate: 216_000 },
{ level: 0x28, frame: 8192, rate: 245_760 },
{ level: 0x2a, frame: 8704, rate: 522_240 },
{ level: 0x32, frame: 22_080, rate: 589_824 },
{ level: 0x33, frame: 36_864, rate: 983_040 },
{ level: 0x34, frame: 36_864, rate: 2_073_600 },
{ level: 0x3c, frame: 139_264, rate: 4_177_920 },
];
/** The codec string of H.264 High at the lowest level that takes these frames. */
export function avcCodec(w: number, h: number, fps: number): string {
const frame = Math.ceil(w / 16) * Math.ceil(h / 16);
const found = AVC_LEVELS.find((l) => frame <= l.frame && frame * fps <= l.rate) ?? AVC_LEVELS.at(-1)!;
return `avc1.6400${found.level.toString(16).padStart(2, '0')}`;
}
/** VP9 profile 0 at 8 bits; level 4.1 takes 1080p60, 5.1 4K60. */
export function vp9Codec(w: number, h: number): string {
return w * h <= 2_228_224 ? 'vp09.00.41.08' : 'vp09.00.51.08';
}
export function videoBitrate(w: number, h: number, fps: number): number {
return Math.round(w * h * fps * BITS_PER_PIXEL);
}
/** Whether the `index`th frame taken (from 0) starts a group, so a player can seek to it. */
export function isKeyFrame(index: number, fps: number): boolean {
return index % (fps * KEY_FRAME_SECONDS) === 0;
}
/**
* The time of a recording in milliseconds, from its first frame: real time, minus what the editor
* held it paused. A game that pauses itself is still seen, so that is not taken out.
*/
export class RecordingClock {
private origin: number | null = null;
private pausedAt: number | null = null;
private paused = 0;
get started(): boolean {
return this.origin !== null;
}
start(now: number): void {
this.origin = now;
}
pause(now: number): void {
this.pausedAt ??= now;
}
resume(now: number): void {
if (this.pausedAt === null) return;
this.paused += now - this.pausedAt;
this.pausedAt = null;
}
at(now: number): number {
if (this.origin === null) return 0;
return Math.max(0, (this.pausedAt ?? now) - this.origin - this.paused);
}
}
/**
* Which drawn frames a recording takes: about `fps` a second whatever the screen draws. A frame a
* little early still counts, so 60 at 60 Hz takes every one however unevenly they come.
*/
export class FrameGate {
private next = 0;
constructor(private readonly fps: number) {}
take(ms: number): boolean {
const period = 1000 / this.fps;
if (ms < this.next - period / 4) return false;
this.next = Math.max(this.next + period, ms + period / 2);
return true;
}
}
/**
* Where the view's local CSS pixels land when `area` of it (a centre and a size) fills a frame of
* `size` pixels: the recording draws into a texture of that size, whatever the window's.
*/
export function pictureTransform(
area: { x: number; y: number; w: number; h: number },
size: { w: number; h: number },
): { a: number; d: number; tx: number; ty: number } {
const sx = size.w / area.w;
const sy = size.h / area.h;
return { a: sx, d: sy, tx: size.w / 2 - area.x * sx, ty: size.h / 2 - area.y * sy };
}