From 34433688553425d080301b259ac2de1c0d13ee2f Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 13 Oct 2026 10:11:27 +0200 Subject: [PATCH] Record a game from its very first frame Start recording has to set up its encoders first. It asks the browser whether it can encode H.264 at the size and hooks in the sound tap, both asynchronously, and it only took its size at the first frame. Meanwhile the game ran on unrecorded. A caption that fades in from alpha 0 at the start was already visible in the first recorded frame. - A run now waits while a recording sets up its encoders, at most 3 s, as it already waits for its videos at Play. That holds in the editor and in play.html. - The recording takes its size at Play, right after the game view has the world, rather than at the first frame. A Start recording in On start is so ready before the first tick. - A recording started in the middle of a game holds the picture for that moment. A sound that already plays goes on. Tests: - tsc, eslint, Prettier clean - e2e 67 on melektaus-atx: ALL PASSED, 11 checks (67a-ii new). The first recorded frame is at game time 0.0168 s, the first frame. - Without the wait 67a-ii is red: the first recorded frame is at 0.1667 s, so 10 frames were lost. - Gate on melektaus-atx: green, 305 files, 4740 tests, run on this commit and the one before together. - Not checked in the browser: a start in the middle of a game, and play.html. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/editor/Editor.ts | 5 ++++- src/editor/PlaySession.ts | 10 +++++++--- src/player/Player.ts | 4 +++- src/viewport/GameRecorder.ts | 13 +++++++++++++ src/viewport/RecordingTake.ts | 18 ++++++++++++++++++ tools/e2e/67-recording.mjs | 22 ++++++++++++++++++++++ 7 files changed, 68 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 711d703e..29aaf2c1 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2061 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2062 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 82a2edd7..f1eb15ed 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -1540,7 +1540,10 @@ export class Editor implements PlayHost { playing: boolean; paused: boolean; gamePaused: boolean; - /** Held at the start until the Game tab's driven videos have a picture (#404). */ + /** + * Held at the start until the Game tab's driven videos have a picture (#404), or while a + * recording sets up its encoders. + */ waiting: boolean; time: number; sceneId: string; diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 131cdf1a..44b67dcd 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -127,9 +127,12 @@ export class PlaySession { return this.world?.paused ?? false; } - /** Held at its start until the Game tab has a picture of its driven videos (#404). */ + /** + * Held at its start until the Game tab has a picture of its driven videos (#404), and while a + * recording sets up its encoders. + */ get isWaiting(): boolean { - return this.videoWait !== null; + return this.videoWait !== null || this.recorder.isStarting; } /** Pause or resume; before Play it switches whether Play starts paused. */ @@ -235,6 +238,7 @@ export class PlaySession { this.host.gameView.setWorld(this.run.world); this.host.showGame(); this.waitForVideos(); + this.recorder.prepare(); if (paused) { this.state.setPaused(true); this.applyPause(false); @@ -331,7 +335,7 @@ export class PlaySession { const run = this.run; if (!run) return; const world = run.world; - if ((this.state.paused && !this.stepping) || this.waitsForVideos()) { + if ((this.state.paused && !this.stepping) || this.waitsForVideos() || this.recorder.isStarting) { // Keys and clicks that happen while paused are dropped instead of piling up – otherwise // resuming would deliver every one of them in a single frame. this.input.endFrame(); diff --git a/src/player/Player.ts b/src/player/Player.ts index 54964438..2463ac55 100644 --- a/src/player/Player.ts +++ b/src/player/Player.ts @@ -78,6 +78,7 @@ export class Player { this.input.attach(); this.screen.setWorld(this.run.world); this.waitForVideos(); + this.recorder.prepare(); if (this.run.world.stopRequested) this.stop(); } @@ -103,7 +104,8 @@ export class Player { tick(dt: number): void { const run = this.run; if (!run) return; - if (this.waitsForVideos()) { + // A recording that sets up its encoders holds the run too: its frames meanwhile would be lost. + if (this.waitsForVideos() || this.recorder.isStarting) { this.input.endFrame(); run.world.dropPointerEdges(); this.screen.redraw(); diff --git a/src/viewport/GameRecorder.ts b/src/viewport/GameRecorder.ts index 656a81f3..10472bd1 100644 --- a/src/viewport/GameRecorder.ts +++ b/src/viewport/GameRecorder.ts @@ -44,6 +44,19 @@ export class GameRecorder implements RecordingSink { return this.take !== null; } + /** + * Whether a recording still sets up its encoders: the run waits for it, or the frames meanwhile + * are lost – the first of a game that starts recording in `On start` among them. + */ + get isStarting(): boolean { + return this.take?.isStarting ?? false; + } + + /** The view shows the world now: a recording started in `On start` takes its size already. */ + prepare(): void { + if (this.host.view()) this.take?.prepare(); + } + start(settings: RecordingSettings): string { if (this.take) return `Start recording is ignored: "${this.take.name}" is recording already – Stop recording ends it`; diff --git a/src/viewport/RecordingTake.ts b/src/viewport/RecordingTake.ts index 43e1c1e0..e54423fa 100644 --- a/src/viewport/RecordingTake.ts +++ b/src/viewport/RecordingTake.ts @@ -21,6 +21,8 @@ const FOLD_BYTES = 16 * 1024 * 1024; const LAST_SOUND_MS = 300; /** How long a stop waits for the frames still on their way to the encoder. */ const LAST_FRAMES_MS = 500; +/** How long a run waits for the encoders to be set up, see `isStarting`. */ +const START_WAIT_MS = 3000; /** A recording once it is made. */ export interface MadeRecording { @@ -120,6 +122,8 @@ export class Take { private heldSince: number | null = null; private lastFrameAt: number | null = null; private failure: string | null = null; + /** When the encoders began to be set up, by `performance.now()`. */ + private startingAt = 0; constructor( readonly name: string, @@ -148,6 +152,7 @@ export class Take { if (!size) return; this.size = size; this.state = 'starting'; + this.startingAt = performance.now(); void this.openEncoders().then( () => { if (this.state !== 'starting') return; @@ -234,6 +239,19 @@ export class Take { tap.node.port.onmessage = (event: MessageEvent) => this.heard(event.data); } + /** + * Whether the encoders are still being set up, at most `START_WAIT_MS`: no frame can be taken + * meanwhile, so a run waits for it rather than playing its first moments unrecorded. + */ + get isStarting(): boolean { + return this.state === 'starting' && performance.now() - this.startingAt < START_WAIT_MS; + } + + /** Takes the size now, when there is a picture, rather than at the first frame (`capture`). */ + prepare(): void { + if (this.state === 'waiting') this.open(); + } + setPaused(paused: boolean): void { this.stand(() => (this.paused = paused)); } diff --git a/tools/e2e/67-recording.mjs b/tools/e2e/67-recording.mjs index e38c83bd..5cdb9d26 100644 --- a/tools/e2e/67-recording.mjs +++ b/tools/e2e/67-recording.mjs @@ -62,6 +62,19 @@ export async function recording(t) { // 67a. Recorded from the start of the run, at 1080p and 30 fps, with the master's sound. The window // is smaller than 1080p: the frame is drawn into a texture of its own (`PictureReader`). + // The game time is written down each frame until the first frame is taken. + await evaluate(`(() => { + window.__rc67 = { at: null }; + const look = () => { + const take = __gm.recording().takes[0]; + if (take?.lastFrameAt != null) { + window.__rc67.at = __gm.playInfo().time; + return; + } + requestAnimationFrame(look); + }; + requestAnimationFrame(look); + })()`); await play(); await sleep(1200); await press('v'); @@ -81,6 +94,15 @@ export async function recording(t) { JSON.stringify(first), ); + // 67a-ii. The run waits while the recording sets up its encoders: a recording started in + // `On start` has the first frames of the game, not what the game shows a moment later. + const firstAt = await json('window.__rc67.at'); + check( + 'a recording started in On start takes the first frames of the run', + typeof firstAt === 'number' && firstAt <= 3 / 60, + JSON.stringify({ firstAt }), + ); + // 67b. A second start while one records is ignored, and says so in the log. await press('g'); await sleep(300);