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
| Layer | Responsibility |
|---|---|
| Core | Owns capture, muxing, local validation, metadata generation, upload gating, and lifecycle telemetry. |
| UI | Displays current recording state, session history, uploadability, and failure reasons. |
| Local SQLite | Stores local session history and enough diagnostic state to explain why a session was uploadable or blocked. |
| Supabase | Stores normalized server-side session and upload records for testers, monitoring, and investor KPIs. |
| Lambda / presigned upload | Accepts upload intent and provides S3 destinations for validated artifacts. |
| Feedback API | Lets 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 API | Signs 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
| State | Meaning |
|---|---|
recording | Capture is active or temporarily paused within the same session. |
stopping | Core is closing capture, muxing, metadata, and validation. |
uploadable | MP4, CSV, and JSON passed validation and can be uploaded as a coherent triplet. |
blocked | The session completed locally but failed validation. No partial upload should start. |
failed | The session did not complete normally, but still needs a terminal record and reason. |
uploaded | All 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:
| Code | When |
|---|---|
AUDIO_UNAVAILABLE | No audio capture device, WASAPI activate failure, AAC encoder init failure, or strict-privacy process loopback unavailable. The recording proceeds without audio. |
INPUT_UNAVAILABLE | InputEventRecorder 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.dllabsent), when no third-party sessions are detected, when the user is not logged in (no JWT), or whenconfig::supabaseUrl()is empty. - Failures are logged as
WARNINGand 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):
| Field | Meaning |
|---|---|
muxed_avg | frame_count_muxed / duration_s. |
real_avg | Genuine game frames per second (muxed minus CFR duplicates). |
cfr_avg | CFR duplicate (padding) frames per second. |
cfr_ratio | Fraction of muxed frames that are CFR duplicates. |
fps_low_detected | The in-session low-FPS detection fired (sustained sub-50 real fps). |
dead_capture_detected | The 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 absoluterecordingPathof the.mp4being written. The observer derives{base}_mic.aacand callsMicRecorder::start(path). IfMicConfig.enabledisfalse(the privacy default), the recorder logsmic.skipand writes nothing.RecordingStoppedEvent/RecordingTerminalErrorEvent— callsMicRecorder::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:
- Keeps the existing single-audio-track MP4 path unchanged — no muxer surgery to support multi-track playback quirks across consumer players.
- 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_sessionsstores terminal validation/upload status and failure reason. - Align field names across core, UI, SQLite, Supabase, and upload code.