The recorder said "recording". It wrote zero bytes.

From the build of SKIP DAY

SKIP DAY is a lane runner about a gym bro who has never done leg day. Every squat rack he dodges shrinks his legs and inflates his chest, and around the forty-second mark the physics files its objection and he face-plants into a leg press with his arms still flexing. That face-plant is the whole marketing plan: it is Day 1 of a series for my fitness app, and the end screen is a real leg-day workout with a QR code. The game shipped at 2:41 in the morning with a verdict I was happy with. Then I sat down to film it, and the film was zero bytes long.

SKIP DAY at the moment of impact: a gym bro with a yellow refrigerator for a torso and two black drinking-straw legs face-planting into a purple leg press, +1 DAY floating over the aisle, a bystander in green watching from the right, the HUD reading DAYS SINCE LEG DAY 11 and PECS:QUADS 4.0

The rig

The capture rig is the one I have used for four games now. A headed Chromium opens the game page at a phone-shaped viewport. An init script wraps AudioContext so every context the game makes gets a MediaStreamDestination tapped off it, and pipes that tap into a capture context the script builds up front, so there is an audio track that exists from frame one. Then getDisplayMedia on the tab plus that audio track go into one MediaRecorder, and the page records itself. Picture and the game's own sound leave through one encoder.

The autopilot changed tonight. Earlier games needed a script to play them from outside, tapping and swiping on cue. This game already has an autopilot inside the sim, because the build loop needed to play it headless before scoring it. So the capture script got simpler: set the sim's autopilot to dodge, ask the game to start, and log the phase changes as they go by.

if (!started && s.phase === 'title') {
  s.debug.autopilot('dodge')
  window.__input.startRequested = true
  started = true
}

It worked immediately. The console said the run went over at 36.7 seconds, ratio 4.0, into a leg press. The frame counter said 2845 frames at 59.4 fps. And then:

recorded 0.0 MB over 42.8s { meanFps: 59.4, p95Ms: 18.3, frames: 2845 } finished: true

The webm on disk was 0 bytes. ffmpeg opened it and said "EBML header parsing failed", which is its way of saying there was no header to parse.

Everything said yes

The natural suspects, in the order I suspected them. The display: it was three in the morning and the Mac's display sleeps after an hour, so maybe tab capture of an unlit window delivers no frames. I woke it with caffeinate -u and ran again. Zero bytes. I added diagnostics to the recorder and read them back two and a half seconds into the take:

recording { trackMuted: false, trackState: 'live', mime: 'video/webm;codecs=vp9,opus',
            source: 'tab', w: 1082, h: 1924, audioTracks: 1 }
after 2.5s { chunks: 0, bytes: 0, state: 'recording', vMuted: false }

Every field said yes. The video track was live and not muted. The recorder's state was recording. It had been asked for a chunk every 1000 ms and had delivered none. Not empty chunks, which I was filtering, but no dataavailable events at all. Then I forced the video side onto canvas.captureStream(30) instead of the tab, which rules out the display entirely. Zero bytes.

So the video was fine both ways, and the thing that was the same both ways was the audio track.

A track that is live and will never speak

Here is the part I had not internalised about MediaRecorder. With two tracks, the muxer waits until it has data from both before it emits anything. It does not emit a video-only stream and add audio when it turns up. It does not time out. It does not error. It sits in state recording with a live, unmuted audio track that has simply never produced a frame, and it waits.

And my audio track could never produce a frame. The capture context is created in the init script, before any user gesture, so it is born suspended. The script only resumes it inside the wrapped constructor, the moment the game builds its own AudioContext:

const Patched = function (...a) {
  const ctx = new Native(...a)
  const tap = ctx.createMediaStreamDestination()
  ctx.__tap = tap
  if (window.__capCtx) {
    window.__capCtx.createMediaStreamSource(tap.stream).connect(window.__capDest)
    void window.__capCtx.resume().catch(() => {})
  }
  return ctx
}

That was the right design when the game's audio arrives with the first tap, and every earlier game started with a tap. SKIP DAY starts on a key, a swipe, or a pointer, and its scene wakes the audio engine on those events. My autopilot pressed none of them. It set a flag. The sim read the flag and started the run, the picture ran at sixty frames a second, and no AudioContext was ever constructed, so the capture context stayed suspended, so the audio track delivered nothing, so the recorder wrote nothing. Four links, every one of them reporting healthy.

The fix is two lines. Start the run the way a person does:

for (const type of ['keydown', 'keyup'])
  window.dispatchEvent(new KeyboardEvent(type, { code: 'Space', key: ' ', bubbles: true }))

Same take, same autopilot: 49.1 MB over 44.8 seconds, 2956 frames at 59.2 fps, the footsteps and the crash on the track. I also resume the capture context at recorder start now, so the next game that starts on a flag will at least record silence instead of nothing.

A phone filmstrip of one run: the fridge on straws sprinting, the topple, the face-plant into the leg press, the slide, and the body settling face down with white socks pointing at the ceiling

What I am taking from it

The failure had no error and no symptom except the size of the file at the very end. Every status I could ask for was green, because every status was describing the object and not the data. A track can be live and never speak. A recorder can be recording and never write. If I had checked bytes-written at two seconds instead of at the end, I would have lost two minutes instead of twenty, so the script now prints the byte count early and I will read it.

The other thing is smaller and I keep relearning it. A gesture is not a flag. Browsers gate audio on a real event on purpose, and a rig that fakes the outcome of a gesture without faking the gesture will drift away from what a human's session looks like in exactly the places that matter for recording it. Dispatching the key is not a workaround. It is the honest version.

The clip, for the record, was worth the twenty minutes.

The end screen: the bro face down on the black rubber floor next to a wrecked purple leg press, and a white GenFit card on the right listing a real leg day, goblet squats, Bulgarian split squats, Romanian deadlifts, walking lunges and calf raises, with a QR code, genfit.fit, an App Store badge and the line the app already knew

← All posts