mux: reconcile fvi:// video-index sink onto rc6
Port + adapt the freemkv native per-picture video index (FVI) from the
old feat/fvi-sink branch onto rc6's codec-agnostic PictureInfo model.
This is a surgical adaptation, not a merge.
Adaptations (fvi_sink.rs, videomap.rs, tests/fvi_pipeline.rs):
- Retarget from the removed crate::mux::codec::mpeg2::PictureInfo (raw
public fields) to rc6's authoritative crate::mux::codec::PictureInfo
in codec/coding.rs, via its accessors.
- type from coding_type() -> CodingType{I,P,B}; emitted for ANY frame
that carries coding (every video codec now fills it), with the
keyframe-flag I/P fallback only when coding is absent.
- Replace the mpeg2-only tff/rff/progressive members with codec-agnostic
members derived through the accessors: field_order (tff/bff/progressive)
and progressive, emitted ONLY when the codec measured the signal
(Option::Some) and omitted otherwise; plus nb_fields.
- Test fixtures rebuilt via PictureInfo::mpeg2(CodingType, Mpeg2Coding{..})
/ coding_type_only(..); added measured_cicp: None to VideoStream
literals for rc6's struct.
Honesty decision (key / random-access):
- The codec-agnostic PictureInfo carries NO GOP-closure (no closed_gop /
gop_start), so key is set from the frame's intra / decode-restart flag
(frame.keyframe == coding.keyframe() for video), NOT a fabricated
clean-RAP claim. The old gop member is honestly omitted. FVI_FORMAT.md
is updated to document this as a limitation: key is an intra picture /
parser-flagged decode-restart point; MPEG-2 open-GOP clean-RAP precision
(closed_gop) is not currently distinguished. §7.1 rewritten for the
new field_order/progressive/nb_fields members.
Wiring:
- mux/mod.rs: pub(crate) mod fvi_sink; pub(crate) mod videomap
(#[allow(dead_code)] on videomap — the VideoMap accumulator is staged
for side-channel reuse, sink builds records directly); pub use
fvi_sink::FviSink.
- mux/resolve.rs: add the fvi:// output scheme to StreamUrl, parse_url,
scheme(), path_str(), input() (write-only reject) and output()
(constructs FviSink), mirroring the mkv:///demux:// patterns.
Provenance fix surfaced by the end-to-end test:
- pipelined_stream::consume_ps was dropping the PS demuxer's byte-exact
source stamp (source: None) when rebuilding PesPacket, so PS/DVD-path
frames reached the mux/index with no provenance (FVI src null). Carry
ps.source through, matching the TS path; the real-pipeline fvi test now
sees the stamped src sectors.
Gate: cargo +1.86 fmt + clippy --lib -D warnings clean; cargo +1.86 test
--lib (2182 passed) and --test fvi_pipeline (2 passed); precommit.sh
libfreemkv green.
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
//! End-to-end `fvi://` tests: drive a REAL MPEG-2 Program-Stream image through
|
||||
//! the public highway (`build_iso_pipeline`, MpegPs → PS demux → `Mpeg2Parser`)
|
||||
//! and into the `fvi://` sink built by `output()`, then parse the `.fvi` back
|
||||
//! and assert the per-picture index is correct.
|
||||
//!
|
||||
//! These tests deliberately use only the public API and the real parser /
|
||||
//! pipeline — no stubbed frames that bypass the demuxer or the codec parse.
|
||||
|
||||
use libfreemkv::disc::{
|
||||
Codec, ColorSpace, ContentFormat, DiscTitle, Extent, FrameRate, HdrFormat, Resolution, Stream,
|
||||
VideoStream,
|
||||
};
|
||||
use libfreemkv::pes::Stream as PesStream;
|
||||
use libfreemkv::{DecryptKeys, SectorSource, build_iso_pipeline, output};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// DVD video PES stream_id (0xE0).
|
||||
const DVD_VIDEO_STREAM_ID: u8 = 0xE0;
|
||||
|
||||
// ── MPEG-2 elementary-stream fixture builders (mirror the in-crate ones) ──────
|
||||
|
||||
/// 720x480, 4:3, 29.97 (aspect_ratio_information=2, frame_rate_code=4).
|
||||
fn m2_seq_header() -> Vec<u8> {
|
||||
let (w, h, aspect, fr): (u16, u16, u8, u8) = (720, 480, 2, 4);
|
||||
let mut hdr = vec![0x00, 0x00, 0x01, 0xB3u8];
|
||||
hdr.push((w >> 4) as u8);
|
||||
hdr.push((((w & 0x0F) as u8) << 4) | (((h >> 8) & 0x0F) as u8));
|
||||
hdr.push((h & 0xFF) as u8);
|
||||
hdr.push((aspect << 4) | (fr & 0x0F));
|
||||
hdr.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0x00]);
|
||||
hdr
|
||||
}
|
||||
|
||||
/// GOP header (00 00 01 B8) with a zeroed time-code / flags.
|
||||
fn m2_gop() -> Vec<u8> {
|
||||
vec![0x00, 0x00, 0x01, 0xB8u8, 0x00, 0x00, 0x00, 0x00]
|
||||
}
|
||||
|
||||
/// One coded picture: picture header (coding_type, temporal_reference) + a
|
||||
/// picture coding extension carrying tff=1 (00 00 01 B5, ext-id 1000), + slice
|
||||
/// padding.
|
||||
fn m2_pic(coding_type: u8, tr: u16) -> Vec<u8> {
|
||||
let b4 = ((tr >> 2) & 0xFF) as u8;
|
||||
let b5 = (((tr & 0x03) as u8) << 6) | ((coding_type & 0x07) << 3);
|
||||
let mut au = vec![0x00, 0x00, 0x01, 0x00u8, b4, b5, 0x00, 0x00];
|
||||
// Picture coding extension: e0=ext-id 1000, e2=0x03 (frame picture),
|
||||
// e3 bit7 = top_field_first = 1.
|
||||
au.extend_from_slice(&[0x00, 0x00, 0x01, 0xB5u8, 0x80, 0x00, 0x03, 0x80, 0x00]);
|
||||
au.extend_from_slice(&[0xAA; 32]);
|
||||
au
|
||||
}
|
||||
|
||||
// ── Program-Stream packing ────────────────────────────────────────────────────
|
||||
|
||||
/// A 14-byte MPEG-2 PS pack header (00 00 01 BA …) with no stuffing.
|
||||
fn ps_pack_header() -> Vec<u8> {
|
||||
let mut p = vec![0x00, 0x00, 0x01, 0xBAu8];
|
||||
// 9 bytes of SCR/mux-rate fields (content irrelevant to the demuxer's
|
||||
// framing) + a final byte whose low 3 bits are pack_stuffing_length = 0.
|
||||
p.extend_from_slice(&[0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3]);
|
||||
p.push(0xF8); // stuffing_length = 0 (low 3 bits)
|
||||
p
|
||||
}
|
||||
|
||||
/// A video PES (stream_id 0xE0) carrying `es`, with a 33-bit PTS in 90 kHz
|
||||
/// ticks and a bounded PES_packet_length. PTS prefix nibble is 0b0010.
|
||||
fn video_pes(es: &[u8], pts: u64) -> Vec<u8> {
|
||||
let mut pes = vec![0x00, 0x00, 0x01, DVD_VIDEO_STREAM_ID];
|
||||
// PES header: flags1=0x80, flags2=0x80 (PTS only), header_data_len=5.
|
||||
let mut body = vec![0x80u8, 0x80, 0x05];
|
||||
// 5-byte PTS ('0010' marker + 33-bit value with marker bits).
|
||||
let p = pts & 0x1_FFFF_FFFF;
|
||||
body.push(0x21 | (((p >> 30) & 0x07) << 1) as u8);
|
||||
body.push(((p >> 22) & 0xFF) as u8);
|
||||
body.push((0x01 | (((p >> 15) & 0x7F) << 1)) as u8);
|
||||
body.push(((p >> 7) & 0xFF) as u8);
|
||||
body.push((0x01 | ((p & 0x7F) << 1)) as u8);
|
||||
body.extend_from_slice(es);
|
||||
let len = body.len() as u16;
|
||||
pes.extend_from_slice(&len.to_be_bytes());
|
||||
pes.extend_from_slice(&body);
|
||||
pes
|
||||
}
|
||||
|
||||
/// One GOP's worth of ES (seq header + GOP + I + P + B pictures).
|
||||
fn gop_es() -> Vec<u8> {
|
||||
let mut es = m2_seq_header();
|
||||
es.extend_from_slice(&m2_gop());
|
||||
es.extend_from_slice(&m2_pic(1, 0)); // I (keyframe, GOP opener)
|
||||
es.extend_from_slice(&m2_pic(2, 2)); // P
|
||||
es.extend_from_slice(&m2_pic(3, 1)); // B
|
||||
es
|
||||
}
|
||||
|
||||
/// In-memory sector source serving a fixed byte image.
|
||||
struct MemSource {
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl SectorSource for MemSource {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
(self.data.len() / 2048) as u32
|
||||
}
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> libfreemkv::error::Result<usize> {
|
||||
let start = lba as usize * 2048;
|
||||
let want = count as usize * 2048;
|
||||
for (i, b) in buf[..want].iter_mut().enumerate() {
|
||||
*b = self.data.get(start + i).copied().unwrap_or(0);
|
||||
}
|
||||
Ok(want)
|
||||
}
|
||||
}
|
||||
|
||||
fn mpeg2_dvd_title(extent_sectors: u32) -> DiscTitle {
|
||||
let mut title = DiscTitle::empty();
|
||||
title.streams.push(Stream::Video(VideoStream {
|
||||
pid: 0xE0, // DVD_VIDEO_PID
|
||||
codec: Codec::Mpeg2,
|
||||
resolution: Resolution::R480i,
|
||||
frame_rate: FrameRate::F29_97,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Smpte170m,
|
||||
display_aspect: Some((4, 3)),
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
}));
|
||||
title.content_format = ContentFormat::MpegPs;
|
||||
title.extents = vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: extent_sectors,
|
||||
}];
|
||||
title
|
||||
}
|
||||
|
||||
/// Tiny unique temp dir helper.
|
||||
fn tempdir() -> PathBuf {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let n = N.fetch_add(1, Ordering::Relaxed);
|
||||
let p = std::env::temp_dir().join(format!("fmkv_fvi_pipe_{}_{}", std::process::id(), n));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
/// Build a 6-sector PS image: GOP A in sector 0, GOP B in sector 3 — each in
|
||||
/// its own 3-sector AACS-aligned region so the prefetcher's unit alignment is
|
||||
/// satisfied and each batch carries a distinct, ascending source offset.
|
||||
fn two_gop_image() -> Vec<u8> {
|
||||
let mut data = vec![0u8; 6 * 2048];
|
||||
|
||||
let mut a = ps_pack_header();
|
||||
a.extend_from_slice(&video_pes(&gop_es(), 0));
|
||||
data[..a.len()].copy_from_slice(&a);
|
||||
|
||||
let mut b = ps_pack_header();
|
||||
b.extend_from_slice(&video_pes(&gop_es(), 3003)); // ~0.1s later
|
||||
let off = 3 * 2048;
|
||||
data[off..off + b.len()].copy_from_slice(&b);
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
/// Drive the real highway and write every frame into the `fvi://` sink.
|
||||
fn run_to_fvi(image: Vec<u8>, title: DiscTitle, path: &std::path::Path) {
|
||||
let mut input = build_iso_pipeline(
|
||||
MemSource { data: image },
|
||||
title.clone(),
|
||||
DecryptKeys::None,
|
||||
3, // 3-sector (one AACS unit) batches → one source stamp per GOP region
|
||||
ContentFormat::MpegPs,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("pipeline builds");
|
||||
|
||||
let url = format!("fvi://{}", path.display());
|
||||
let mut sink = output(&url, &title).expect("fvi sink opens");
|
||||
|
||||
while let Some(frame) = input.read().expect("read ok") {
|
||||
sink.write(&frame).expect("sink write ok");
|
||||
}
|
||||
sink.finish().expect("sink finish ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fvi_sink_indexes_real_mpeg2_pipeline_output() {
|
||||
let dir = tempdir();
|
||||
let path = dir.join("movie.fvi");
|
||||
run_to_fvi(two_gop_image(), mpeg2_dvd_title(6), &path);
|
||||
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let mut lines = text.lines();
|
||||
|
||||
// ── Header line (docs/FVI_FORMAT.md v1 schema) ─────────────────────────────
|
||||
let header: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
|
||||
assert_eq!(header["format"], "freemkv/video-index");
|
||||
assert_eq!(header["fvi_version"], 1);
|
||||
assert_eq!(header["timescale"], 1_000_000_000u64);
|
||||
let stream = &header["stream"];
|
||||
assert_eq!(stream["codec"], "mpeg2video");
|
||||
assert_eq!(stream["width"], 720);
|
||||
assert_eq!(stream["height"], 480);
|
||||
assert_eq!(stream["dar"], serde_json::json!([4, 3])); // anamorphic DVD
|
||||
assert_eq!(stream["scan"], "interlaced"); // 480i
|
||||
assert_eq!(stream["frame_rate"], serde_json::json!([30000, 1001]));
|
||||
// SMPTE 170M → CICP (6,6,6), limited range.
|
||||
assert_eq!(stream["colour"]["primaries"], 6);
|
||||
assert_eq!(stream["colour"]["transfer"], 6);
|
||||
assert_eq!(stream["colour"]["matrix"], 6);
|
||||
assert_eq!(stream["colour"]["range"], "limited");
|
||||
// Provenance root: medium defaults to "file", sector_size present.
|
||||
assert_eq!(header["source"]["sector_size"], 2048);
|
||||
|
||||
// ── Records ───────────────────────────────────────────────────────────────
|
||||
let records: Vec<serde_json::Value> = lines.map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert!(
|
||||
records.len() >= 4,
|
||||
"two GOPs of I/P/B → at least 4 pictures, got {}",
|
||||
records.len()
|
||||
);
|
||||
|
||||
// `n` is 0-based and contiguous in coded order.
|
||||
for (i, r) in records.iter().enumerate() {
|
||||
assert_eq!(r["n"], i as u64, "record n must be contiguous coded order");
|
||||
}
|
||||
|
||||
// Every picture carries the codec-agnostic coding members from the REAL
|
||||
// parser, derived through the `PictureInfo` accessors.
|
||||
for r in &records {
|
||||
assert!(
|
||||
["I", "P", "B"].contains(&r["type"].as_str().unwrap()),
|
||||
"type must be a real coding type, got {}",
|
||||
r["type"]
|
||||
);
|
||||
// tff was set in the picture coding extension fixture, the frame is an
|
||||
// interlaced (non-progressive) frame picture → field_order "tff",
|
||||
// progressive false, 2 displayed fields.
|
||||
assert_eq!(
|
||||
r["field_order"], "tff",
|
||||
"top_field_first survives the parse as field_order"
|
||||
);
|
||||
assert_eq!(r["progressive"], false);
|
||||
assert_eq!(r["nb_fields"], 2);
|
||||
// No GOP-closure signal is carried by the codec-agnostic PictureInfo, so
|
||||
// the `gop` member is honestly omitted (not fabricated).
|
||||
assert!(r.get("gop").is_none(), "gop member omitted, never guessed");
|
||||
}
|
||||
|
||||
// Exactly two I-pictures (one per GOP), each `type` I and a random-access
|
||||
// point (`key` true — the parser-flagged intra/decode-restart point). The
|
||||
// fixture's only intra pictures are the two GOP-opening I-frames.
|
||||
let key_pics: Vec<&serde_json::Value> = records
|
||||
.iter()
|
||||
.filter(|r| r["key"] == serde_json::Value::Bool(true))
|
||||
.collect();
|
||||
assert_eq!(key_pics.len(), 2, "two intra/random-access pictures");
|
||||
for opener in &key_pics {
|
||||
assert_eq!(opener["type"], "I", "a random-access point is an I-picture");
|
||||
}
|
||||
|
||||
// The two stamped source sectors (sector 0 region and sector 3 region) reach
|
||||
// the index, in ascending order — provenance carried, never reconstructed.
|
||||
let src_sectors: Vec<u64> = records
|
||||
.iter()
|
||||
.filter_map(|r| r["src"]["sector"].as_u64())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
src_sectors,
|
||||
vec![0, 3],
|
||||
"stamped src sectors must reach the .fvi in arrival order; got {src_sectors:?}"
|
||||
);
|
||||
// For each stamped record, sector == byte / 2048 (SourcePos::at_byte). The
|
||||
// byte offset is exact (here 14 into each region, just past the pack
|
||||
// header), so it is NOT sector-aligned — provenance is byte-exact.
|
||||
for r in &records {
|
||||
if let (Some(sector), Some(byte)) = (r["src"]["sector"].as_u64(), r["src"]["byte"].as_u64())
|
||||
{
|
||||
assert_eq!(sector, byte / 2048, "src.sector must equal src.byte / 2048");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Codec-agnostic path: a non-MPEG2 stream (HEVC/H.264/VC-1) emits frames with
|
||||
/// `coding == None` but real `keyframe` + `source` + `pts`. The `.fvi` records
|
||||
/// must still be USEFUL — `key`/`type` from the frame's keyframe flag, `src`/
|
||||
/// `pts` populated — NOT degraded to `type:"?"`/`src:null` just because
|
||||
/// `PictureInfo` is MPEG-2-specific. The genuine null/"P"-fallback path only
|
||||
/// fires when a field is truly absent (no provenance / non-key). No panic.
|
||||
#[test]
|
||||
fn fvi_sink_indexes_non_mpeg2_frames_codec_agnostically() {
|
||||
use libfreemkv::pes::{PesFrame, SourcePos};
|
||||
|
||||
let dir = tempdir();
|
||||
let path = dir.join("nocoding.fvi");
|
||||
let title = mpeg2_dvd_title(0);
|
||||
|
||||
let mk = |pts: i64, keyframe: bool, source: Option<SourcePos>| PesFrame {
|
||||
track: 0,
|
||||
pts,
|
||||
keyframe,
|
||||
data: vec![0u8; 8],
|
||||
duration_ns: None,
|
||||
source,
|
||||
coding: None, // non-MPEG2: no PictureInfo
|
||||
};
|
||||
|
||||
let url = format!("fvi://{}", path.display());
|
||||
let mut sink = output(&url, &title).expect("fvi sink opens");
|
||||
// IDR (keyframe) with real provenance — must NOT be null/"?".
|
||||
sink.write(&mk(1234, true, Some(SourcePos::at_byte(8192))))
|
||||
.unwrap();
|
||||
// Non-key with provenance.
|
||||
sink.write(&mk(5678, false, Some(SourcePos::at_byte(16384))))
|
||||
.unwrap();
|
||||
// Keyframe with NO provenance — src genuinely null, but key/type still set.
|
||||
sink.write(&mk(9012, true, None)).unwrap();
|
||||
sink.finish().unwrap();
|
||||
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let recs: Vec<serde_json::Value> = text
|
||||
.lines()
|
||||
.skip(1)
|
||||
.map(|l| serde_json::from_str(l).unwrap())
|
||||
.collect();
|
||||
assert_eq!(recs.len(), 3);
|
||||
|
||||
// IDR: key true, type "I" (from keyframe), real src + pts, no mpeg2 fields.
|
||||
assert_eq!(recs[0]["key"], true, "HEVC IDR → key from frame.keyframe");
|
||||
assert_eq!(recs[0]["type"], "I");
|
||||
assert_eq!(recs[0]["pts"], 1234);
|
||||
assert_eq!(recs[0]["src"]["sector"], 4); // 8192 / 2048
|
||||
assert_eq!(recs[0]["src"]["byte"], 8192);
|
||||
assert!(
|
||||
recs[0].get("field_order").is_none() && recs[0].get("nb_fields").is_none(),
|
||||
"coding-absent record omits field_order/nb_fields"
|
||||
);
|
||||
assert!(recs[0].get("dts").is_none(), "no DTS on a frame → omitted");
|
||||
|
||||
// Non-key with provenance: key false, type "P", src still present.
|
||||
assert_eq!(recs[1]["key"], false);
|
||||
assert_eq!(recs[1]["type"], "P");
|
||||
assert_eq!(recs[1]["src"]["sector"], 8); // 16384 / 2048
|
||||
|
||||
// Keyframe without provenance: key/type still set, src genuinely null.
|
||||
assert_eq!(recs[2]["key"], true);
|
||||
assert_eq!(recs[2]["type"], "I");
|
||||
assert_eq!(recs[2]["src"], serde_json::Value::Null);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
Reference in New Issue
Block a user