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);