Skip to main content

Pure-v2 Capture Pipeline

Pure-v2 is the current recording path. It captures frames, encodes video, muxes audio and video into MP4, writes CSV events, and emits JSON metadata.

Pipeline

Verified invariants

These properties were established by reading the production code path and hand-verifying them during a reliability review (EC-282) and the dead-code removal that followed (EC-283). They are documented here because each one is a non-obvious fact that a casual read of the source — or an automated scan — tends to get wrong, flagging a bug that does not exist. Treat them as checked axioms: if you think you've found a defect that contradicts one of these, re-read the cited code before filing it.

  • WGC always runs in queue mode in production. DesktopCapture calls enableQueueMode(true) unconditionally before starting capture (desktop_capture.cpp). Consequences:
    • The only frame producer is the polling thread (WindowsGraphicsCapture::capturePollingThreadFuncenqueueFrame). The old event-driven onFrameArrived path was never enabled in production and has been removed (EC-283), along with the superseded single-texture processFrameWithZeroCopy / zeroCopyTexture_ mechanism and the CPU processFrameWithStaging readback. Zero-copy GPU→NVENC lives exclusively in the pooled path: the D3D11TexturePool BGRA slot is fed directly to NVENC as an NV_ENC_INPUT_RESOURCE_TYPE_DIRECTX input resource, with no per-frame CUDA-context copy (EC-312). The legacy CUDA path (register the texture as a CUDA resource and cuMemcpy2DAsync into a NVENC input buffer) is retained only as a runtime fallback — used when DirectX input is opted out (PLAYROLL_NVENC_DIRECTX=0), no D3D11 device is bound, or the DirectX session fails to open. See NVENC input path (EC-312) below.
    • Because there is exactly one producer and one consumer, the lock-free CaptureQueue is a genuine SPSC ring — the single-producer/single-consumer assumption is satisfied, not violated.
  • WGC records a window, never a screen. Playroll always captures the detected game window (CaptureManager::startCapture(HWND)startWindowStreamingstartWindowCapture). The full-monitor display-capture path (startDisplayCapture / setMonitor / startDisplayStreaming) had no callers and was removed (EC-283). WGC::initialize no longer pre-creates any capture item; startWindowCapture is the sole capture-item creator (the old monitor item built at init was always discarded).
  • Video timing is wall-clock-anchored CFR, not "encoded-frame-count" CFR. The muxed video PTS is frame_index * (90000 / fps), but frame_index is advanced to match elapsed wall-clock (target frame count derived from elapsed microseconds), and padPureV2CfrTail pads the tail to keep it aligned. Dropped/decimated frames therefore do not shorten the video relative to the audio sample clock — A/V stays in sync. (This is robust but depends on the CFR padding always running; that path is load-bearing.)
  • open_flag implies header_written in the muxer. In HybridMp4Muxer, header_written is set immediately before open_flag, and close_clean() early-returns unless open_flag is set. So av_write_trailer is never reached with the header unwritten, and the abrupt-close path skips the trailer entirely. The two flags are logically coupled, not independent.
  • The NVENC RAII chain is balanced across mid-session recovery. NvidiaNvsdkV2 → (encoder session, registered resources, bitstream buffers) are released in the correct dependency order, including across a destroy+reinit recovery cycle. The bitstream lock/unlock and resource map/unmap are paired on all paths. On the primary DirectX-input path NVENC registers each pool texture as a DirectX resource (NV_ENC_INPUT_RESOURCE_TYPE_DIRECTX); on the CUDA fallback path the chain is additionally rooted in a CudaContext and the texture is registered as a CUDA resource. Both paths are synchronous — the encoder session is opened with enableEncodeAsync=0 and frames are retired in submit order via a blocking nvEncLockBitstream, which provides natural backpressure. (An async completion-event ring was prototyped under EC-312 and reverted: with a single encode/poll thread its non-blocking drain lapped NVENC and starved the capture pool to ~5 FPS. The async scaffolding remains compiled but is dead at runtime.) The read-after-write ordering that prevents black frames is not owned by the encoder: it lives upstream as a GPU fence after the capture CopyResource, and is preserved unchanged on the DirectX path — see EC-284.
  • Encoded packets are moved, not copied, through the whole pipeline — encoder → EncodeStagePacketQueue → mux stage all use std::move, and the final mux hop hands the buffer to FFmpeg via av_buffer_create (zero-copy) rather than av_new_packet + memcpy (EC-282).

GPU adapter pinning (hybrid laptops)

On hybrid laptops D3D11CreateDevice(nullptr) may bind the capturer to the Intel iGPU while NVENC encodes on the NVIDIA dGPU, forcing a cross-adapter copy on every frame and silently destroying capture throughput. Pure-v2 enumerates DXGI adapters and binds D3D11 explicitly to the NVIDIA adapter, then selects the matching CUDA device by PCI ID. The enumerated adapters, the active D3D11 adapter, and the selected CUDA device are logged at start so the capture/encode GPU is verifiable from logs.

With the DirectX NVENC input path (EC-312), the same ID3D11Device is used to create the capture textures and to open the NVENC DirectX session, so the D3D11/NVENC device-match is now the load-bearing constraint for the primary path; the CUDA-device selection still matters for the CUDA fallback and for on-device transcription.

Game/Input write synchronization

Pure-v2 does not block the video encoder while writing input rows to disk. Synchronization is logical and happens through shared session anchors:

AnchorSourcePurpose
frame_numberCaptureManager::currentInputFrameNumber()Links lifecycle and input rows to the current encoded-frame position. In pure-v2 this comes from framesEncodedAtomic_.
timestampstd::chrono::system_clock::now() at event observation timeProvides absolute ordering and duration math across CSV lifecycle rows.
Lifecycle rowsSTART, VIDEO_START, VIDEO_PAUSE, VIDEO_RESUME, VIDEO_END, ENDBounds the effective video interval and pause intervals used by validation.
Async queueInputEventRecorder writer threadDecouples input persistence from capture/encode throughput.

The important property is that capture writes and input writes do not need to be physically synchronous. They need to share enough anchors to reconstruct the same recording interval during validation.

Operationally:

  • The MP4 path owns video sample ordering and cadence. The CSV writer never sits on the encode/mux hot path.
  • Input and lifecycle events are timestamped before they enter the async queue, so writer delay does not change their event time.
  • VIDEO_START is emitted after CSV recording starts, and VIDEO_END is emitted before stopRecording() performs the final CSV flush.
  • The validator parses CSV lifecycle timestamps, requires monotonic timestamps and valid JSON event_args, subtracts pause intervals, then compares the resulting CSV duration with MP4/JSON duration.

Under queue pressure, lifecycle rows are preserved preferentially and non-lifecycle input rows are the first candidates for dropping. This keeps the CSV usable for upload validation and tester troubleshooting even if detailed input fidelity is degraded.

Target quality

The product target is 60 FPS. The validator-compatible effective minimum is currently 58 FPS for a 60 FPS target. This gives tolerance for timing noise while still rejecting recordings that materially miss the quality target.

Active-duration accounting and first-frame gating

The validator computes muxed_fps = frame_count_muxed / duration_s, where duration_s is the recording's active duration (wall-clock minus pause intervals). Two rules keep the denominator honest:

  1. Pause intervals are excluded. Every pauseCapture/resumeCapture pair is subtracted from active_duration_s (written into _meta.json as duration_s, with the raw wall-clock kept as wall_duration_s for reference).
  2. First-frame gating. The active segment does not start at capture.v2.start — it starts when the first frame is actually enqueued into the capture queue. This shields the recording from time spent waiting for WGC to start producing frames (window minimized at record start, slow WGC source initialization, etc.) that would otherwise inflate the denominator while producing no frames. The transition is logged as capture.v2.first_frame with a setup_ms field; sustained high setup_ms indicates a window-readiness or WGC source problem at start.

A practical consequence: a recording that takes 3 seconds for WGC to start producing frames will report active_duration_s = wall_duration_s - 3s and muxed_fps computed on the smaller denominator. This is what we want — the validator should reject a session for low quality, not for a slow start.

Source-health → pause policy

The WGC layer detects two health transitions while polling:

  • window_invalid — the captured window is minimized, hidden, or destroyed. The WGC layer signals onSourceTurnedUnhealthy("window_invalid") and stops producing frames upstream until the window is valid again. There is no fallback texture path: producing frames here would not reach the encoder anyway because CaptureManager pauses the encode pipeline on wgc-source-unhealthy.
  • window_restored — the source becomes valid again.

Both transitions invoke a latched callback into CaptureManager, which forwards them to the pause policy as the reason wgc-source-unhealthy. The pause policy aggregates this with other reasons (e.g. input-idle, focus_lost) via PauseCoordinator and applies a physical pause/resume if the aggregate state changes. This closes the ~2-3s gap between the WGC poll noticing degradation and the periodic WindowWatchdog poll picking it up — without bypassing the coordination layer responsible for UI overlay state, audio cues, and recording phase transitions.

The reason wgc-source-unhealthy is whitelisted in RecordingPausePolicyReason (see recording_pause_policy_reason.hpp), required for the message-window command boundary to forward the request.

FPS profiling

Core includes an FPSProfiler module that observes periodic pure-v2 QoS snapshots.

In addition to the telemetry event below, the most recent snapshot (plus the profiler's severity/reason classification) is retained on CaptureManager while recording and surfaced live via the recorder.perf block of /api/status. This feeds the performance overlay — now the Electron overlay's stats/GPU widgets (the native bottom-left D2D HUD is retired; PLAYROLL_LEGACY_OVERLAY=1 restores it) — and any in-UI perf readout.

It computes:

  • Window source FPS.
  • Window encoded FPS.
  • Window muxed FPS.
  • Cumulative muxed FPS.
  • Drop ratio.
  • Worst window muxed FPS while a warning is active.

It emits events when quality degrades, escalates, or recovers.

Encoder frame lifecycle

NVENC input path (EC-312)

The encode stage hands the captured D3D11TexturePool BGRA texture directly to NVENC, registering it as an NV_ENC_INPUT_RESOURCE_TYPE_DIRECTX input resource. There is no per-frame CUDA copy on this path: the map texture and encode H.264 step in the diagram above is a register/map of the existing D3D11 texture, not a cuMemcpy. This removes a per-frame cuMemcpy2DAsync + blocking cuEventSynchronize that previously stalled the encode thread whenever the game saturated the GPU (the copy_sync cost in EC-309 / EC-312). On the DirectX path the copy_sync_us metric is 0; it is only non-zero on the CUDA fallback.

This is purely an encoder-input change. WGC capture is unchanged — Playroll still captures via Windows Graphics Capture (non-injecting, anti-cheat-safe); it does not hook the game's swapchain.

Path selection (decided once at encoder init, then stamped on telemetry):

PathWhenNVENC inputPer-frame CUDA copy
DirectX (default)D3D11 device bound and the DirectX NVENC session opensNV_ENC_INPUT_RESOURCE_TYPE_DIRECTX (the pool texture)none
CUDA (fallback)PLAYROLL_NVENC_DIRECTX=0, no D3D11 device, or DirectX session-open failureCUDA-registered resourcecuMemcpy2DAsync + sync

The active path is reported at init via capture.v2.nvenc.input_path and stamped as input_path (directx | cuda) on every capture.v2.encode_stage.frame_metrics event, alongside copy_sync_us / map_us / submit_us / nvenc_lock_wait_ms, so the path in use — and the absence of copy-sync cost on DirectX — is verifiable from telemetry.

Ordering and lifetime notes:

  • EC-284 fence is preserved. The capture-side CopyResource into the pool slot is fenced to GPU completion (fenceGpuCopy) before the slot is enqueued, so NVENC always reads an already-written texture. Removing the CUDA copy did not remove this fence — it lives upstream in CaptureManager / WGC, not in the encoder.
  • In-flight slots are not overwritten. On the DirectX path the encoder defers the capture-pool slot release (defers_slot_release()) until NVENC has retired the frame, so capture cannot recycle a texture NVENC is still reading.

Backpressure ownership

Warning reasons

ReasonMeaning
low_source_fpsThe source/capture side is not producing enough frames.
capture_pool_pressureTexture pool pressure or pool-slot drops are causing frame loss.
encode_backpressureEncoder latency or NVENC lock waits are too high.
mux_backpressurePacket queue, mux, or disk I/O is slowing the pipeline.
low_muxed_fpsMuxed FPS is low and no more specific cause was identified.

Current event

capture.v2.fps_profiler.event

This event is logged by core today. UI exposure is a separate integration step.

Live stats surface

CaptureManager::getStats() snapshots into Stats so callers (HTTP status, dashboards, overlay) can read live values without taking the capture-thread mutex. Beyond frame counters, it includes:

FieldSourceMeaning
qosStateatomic mirror of qos.keep_every_n / saturationNormal, Escalating, or Saturated — distinguishes "low FPS because the machine is slow" from "low FPS because Playroll is throttling capture under encoder pressure".
qosKeepEveryNatomic mirrorCurrent decimation factor. Under the hard-60 policy (kQosMaxKeepEveryN = 1 in capture_manager.cpp) this is capped at 1 for the whole session — adaptive capture decimation is disabled by design, so quality degradation surfaces as qosState = Escalating/Saturated and dropped frames, never as a lower kept-frame ratio.
qosDropsTotalatomic mirror, updated on the 5s QoS snapshot tickCumulative all-cause drops since recording start.
qosDropsLastWindowcomputed from total + previous snapshotDrops since the previous getStats() call — useful for live "drops in the last second" overlays.
recoveryInProgressset during zero_video_output_watchdog recovery attemptsUI can render a "Reconnecting…" banner instead of leaving the FPS counter mysteriously at zero.
recoveryAttemptattempt counter while recoveryInProgress=trueReset to 0 when the recovery succeeds.

Pure-v2 also emits telemetry for conditions that can explain failed or degraded recordings:

Event familyMeaning
capture.v2.qos*Queue pressure, dropped frames, and capture backpressure.
capture.v2.pool_pressureTexture pool pressure and GPU resource contention.
capture.v2.encode_stage.frame_metricsEncode throughput and latency.
capture.v2.packet_queue.depthPacket queue growth and enqueue blocks.
capture.v2.mux_stage.write_metricsMP4 write latency and disk I/O.
capture.v2.zero_video_output_watchdogActive pipeline with no muxed video output.
capture.v2.nvenc.*NVIDIA encoder warnings and failures.

See Core Signals for the full user-facing and operational signal catalog.