diff --git a/api/openapi.json b/api/openapi.json index 323af334..84a38a49 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -3912,11 +3912,19 @@ "format": "int64", "minimum": 0 }, + "encoder_backend": { + "type": "string", + "description": "The encode backend that ACTUALLY opened for this session — `\"nvenc\"`, `\"vaapi\"`,\n`\"vulkan\"`, `\"amf\"`, `\"qsv\"`, `\"software\"`, … — and the GPU it runs on.\n\nRecorded because the stage split alone can't be read without them. A p50 `submit` of 10 ms\nmeans \"the GPU's CSC+encode throughput is the ceiling\" on one backend and something else\nentirely on another, and every fps-shortfall report so far has cost a round-trip asking\nwhich one it was. Both come from `pf_gpu::active()`, the record the encoder open itself\nwrites, so they name the branch that really opened rather than a re-derived guess.\n\n`\"\"` when nothing was streaming at registration (or on a build without the record)." + }, "fps": { "type": "integer", "format": "int32", "minimum": 0 }, + "gpu": { + "type": "string", + "description": "Human-readable GPU name (`\"NVIDIA GeForce RTX 4090\"`, `\"CPU (openh264)\"`), or `\"\"`." + }, "height": { "type": "integer", "format": "int32", diff --git a/crates/punktfunk-host/src/stats_recorder.rs b/crates/punktfunk-host/src/stats_recorder.rs index c4b3271f..fc53fcc9 100644 --- a/crates/punktfunk-host/src/stats_recorder.rs +++ b/crates/punktfunk-host/src/stats_recorder.rs @@ -80,6 +80,21 @@ pub struct CaptureMeta { /// Short label / fingerprint prefix, or `""` if unknown. pub client: String, pub sample_count: u32, + /// The encode backend that ACTUALLY opened for this session — `"nvenc"`, `"vaapi"`, + /// `"vulkan"`, `"amf"`, `"qsv"`, `"software"`, … — and the GPU it runs on. + /// + /// Recorded because the stage split alone can't be read without them. A p50 `submit` of 10 ms + /// means "the GPU's CSC+encode throughput is the ceiling" on one backend and something else + /// entirely on another, and every fps-shortfall report so far has cost a round-trip asking + /// which one it was. Both come from `pf_gpu::active()`, the record the encoder open itself + /// writes, so they name the branch that really opened rather than a re-derived guess. + /// + /// `""` when nothing was streaming at registration (or on a build without the record). + #[serde(default)] + pub encoder_backend: String, + /// Human-readable GPU name (`"NVIDIA GeForce RTX 4090"`, `"CPU (openh264)"`), or `""`. + #[serde(default)] + pub gpu: String, } /// A full capture: summary + the sample time-series. The wire + on-disk shape. @@ -115,6 +130,8 @@ struct MetaSeed { fps: u32, codec: String, client: String, + encoder_backend: String, + gpu: String, } /// The in-progress capture (present iff armed). @@ -246,6 +263,12 @@ impl StatsRecorder { client: &str, ) -> u32 { let sid = self.next_sid.fetch_add(1, Ordering::Relaxed); + // The live encode backend + GPU, straight from the record the encoder open wrote. Read + // outside the lock (it takes its own) and only on the first registration path's behalf — + // this runs once per capture, not per frame. + let (encoder_backend, gpu) = pf_gpu::active() + .map(|(g, _)| (g.backend.to_string(), g.name)) + .unwrap_or_default(); let mut guard = self.live.lock().unwrap_or_else(|e| e.into_inner()); if let Some(live) = guard.as_mut() { if live.meta.is_none() { @@ -256,6 +279,8 @@ impl StatsRecorder { fps, codec: codec.to_string(), client: client.to_string(), + encoder_backend, + gpu, }); } } @@ -398,7 +423,7 @@ fn status_of(live: Option<&Live>) -> StatsStatus { /// Compute the `CaptureMeta` for an in-progress or finalizing capture (id derived from the start /// time + negotiated mode; duration from the monotonic start). fn meta_of(live: &Live) -> CaptureMeta { - let (kind, width, height, fps, codec, client) = match &live.meta { + let (kind, width, height, fps, codec, client, encoder_backend, gpu) = match &live.meta { Some(m) => ( m.kind.clone(), m.width, @@ -406,8 +431,10 @@ fn meta_of(live: &Live) -> CaptureMeta { m.fps, m.codec.clone(), m.client.clone(), + m.encoder_backend.clone(), + m.gpu.clone(), ), - None => (String::new(), 0, 0, 0, String::new(), String::new()), + None => Default::default(), }; CaptureMeta { id: capture_id(live.started_unix_ms, width, height), @@ -420,6 +447,8 @@ fn meta_of(live: &Live) -> CaptureMeta { codec, client, sample_count: live.samples.len() as u32, + encoder_backend, + gpu, } } diff --git a/web/src/sections/Stats/Detail.tsx b/web/src/sections/Stats/Detail.tsx index 1957850a..6e67340f 100644 --- a/web/src/sections/Stats/Detail.tsx +++ b/web/src/sections/Stats/Detail.tsx @@ -33,9 +33,15 @@ export const DetailCard: FC<{ {m.stats_detail_title()} {cap && ( + // Encoder + GPU ride along with the mode: the stage split below can't be + // read without knowing which backend produced it (a 10 ms `submit` means + // very different things on NVENC and on Vulkan). Older recordings predate + // the fields and simply omit them. {cap.meta.width}×{cap.meta.height}@{cap.meta.fps} ·{" "} {cap.meta.codec.toUpperCase()} + {cap.meta.encoder_backend && ` · ${cap.meta.encoder_backend}`} + {cap.meta.gpu && ` · ${cap.meta.gpu}`} )} diff --git a/web/src/stories/lib/fixtures.ts b/web/src/stories/lib/fixtures.ts index c52454a2..6b77a026 100644 --- a/web/src/stories/lib/fixtures.ts +++ b/web/src/stories/lib/fixtures.ts @@ -232,6 +232,8 @@ export const captureMetas: CaptureMeta[] = [ duration_ms: 92_000, sample_count: 92, started_unix_ms: 1_782_415_260_000, + encoder_backend: "nvenc", + gpu: "NVIDIA GeForce RTX 4090", }, { id: "cap-20260628-1903",