feat(client): PUNKTFUNK_DUMP_VIDEO captures the exact decoder input
Fixture-corpus enabler for the native-decode program (M0, design/client-native-decode.md): every AU exactly as the pump hands it to decode_frame — the raw concatenation plus a sidecar .idx carrying the AU boundaries and wire flags a byte stream cannot. Best-effort by design: any I/O error disables the capture, never the stream.
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
//! Decoder-input capture behind `PUNKTFUNK_DUMP_VIDEO` (fixture corpus for the
|
||||||
|
//! native-decode program, design/client-native-decode.md M0).
|
||||||
|
//!
|
||||||
|
//! Writes every AU exactly as the pump hands it to [`crate::video::Decoder::decode_frame`]:
|
||||||
|
//! the data file is the raw concatenation (a valid Annex-B / OBU stream for clean
|
||||||
|
//! captures), and the sidecar `.idx` keeps what a byte stream cannot carry — the exact
|
||||||
|
//! AU boundaries plus the wire `flags`/`complete` bits — one `offset len flags complete`
|
||||||
|
//! line per AU, so parser fixtures never have to re-derive framing from start codes.
|
||||||
|
//!
|
||||||
|
//! Capture is best-effort by design: any I/O error logs once and disables the dump for
|
||||||
|
//! the rest of the session; the streaming path is never failed on its account. The
|
||||||
|
//! final buffered tail flushes on drop (session end), errors swallowed — a truncated
|
||||||
|
//! last AU in a debug capture is acceptable, a stream torn down over one is not.
|
||||||
|
|
||||||
|
use std::io::BufWriter;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub(crate) struct AuDump {
|
||||||
|
data: BufWriter<std::fs::File>,
|
||||||
|
idx: BufWriter<std::fs::File>,
|
||||||
|
offset: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire-codec byte → fixture file extension (also the corpus naming convention).
|
||||||
|
fn codec_ext(codec: u8) -> &'static str {
|
||||||
|
match codec {
|
||||||
|
punktfunk_core::quic::CODEC_H264 => "h264",
|
||||||
|
punktfunk_core::quic::CODEC_HEVC => "h265",
|
||||||
|
punktfunk_core::quic::CODEC_AV1 => "av1",
|
||||||
|
punktfunk_core::quic::CODEC_PYROWAVE => "pyrowave",
|
||||||
|
_ => "bin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuDump {
|
||||||
|
/// Read `PUNKTFUNK_DUMP_VIDEO`; `None` (the overwhelmingly common case) means the
|
||||||
|
/// variable is unset or the capture files could not be created — both already logged
|
||||||
|
/// where they matter.
|
||||||
|
pub(crate) fn from_env(codec: u8) -> Option<AuDump> {
|
||||||
|
let dir = std::env::var_os("PUNKTFUNK_DUMP_VIDEO")?;
|
||||||
|
let stamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
Self::create(Path::new(&dir), &format!("au-{stamp}"), codec)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create(dir: &Path, base: &str, codec: u8) -> Option<AuDump> {
|
||||||
|
let ext = codec_ext(codec);
|
||||||
|
let data_path = dir.join(format!("{base}.{ext}"));
|
||||||
|
let idx_path = dir.join(format!("{base}.{ext}.idx"));
|
||||||
|
let made = std::fs::create_dir_all(dir).and_then(|()| {
|
||||||
|
Ok((
|
||||||
|
std::fs::File::create(&data_path)?,
|
||||||
|
std::fs::File::create(&idx_path)?,
|
||||||
|
))
|
||||||
|
});
|
||||||
|
match made {
|
||||||
|
Ok((data, idx)) => {
|
||||||
|
tracing::info!(
|
||||||
|
path = %data_path.display(),
|
||||||
|
"PUNKTFUNK_DUMP_VIDEO: capturing decoder input"
|
||||||
|
);
|
||||||
|
Some(AuDump {
|
||||||
|
data: BufWriter::new(data),
|
||||||
|
idx: BufWriter::new(idx),
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
dir = %dir.display(),
|
||||||
|
"PUNKTFUNK_DUMP_VIDEO set but capture files could not be created"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append one AU. Returns `false` once the dump should be dropped (error logged).
|
||||||
|
pub(crate) fn write(&mut self, au: &[u8], flags: u32, complete: bool) -> bool {
|
||||||
|
let r = self.data.write_all(au).and_then(|()| {
|
||||||
|
writeln!(
|
||||||
|
self.idx,
|
||||||
|
"{} {} {:#x} {}",
|
||||||
|
self.offset,
|
||||||
|
au.len(),
|
||||||
|
flags,
|
||||||
|
u8::from(complete)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
self.offset += au.len() as u64;
|
||||||
|
match r {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "PUNKTFUNK_DUMP_VIDEO write failed — capture disabled");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_capture_is_a_byte_faithful_concatenation_with_a_boundary_index() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("pf-au-dump-test-{}", std::process::id()));
|
||||||
|
let mut dump = AuDump::create(&dir, "t", punktfunk_core::quic::CODEC_HEVC)
|
||||||
|
.expect("capture files should be creatable in a temp dir");
|
||||||
|
assert!(dump.write(&[1, 2, 3], 0x04, true));
|
||||||
|
assert!(dump.write(&[9, 8], 0x00, false));
|
||||||
|
drop(dump);
|
||||||
|
|
||||||
|
let data = std::fs::read(dir.join("t.h265")).unwrap();
|
||||||
|
let idx = std::fs::read_to_string(dir.join("t.h265.idx")).unwrap();
|
||||||
|
assert_eq!(data, &[1, 2, 3, 9, 8]);
|
||||||
|
assert_eq!(idx, "0 3 0x4 1\n3 2 0x0 0\n");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
// instead of an argument precisely because nothing required one.
|
// instead of an argument precisely because nothing required one.
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
|
mod au_dump;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
|||||||
@@ -630,6 +630,9 @@ fn pump(
|
|||||||
// ahead of `frames_dropped` (the reassembler only declares a straggler lost once it ages out of
|
// ahead of `frames_dropped` (the reassembler only declares a straggler lost once it ages out of
|
||||||
// the loss window, by which point the concealment already reached the screen).
|
// the loss window, by which point the concealment already reached the screen).
|
||||||
let mut next_expected_index: Option<u32> = None;
|
let mut next_expected_index: Option<u32> = None;
|
||||||
|
// Fixture capture for the native-decode program: every AU exactly as it reaches
|
||||||
|
// `decode_frame`, plus a boundary/flags index — see `au_dump.rs` for the format.
|
||||||
|
let mut au_dump = crate::au_dump::AuDump::from_env(connector.codec);
|
||||||
|
|
||||||
let end: Option<String> = loop {
|
let end: Option<String> = loop {
|
||||||
if stop.load(Ordering::SeqCst) {
|
if stop.load(Ordering::SeqCst) {
|
||||||
@@ -743,6 +746,11 @@ fn pump(
|
|||||||
Some(n) if frame.frame_index.wrapping_sub(n) > u32::MAX / 2 => n,
|
Some(n) if frame.frame_index.wrapping_sub(n) > u32::MAX / 2 => n,
|
||||||
_ => frame.frame_index,
|
_ => frame.frame_index,
|
||||||
});
|
});
|
||||||
|
if let Some(d) = au_dump.as_mut() {
|
||||||
|
if !d.write(&frame.data, frame.flags, frame.complete) {
|
||||||
|
au_dump = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
match decoder.decode_frame(&frame.data, frame.flags, frame.complete) {
|
match decoder.decode_frame(&frame.data, frame.flags, frame.complete) {
|
||||||
Ok(Some(image)) => {
|
Ok(Some(image)) => {
|
||||||
// Fold this decoded frame through the shared freeze gate: it reads the AU's
|
// Fold this decoded frame through the shared freeze gate: it reads the AU's
|
||||||
|
|||||||
Reference in New Issue
Block a user