Skip to content

Recording

NuclearMeltdown edited this page Aug 24, 2026 · 1 revision

Recording

src/record/recorder.cpp, src/record/recorder.h

The Recording tab

Raw frames and raw audio are fed to an ffmpeg child process over pipes. Two rules shape the whole design.

Rule 1: the display path is never blocked

PushVideo() is called from the render thread with a freshly read-back frame. It copies into a triple buffer — the same shape as the capture sink — and returns. A writer thread owns the pipes.

If the encoder cannot keep up, frames are dropped from the recording, never from the screen. The screen is what you play on.

The readback itself is asynchronous. QueueReadback() issues a CopyResource into a staging texture and FetchReadback() maps it two frames later, so the display path never waits on the GPU. The recording is therefore a frame or two behind the screen, which is the right trade.

Rule 2: audio is the master clock

The card's clock and the PC's clock drift apart over an hour. The video timeline is derived from the number of audio samples written, duplicating or dropping frames to match — which makes sync arithmetic instead of hope, and produces constant frame rate output, which both MKV and MP4 prefer.

if (audioIsMaster) target = (uint64_t)((double)audioFrames / (double)audioRate_ * fps_);
else               target = (uint64_t)(QpcSeconds(now - startQpc_) * fps_);

Measured drift over the file: 1 ms over 15 seconds.

When audio cannot be the master

Two cases, both handled by falling back to the wall clock:

At the start. ffmpeg opens its inputs in order and blocks reading video before it ever opens the audio pipe, so waiting for audio samples before writing the first frame deadlocks both sides.

If it stops. A device disappearing mid-recording would otherwise freeze the video timeline forever:

const bool audioStalled = lastAudioProgressQpc_ != 0 &&
                          QpcSeconds(now - lastAudioProgressQpc_) > 2.0;

Two seconds of no progress, then the wall clock takes over and a warning goes to the log.

Every frame in the timeline gets written

for (uint64_t i = written; i < target && running_; ++i) {
  if (!WriteAll(videoPipe_, held, frameBytes_)) { … }
  videoFramesWritten_.fetch_add(1, …);
  if (!fresh || i > written) duplicated_.fetch_add(1, …);
}

Skipping one would make the video shorter than the audio and desync the file for good. If the encoder stalls, the pipe blocks right here — which is exactly where the waiting belongs, on the writer thread and not on the renderer.

The duplicated_ counter is what the statistics overlay reports.

What gets recorded

The picture at source resolution, after crop, deinterlacing and rotation, and before window scaling. Window size does not affect the result, and neither does sharpening.

Odd sizes break 4:2:0 chroma, so the width and height are rounded down by a pixel (width & ~1). The alternative is telling somebody their 1439 pixel capture cannot be recorded, which helps nobody.

The pipes

Video goes in on pipe:0 — ffmpeg's standard input. Every audio track goes through its own named pipe, because a process has only one standard input.

AudioThread() connects each pipe with an overlapped ConnectNamedPipe and a 100 ms polling wait, so an ffmpeg that never starts cannot leave the thread stuck and hang the whole shutdown.

The command line

-hide_banner -loglevel error -y
-f rawvideo -pix_fmt <from the renderer> -s WxH -r <fps> -i pipe:0
[-f f32le -ar <rate> -ac 2 -i <capture pipe>]
[-f f32le -ar <rate> -ac 2 -i <mic pipe>]
[-filter_complex "[1:a][2:a]amix=inputs=2:duration=first:normalize=0[mix]"]
-map 0:v -map …
-c:v <encoder> <encoder options>
-pix_fmt nv12 | -pix_fmt p010le -color_primaries bt2020 -color_trc smpte2084 -colorspace bt2020nc -color_range tv
-b:v … -maxrate … -bufsize …
[-c:a aac -b:a 192k -metadata:s:a:N title="…"]
<output file>

A few of those lines are load-bearing:

The pixel format comes from the renderer (VideoRenderer::kReadbackPixelFormat) rather than being spelled out in the recorder. These two have to agree byte for byte, and a literal in the recorder is exactly how they came apart once already: the staging texture is R8G8B8A8_UNORM, so the bytes run R, G, B, A — that is ffmpeg's rgba, not bgra. Getting it wrong is not a crash; red and blue simply trade places and orange comes back blue.

Declaring -r on the input is what makes the output constant frame rate. The writer thread guarantees that many frames per second actually arrive.

normalize=0 on amix matters: amix otherwise divides every input by the number of inputs, which would make the game quieter in the mix than on its own track and leave people wondering what happened. The sum can clip if both are hot — that is what the level meters are for.

The three colour description flags for HDR are not optional. Nothing else in the file says the picture is on the PQ curve, and a player that is not told will assume it is not.

Named audio tracks, so a player and an editor both show which is which instead of "Audio 1" and "Audio 2".

Track layout

With both a capture source and a microphone, three layouts are available:

Mode Tracks in the file
Both (default) Mix, Capture, Microphone
Mixed only Mix
Separate only Capture, Microphone

The mix is made by ffmpeg rather than in CapView, so the separate tracks stay exactly what each device delivered — no resampling, no drift correction, no volume. See Audio for why that matters.

The microphone is never played back and never mixed into what you hear.

Containers, and the remuxer

MKV and MP4, with MKV the default because it survives a crash: a truncated MKV plays right up to the point the power went out, where an MP4 whose moov atom was never finalised has no header and will not open at all.

MP4 is what everything else wants to be handed, so the two together need a step in between. src/record/remuxer.cpp is that step, reachable from the Recording tab: pick any number of finished recordings and it rewraps them into the other container. Nothing is re-encoded — the frames are copied across byte for byte, which takes seconds rather than the length of the recording and cannot lose quality.

It runs on its own thread with a progress bar, and only failures are listed in the UI afterwards: a success is its own file on disk.

Splitting

Optional, and by file size onlysplitSizeMb, default 4000, which is the FAT32 case it exists for.

App::FeedRecorder() polls outputFileSize() once a second and, past the limit, calls StopRecording() immediately followed by StartRecording().

That is a restart, not a seamless cut: ffmpeg has to close the container it is writing, and a fraction of a second is lost at the boundary. Which is why it is off unless somebody really is on FAT32.

When ffmpeg dies

FeedRecorder() also checks recorder_.failed() each pass. If the child process exited on its own, the recording is stopped cleanly and the error is shown as a toast, rather than the writer thread continuing to fill a dead pipe.

Clone this wiki locally