Skip to content

Recording

NuclearMeltdown edited this page Aug 31, 2026 · 2 revisions

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

By file size

Optional, and off by default — splitSizeMb, 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 the source changes

Not optional, and not a setting. -s WxH and -r <fps> stand fixed in the command line for the length of a file, and rawvideo carries no header that could ever say anything else. A console switched from 60 to 50 Hz mid-recording, a switch box, a cable pulled and put back — from that moment the frames arriving do not fit the file being written. The old behaviour was to write them anyway: wrong line count, wrong tempo, and ffmpeg with no way of noticing.

The recording is now cut and continued in a new file at the new shape, the same StopRecording() / StartRecording() pair the size split uses. Two things are compared each pass, both against what ffmpeg was actually told at the start (Recorder::frameWidth(), frameHeight(), and recordSourceFps_):

Threshold
Frame size any difference, rounded down to even the way Start rounds
Frame rate more than 8 % from the rate the file was opened with
Held for 1.5 s before anything is cut

The rounding matters: an odd source is a legitimate one — a crop can leave 721 columns — and Start already writes 720 into the command line on purpose. Comparing raw numbers would cut such a file once a second forever.

The 1.5 seconds are there for the standard search. A search walks through several line counts on its way to an answer (see Automatic video standard), and the timer restarts whenever the observed shape changes, so a whole search produces at most one cut — the one where it settles. A search that ends where it started produces none.

The rate is compared undoubled, before the deinterlacer's frame doubling is applied. Somebody switching the deinterlacer mid-recording is changing a setting, not the source, and that must not cut the file. A genuine 25 → 29.97 does.

Underneath this sat a real out-of-bounds read, which is the reason it is not optional. PushVideo() copied height_ rows of width_ * 4 bytes out of the staging buffer without checking how large the frame in it actually was; a source that had just gone from 576 to 480 lines read a fifth of a frame past the end of it. PushVideo() now takes the frame's dimensions and drops anything that does not match, which covers the fraction of a second between the source changing and the app noticing.

The same comparison catches an older case for free. The rate written into -r is the measured arrival rate, and that measurement counts frames over a window of at least a second rather than averaging — so a recording started immediately after a graph rebuild can latch a half-filled window and write the wrong number into the command line. A second and a half later it no longer matches what is arriving, and the recording continues in a new file at the correct rate.

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