Skip to main content

Recording Session Lifecycle

A recording session is one start-to-stop recording attempt. The session exists even if the final artifacts are not uploadable.

Lifecycle contract

Every session should have:

  • A start event when recording begins.
  • A terminal event when recording stops, fails, is auto-paused, or is otherwise abandoned.
  • A validation result after local artifacts are closed.
  • Upload records only when the local validation result is uploadable.

The terminal state must be explicit. If a session breaks in the middle, the core should still emit a terminal result with a failure status and granular reason.

Lifecycle overview

Current entities

LayerResponsibility
CoreOwns capture, muxing, local validation, metadata generation, upload gating, and lifecycle telemetry.
UIDisplays current recording state, session history, uploadability, and failure reasons.
Local SQLiteStores local session history and enough diagnostic state to explain why a session was uploadable or blocked.
SupabaseStores normalized server-side session and upload records for testers, monitoring, and investor KPIs.
Lambda / presigned uploadAccepts upload intent and provides S3 destinations for validated artifacts.
Feedback APILets the launcher fetch a processed session's accept/reject verdict and plain-English reasons, projected from the pipeline's eval certification (see Pipeline outputs). A session not yet processed reads as pending.
Admin download APISigns time-limited download links for recording artifacts so operators can retrieve them from the private capture store through the operator tool; authenticated server-side, never exposed to browsers.

Expected states

StateMeaning
recordingCapture is active or temporarily paused within the same session.
stoppingCore is closing capture, muxing, metadata, and validation.
uploadableMP4, CSV, and JSON passed validation and can be uploaded as a coherent triplet.
blockedThe session completed locally but failed validation. No partial upload should start.
failedThe session did not complete normally, but still needs a terminal record and reason.
uploadedAll required artifacts were uploaded and server state is coherent.

Invariants

  • A session is the unit of payment and support investigation.
  • Recording hours must come from validated session timing, not from best-effort upload counts.
  • Upload should be all-or-nothing for the required artifact triplet.
  • Current required triplet is MP4, CSV, and JSON.
  • OGG is legacy and should not appear in new pure-v2 recording flows.
  • The mic sidecar (_mic.aac, EC-259) is optional and never gates upload validation.
  • Lobby data is optional metadata used to pair simultaneous sessions; it is not the identity of the session. See Lobby Mode for the full lifecycle, HTTP surface, and cross-DB correlation.

Startup warnings (recording proceeds)

Non-fatal degradations discovered during startCapture are surfaced through a one-shot startup_warning_ slot inside CaptureManager. The slot holds at most one UiErrorDirective; subsequent warnings overwrite the prior one. The HTTP layer drains it once via takeStartupWarning() after startCapture returns successfully, and the UI shows a non-blocking banner.

Codes surfaced today:

CodeWhen
AUDIO_UNAVAILABLENo audio capture device, WASAPI activate failure, AAC encoder init failure, or strict-privacy process loopback unavailable. The recording proceeds without audio.
INPUT_UNAVAILABLEInputEventRecorder init failed at boot (sticky and re-emitted on every startCapture), inputRecorder_->startRecording failed at session start, or any of the raw-input window/class/device registrations failed. Replay heatmaps and timing annotations will not be available for this session.

The slot is not a queue — designed for the most relevant current warning, not a history of every degradation. For per-event tracking, the underlying LOG_EVENT warnings stay in OTel.

NVENC contention snapshot (start / stop)

At the start and end of every recording attempt (ServiceApp::start* and ServiceApp::stop) the core captures an NvencSnapshot via NVML — gpu name, driver version, and the list of third-party NVENC sessions currently running on the GPU (process name, codec, resolution). The same snapshot is logged locally and posted best-effort to the nvenc-contention-report Supabase edge function with phase set to pre_start or post_stop.

It is best-effort telemetry on the start/stop path:

  • It runs synchronously inline with start() / stop() but is wrapped to never throw and never block.
  • It is silently skipped when NVML is unavailable (no NVIDIA driver / nvml.dll absent), when no third-party sessions are detected, when the user is not logged in (no JWT), or when config::supabaseUrl() is empty.
  • Failures are logged as WARNING and swallowed; they never block or fail a recording.

This gives us a fleet-wide view of which third-party apps (Discord, OBS, browser hardware acceleration, other recorders) are competing for NVENC slots when a Playroll session starts or stops, so encoder-backpressure incidents can be correlated with real contention instead of speculation.

On-disk layout — one folder per session

Recordings live in PlayrollCaptures/<Game>/<sessionId>/<timestamp>.mp4, where <sessionId> is the recording session id (rec-<epoch-ms>) — the same id used by the sessions table, every capture.v2.* telemetry event, the activity-view badge, and the S3 key segment (user/game/date/<sessionId>/<filename>), so the local folder maps 1:1 onto the uploaded layout. All session artifacts (mp4, _input.csv, _meta.json, _mic.aac, _transcript.json) derive from the mp4 path's directory + stem and therefore live in that folder. Recordings made before this change stay flat under PlayrollCaptures/<Game>/; every consumer resolves absolute paths from SQLite, and crash recovery scans both layouts (per-session folder by direct name lookup first, then the legacy flat probe).

Metadata sidecar (_meta.json)

The metadata sidecar is written atomically: the encoder dumps to <name>_meta.json.tmp and renames into place only after the dump completes. If write or rename fails, the .tmp file is removed and the validator sees json_missing rather than a partial file. This keeps the validator's failure reason clean — "we never wrote the sidecar" instead of "the FPS computation tried to use a half-written file".

The JSON includes both duration_s (active duration, excludes pauses, used by the validator for FPS computation) and wall_duration_s (raw start-to-stop wall clock, for reference and debugging).

Since EC-360 it also includes an fps_breakdown block — the honest real-vs-CFR split of the muxed stream (the muxed output is CFR, so real_avg + cfr_avg tracks fps_target whenever the duplicate path kept up):

FieldMeaning
muxed_avgframe_count_muxed / duration_s.
real_avgGenuine game frames per second (muxed minus CFR duplicates).
cfr_avgCFR duplicate (padding) frames per second.
cfr_ratioFraction of muxed frames that are CFR duplicates.
fps_low_detectedThe in-session low-FPS detection fired (sustained sub-50 real fps).
dead_capture_detectedThe dead-capture detection fired (cfr_ratio > 0.5 mid-session).

These fields replace the 4.1.10 abort+discard behavior (EC-346/348): a low-real-fps or dead-capture session now uploads with this metadata and is filtered downstream.

Microphone sidecar (_mic.aac) — EC-259

A second optional sidecar contains the user's microphone audio, recorded in parallel with the game-window per-process loopback. It is not part of the required upload triplet (MP4 + CSV + JSON) and its absence never blocks upload validation — a missing _mic.aac simply means the user did not opt into mic capture for this session.

Lifecycle is driven by RecordingMicObserver, which subscribes to the same recordingEvents dispatcher as the existing presentation / uploader / watchdog observers:

  • RecordingStartedEvent — carries the absolute recordingPath of the .mp4 being written. The observer derives {base}_mic.aac and calls MicRecorder::start(path). If MicConfig.enabled is false (the privacy default), the recorder logs mic.skip and writes nothing.
  • RecordingStoppedEvent / RecordingTerminalErrorEvent — calls MicRecorder::stop(), draining tail AAC AUs into the sidecar and closing the file.

The mic sidecar duration matches the .mp4 duration ±1 frame regardless of how often the user toggles mute mid-session. Mute substitutes zeros at the s16 conversion stage but never stops the encoder — the file keeps growing with silent PCM so post-production can rely on sample-accurate alignment with the video timeline.

The sidecar is a raw .aac file with ADTS framing (one 7-byte ADTS header per AAC-LC AU) so it plays in VLC / ffplay / browsers without an MP4 container. It is not muxed into the .mp4 for two reasons:

  1. Keeps the existing single-audio-track MP4 path unchanged — no muxer surgery to support multi-track playback quirks across consumer players.
  2. Makes the mic stream trivially separable in post (delete, edit, transcribe) without rewriting the MP4.

In-game voice chat (Valorant / CS2 teammate voices rendered inside the game process) is not in this sidecar — it's part of the game-audio per-process loopback because the game mixes it into its own render path. See EC-113 for the privacy investigation and source-separation roadmap.

Open work

  • Ensure UI history displays blocked and failed sessions with granular reasons.
  • Ensure Supabase recording_sessions stores terminal validation/upload status and failure reason.
  • Align field names across core, UI, SQLite, Supabase, and upload code.