//! `VideoMap` — freemkv's reusable, pure-data per-picture video index ("the //! FVI object"). //! //! A [`VideoMap`] is a header (per-title video facts + provenance root) plus an //! ordered list of per-picture records. Each record carries the per-picture //! coding truth ([`PictureInfo`], off `frame.coding`) and the byte-exact source //! provenance ([`SourcePos`], off `frame.source`) that the highway already //! stamps — this module never re-parses the elementary stream. //! //! It is a STANDALONE PRIMITIVE, deliberately decoupled from any one sink: //! - The `fvi://` sink ([`crate::mux::fvi_sink`]) owns a `VideoMap`, appends //! each video [`PesFrame`], and serializes it. //! - The same `VideoMap` can later be populated as a side-channel during ANY //! mux (e.g. `iso → mkv` while ALSO emitting a `.fvi` sidecar), and reused //! for seek-indexing, recovery loss-mapping, and diagnostics. //! //! `VideoMap` is PURE DATA — it knows no output format. The on-disk shape is the //! freemkv FVI format, whose normative spec is `docs/FVI_FORMAT.md` (ships //! publicly with libfreemkv); the `fvi://` sink does the serialization. A //! different output format would be a DIFFERENT sink reusing this same model, //! not a pluggable encoder here. use crate::disc::{ColorSpace, DiscTitle, Stream as DiscStream, VideoStream}; use crate::mux::codec::PictureInfo; use crate::mux::codec::coding::{CodingType, FieldOrder}; use crate::pes::{PesFrame, SourcePos}; // ── Format constants (cite docs/FVI_FORMAT.md) ─────────────────────────────── /// Value of the header `"format"` member — the FVI document signature /// (`docs/FVI_FORMAT.md` §6). Identifies a stream as a freemkv video index. pub const FVI_FORMAT: &str = "freemkv/video-index"; /// Value of the header `"fvi_version"` member — the FVI document format version /// (`docs/FVI_FORMAT.md` §6, §11). This spec defines `1`. pub const FVI_VERSION: u32 = 1; /// Producing tool tag for the header `"generator"` member /// (`docs/FVI_FORMAT.md` §6). pub const FVI_GENERATOR: &str = concat!("freemkv/", env!("FREEMKV_VERSION"), env!("GIT_SUFFIX")); /// Header `"timescale"` for all `pts`/`dts` ticks (`docs/FVI_FORMAT.md` §10). /// The highway carries presentation timestamps in nanoseconds, so the timescale /// is `1_000_000_000` ticks per second. pub const FVI_TIMESCALE: u64 = 1_000_000_000; /// Bytes per `src.sector` unit (`docs/FVI_FORMAT.md` §6.2, §9). The highway's /// [`SourcePos`] counts 2048-byte logical sectors. pub const FVI_SECTOR_SIZE: u32 = crate::consts::SECTOR_BYTES as u32; // ── Logical model (serialization-independent) ──────────────────────────────── /// Source-stream colour description (CICP code points), header-level /// (`docs/FVI_FORMAT.md` §6.1 `colour`). /// /// Each field is the ITU-T H.273 / ISO 23091-2 code point for the title's /// primary video, derived from the disc's [`ColorSpace`]. `full_range` is the /// video-range flag (`false` = limited / TV range, the disc norm). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Colour { pub primaries: u8, pub transfer: u8, pub matrix: u8, pub full_range: bool, } impl Colour { /// Derive the FVI CICP code points from a full [`VideoStream`], using the /// SAME precedence as the MKV muxer ([`crate::mux::mkv::cicp_for_video`]): /// measured CICP (authoritative) → coarse `color_space` enum + HDR-driven /// transfer override. This is what the sidecar must use so it never reports /// an SDR transfer (14) for an HDR10 BT.2020 title while the MKV container /// reports PQ (16) — the two sinks of one title must agree. pub fn from_video(v: &VideoStream) -> Self { let (matrix, transfer, primaries, range) = crate::mux::mkv::cicp_for_video(v); Self { primaries, transfer, matrix, // Matroska/MeasuredCicp Range: 1 = limited (disc norm), 2 = full. full_range: range == 2, } } /// Map the title's [`ColorSpace`] alone to CICP code points (no HDR/measured /// context). Retained for the no-video header fallback and unit coverage; /// the title path uses [`Colour::from_video`]. Unknown colorimetry maps to /// code point 2 ("unspecified"), the CICP convention. pub fn from_color_space(cs: ColorSpace) -> Self { // (primaries, transfer, matrix) per ITU-T H.273. let (p, t, m) = match cs { ColorSpace::Bt709 => (1, 1, 1), ColorSpace::Bt2020 => (9, 14, 9), // BT.2020 NCL ColorSpace::Bt470bg => (5, 5, 5), ColorSpace::Smpte170m => (6, 6, 6), ColorSpace::Unknown => (2, 2, 2), // unspecified }; Self { primaries: p, transfer: t, matrix: m, // Disc video is limited-range; full-range is not signalled at this // layer, so report the disc norm. full_range: false, } } } /// Scan type for the header `stream.scan` member (`docs/FVI_FORMAT.md` §6.1). /// `"mbaff"` is reachable only for codecs that signal it; MPEG-2 / disc video /// resolves to `progressive` / `interlaced`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Scan { Progressive, Interlaced, } impl Scan { pub fn as_str(self) -> &'static str { match self { Scan::Progressive => "progressive", Scan::Interlaced => "interlaced", } } } /// Source `medium` for the header `source.medium` member /// (`docs/FVI_FORMAT.md` §6.2). Describes the physical/logical input the index /// was built from — never the destination the index is written to. The driver /// derives it from the `MuxInput` arm; [`Medium::File`] is the default only for /// a caller that declares no provenance at all. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Medium { Disc, Iso, #[default] File, Stream, } impl Medium { pub fn as_str(self) -> &'static str { match self { Medium::Disc => "disc", Medium::Iso => "iso", Medium::File => "file", Medium::Stream => "stream", } } } /// Provenance root for the header (`docs/FVI_FORMAT.md` §6.2 `source`). #[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct SourceInfo { /// Input medium. pub medium: Medium, /// Source path / label (may be empty). pub path: String, /// 0-based title / program number the index was built from. pub title: usize, /// Playlist / PGC identifier, if known (empty → omitted). pub playlist: String, /// Disc volume identifier, if read (empty → omitted). pub volume_id: String, } /// Per-title video facts for the header `stream` object /// (`docs/FVI_FORMAT.md` §6.1). #[derive(Clone, Debug, PartialEq, Eq)] pub struct StreamInfo { /// Registered codec id (Appendix B), e.g. `"mpeg2video"`, `"hevc"`. pub codec: &'static str, /// Coded luma dimensions in pixels. pub width: u32, pub height: u32, /// Display aspect ratio as `(num, den)`. pub dar: (u32, u32), /// Nominal frame rate as an exact rational `(num, den)`. pub frame_rate: (u32, u32), /// Scan type. pub scan: Scan, /// Source colour (CICP code points). pub colour: Colour, } /// The header row: per-title facts (`docs/FVI_FORMAT.md` §6). #[derive(Clone, Debug, PartialEq, Eq)] pub struct MapHeader { /// The indexed elementary stream. pub stream: StreamInfo, /// Provenance root. pub source: SourceInfo, /// Total pictures if known at header time; `None` when streaming (omitted). pub picture_count: Option, } /// Map the disc's `Codec` to a registered FVI codec id /// (`docs/FVI_FORMAT.md` Appendix B). The disc-info `Codec::id` strings differ /// (`"mpeg2"`/`"mpeg1"`); FVI uses the bitstream names. fn fvi_codec_id(codec: crate::disc::Codec) -> &'static str { use crate::disc::Codec; match codec { Codec::Mpeg2 => "mpeg2video", Codec::Mpeg1 => "mpeg1video", Codec::H264 => "h264", Codec::Hevc => "hevc", Codec::Vc1 => "vc1", // Not in the registry yet; carry the disc-info id so the field is still // a stable, machine-readable token (readers ignore unknown codecs). other => other.id(), } } impl MapHeader { /// Assemble the header from the title's primary video stream + the supplied /// provenance (`source`). Without a video stream there is nothing to index; /// this returns neutral stream defaults so the header still serializes (the /// record stream will be empty) — a malformed / audio-only title does not /// panic. pub fn from_title(title: &DiscTitle, source: SourceInfo) -> Self { let video: Option<&VideoStream> = title.streams.iter().find_map(|s| match s { DiscStream::Video(v) => Some(v), _ => None, }); let stream = match video { Some(v) => { // Informational map; absent dimensions report as 0. let (width, height) = v.resolution.pixels().unwrap_or((0, 0)); StreamInfo { codec: fvi_codec_id(v.codec), width, height, dar: display_aspect_ratio(v, width, height), frame_rate: v.frame_rate.as_fraction(), scan: if v.resolution.is_interlaced() { Scan::Interlaced } else { Scan::Progressive }, colour: Colour::from_video(v), } } None => StreamInfo { codec: "unknown", width: 0, height: 0, dar: (0, 1), frame_rate: (0, 1), scan: Scan::Progressive, colour: Colour::from_color_space(ColorSpace::Unknown), }, }; Self { stream, source, picture_count: None, } } } /// Display aspect ratio as `(num, den)`. Anamorphic titles carry an explicit /// `display_aspect`; square-pixel titles use the coded pixel dimensions. fn display_aspect_ratio(v: &VideoStream, w: u32, h: u32) -> (u32, u32) { match v.display_aspect { Some((a, b)) if b != 0 => (a, b), _ if h != 0 => (w, h), _ => (0, 1), } } /// One per-picture index record, distilled from a video [`PesFrame`] /// (`docs/FVI_FORMAT.md` §7). /// /// `coding` is the codec-agnostic per-picture truth ([`PictureInfo`], set by /// EVERY video parser that decodes coding — MPEG-2 fully, H.264/HEVC/VC-1 as /// coding-type-only); `source` is the byte-exact provenance. Both are optional: /// an audio / synthetic / provenance-absent frame yields a record whose /// coding-derived members are omitted and whose `src` is the spec-defined null. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct PictureRecord { /// Coded-order index, 0-based, contiguous. pub n: u64, /// Codec-agnostic per-picture coding info, if present. Set by every video /// parser that decodes coding (MPEG-2 fully; H.264/HEVC/VC-1 carry /// coding-type only); `None` for audio/subtitle/synthetic frames. pub coding: Option, /// Random-access / keyframe flag carried for EVERY codec on the frame /// (`PesFrame::keyframe`): IDR/IRAP for HEVC/H.264, the I-picture flag for /// MPEG-2/VC-1. Drives the codec-agnostic `key` member. pub keyframe: bool, /// Presentation timestamp in `timescale` ticks (nanoseconds). pub pts_ns: Option, /// Byte-exact source provenance, if present. pub source: Option, } /// Record `type` label (`docs/FVI_FORMAT.md` §7), codec-agnostic. /// /// When `coding` is present (any video codec — every parser now fills it), the /// agnostic coding type is reported from [`PictureInfo::coding_type`]: /// `CodingType::{I,P,B}` → `"I"`/`"P"`/`"B"`. When `coding` is absent /// (audio / synthetic frames), the type degrades to the I-vs-non-I distinction /// the frame's keyframe flag still carries: `keyframe` → "I", otherwise "P". pub fn type_label(coding: Option, keyframe: bool) -> &'static str { match coding { Some(c) => match c.coding_type() { CodingType::I => "I", CodingType::P => "P", CodingType::B => "B", }, // No PictureInfo: the highway still gives a keyframe flag. None => { if keyframe { "I" } else { "P" } } } } /// Field-display-order label for the optional `field_order` member /// (`docs/FVI_FORMAT.md` §7.1, Matroska element 0x9D), or `None` when the codec /// did not measure it (signal absent / coding-type-only codec). `None` is an /// HONEST absence — the writer OMITS the member rather than guessing a default. pub fn field_order_label(coding: Option) -> Option<&'static str> { match coding?.field_order()? { FieldOrder::Tff => Some("tff"), FieldOrder::Bff => Some("bff"), FieldOrder::Progressive => Some("progressive"), } } /// Whether a picture is a random-access point for the `key` member /// (`docs/FVI_FORMAT.md` §7), codec-agnostic. /// /// For EVERY codec the frame's own `keyframe` flag IS the random-access signal: /// IDR/IRAP for HEVC/H.264, the I-picture flag for MPEG-2/VC-1 — authored by /// each codec's parser through the highway. The codec-agnostic [`PictureInfo`] /// carries NO GOP-closure (no `closed_gop`/`gop_start`), so we DO NOT claim the /// stricter open-GOP clean-RAP precision; `key` is the parser-flagged /// decode-restart point (an intra picture). This is the honest limitation /// documented in `docs/FVI_FORMAT.md`. pub fn is_random_access(coding: Option, keyframe: bool) -> bool { // For a video frame `coding.keyframe()` == an intra (I) picture, which is // exactly the highway's `frame.keyframe`; use the frame flag uniformly. let _ = coding; keyframe } /// The reusable video index: a header plus an ordered list of per-picture /// records. PURE DATA — serialization lives in the sink that consumes it. #[derive(Clone, Debug)] pub struct VideoMap { header: MapHeader, records: Vec, } impl VideoMap { /// Create an empty map with the header assembled from `title`'s primary /// video stream + the supplied provenance. pub fn new(title: &DiscTitle, source: SourceInfo) -> Self { Self { header: MapHeader::from_title(title, source), records: Vec::new(), } } /// The header row. pub fn header(&self) -> &MapHeader { &self.header } /// The per-picture records, in coded/arrival order. pub fn records(&self) -> &[PictureRecord] { &self.records } /// Append one video frame as the next picture record, pulling the coding /// truth from `frame.coding` and the provenance from `frame.source`. The /// record index `n` is the current record count (coded order). Returns the /// record just appended. pub fn append_frame(&mut self, frame: &PesFrame) -> &PictureRecord { let rec = PictureRecord { n: self.records.len() as u64, coding: frame.coding, keyframe: frame.keyframe, // pts is carried as ns; the highway always sets a presentation time // (0 at start), so emit it. A future source genuinely lacking a PTS // would set None and the writer omits the member. pts_ns: Some(frame.pts), source: frame.source, }; self.records.push(rec); self.records.last().expect("just pushed") } } #[cfg(test)] mod tests { use super::*; use crate::disc::{ Codec, ColorSpace, ContentFormat, FrameRate, HdrFormat, Resolution, VideoStream, }; fn video_title(codec: Codec, res: Resolution, fr: FrameRate, cs: ColorSpace) -> DiscTitle { let mut t = DiscTitle::empty(); t.streams = vec![DiscStream::Video(VideoStream { pid: 0x1011, codec, resolution: res, frame_rate: fr, hdr: HdrFormat::Sdr, color_space: cs, display_aspect: None, secondary: false, label: String::new(), measured_cicp: None, })]; t.content_format = ContentFormat::BdTs; t } fn src(medium: Medium, path: &str, title: usize) -> SourceInfo { SourceInfo { medium, path: path.to_string(), title, ..Default::default() } } fn vframe(coding: Option, pts: i64, source: Option) -> PesFrame { let keyframe = coding.map(|c| c.keyframe()).unwrap_or(false); PesFrame { track: 0, pts, keyframe, data: vec![0u8; 4], duration_ns: None, source, coding, } } use crate::mux::codec::coding::Mpeg2Coding; /// An interlaced (tff) MPEG-2 frame picture of the given coding type. fn mpeg2_pic(ct: CodingType) -> PictureInfo { PictureInfo::mpeg2( ct, Mpeg2Coding { top_field_first: true, repeat_first_field: false, progressive_frame: false, progressive_sequence: false, frame_picture: true, }, ) } /// A canonical I-picture fixture (interlaced frame). fn i_picture() -> PictureInfo { mpeg2_pic(CodingType::I) } #[test] fn colour_maps_cicp_code_points() { assert_eq!( Colour::from_color_space(ColorSpace::Bt709), Colour { primaries: 1, transfer: 1, matrix: 1, full_range: false } ); assert_eq!( Colour::from_color_space(ColorSpace::Bt2020), Colour { primaries: 9, transfer: 14, matrix: 9, full_range: false } ); assert_eq!(Colour::from_color_space(ColorSpace::Unknown).primaries, 2); } /// Regression: the FVI sidecar must mirror the MKV muxer's colour precedence, /// not blindly map `color_space` → the SDR transfer 14 for BT.2020. An HDR10 /// BT.2020 title's real transfer is PQ (16); a measured CICP triplet is /// authoritative and copied through verbatim. Before the fix the FVI Colour /// reported transfer=14 while the MKV container reported 16 — two sinks of /// one title disagreeing on the colour code points. #[test] fn fvi_colour_follows_hdr_and_measured_cicp() { use crate::disc::MeasuredCicp; let mk = |hdr: HdrFormat, cs: ColorSpace, cicp: Option| VideoStream { pid: 0x1011, codec: Codec::Hevc, resolution: Resolution::R2160p, frame_rate: FrameRate::F23_976, hdr, color_space: cs, display_aspect: None, secondary: false, label: String::new(), measured_cicp: cicp, }; // HDR10 BT.2020 with NO measured CICP → PQ transfer (16), NOT SDR 14. let c = Colour::from_video(&mk(HdrFormat::Hdr10, ColorSpace::Bt2020, None)); assert_eq!( c, Colour { primaries: 9, transfer: 16, // PQ — not the SDR 14 the enum alone would give matrix: 9, full_range: false, } ); // HLG BT.2020 → transfer 18. let c = Colour::from_video(&mk(HdrFormat::Hlg, ColorSpace::Bt2020, None)); assert_eq!(c.transfer, 18, "HLG transfer must be 18"); // Measured CICP is authoritative — copied through verbatim, incl. full // range (2 → full_range = true), ignoring the coarse enum/HDR guess. let measured = MeasuredCicp { matrix: 9, transfer: 16, primaries: 9, range: 2, }; let c = Colour::from_video(&mk(HdrFormat::Sdr, ColorSpace::Bt709, Some(measured))); assert_eq!( c, Colour { primaries: 9, transfer: 16, matrix: 9, full_range: true, }, "measured CICP must override the coarse color_space enum" ); // Unknown colorimetry, SDR, no measured CICP → all code points map to // "unspecified" (2), matching `from_color_space(Unknown)`. Both sinks of // one title must emit 2, never 0. let c = Colour::from_video(&mk(HdrFormat::Sdr, ColorSpace::Unknown, None)); assert_eq!( c, Colour { primaries: 2, transfer: 2, matrix: 2, full_range: false, }, "Unknown colorimetry must emit CICP 'unspecified' (2), not 0" ); } #[test] fn type_label_full_and_codec_agnostic_fallback() { // coding present: full I/P/B from the agnostic coding_type(). let mk = |ct| Some(mpeg2_pic(ct)); assert_eq!(type_label(mk(CodingType::I), false), "I"); assert_eq!(type_label(mk(CodingType::P), false), "P"); assert_eq!(type_label(mk(CodingType::B), false), "B"); // coding-type-only codec still reports its type. assert_eq!( type_label(Some(PictureInfo::coding_type_only(CodingType::B)), false), "B" ); // No coding (audio/synthetic): degrade to I-vs-non-I from keyframe. assert_eq!(type_label(None, true), "I"); assert_eq!(type_label(None, false), "P"); } #[test] fn field_order_label_omitted_when_unmeasured() { // MPEG-2 interlaced tff frame → "tff". assert_eq!(field_order_label(Some(i_picture())), Some("tff")); // Progressive frame → "progressive". let prog = PictureInfo::mpeg2( CodingType::I, Mpeg2Coding { top_field_first: true, repeat_first_field: false, progressive_frame: true, progressive_sequence: false, frame_picture: true, }, ); assert_eq!(field_order_label(Some(prog)), Some("progressive")); // Coding-type-only codec did not measure field order → None (omitted). assert_eq!( field_order_label(Some(PictureInfo::coding_type_only(CodingType::I))), None ); // No coding at all → None. assert_eq!(field_order_label(None), None); } #[test] fn is_random_access_codec_agnostic() { // For EVERY codec the frame keyframe flag IS the random-access signal. assert!(is_random_access(Some(i_picture()), true)); // An I-picture whose frame flag is clear is NOT promoted — `key` follows // the frame's keyframe flag, never fabricated GOP-closure. assert!(!is_random_access(Some(i_picture()), false)); // P/B with the flag clear → never. assert!(!is_random_access(Some(mpeg2_pic(CodingType::P)), false)); // No coding: the frame keyframe flag IS the RAP signal. assert!(is_random_access(None, true)); assert!(!is_random_access(None, false)); } #[test] fn fvi_codec_ids_use_bitstream_names() { assert_eq!(fvi_codec_id(Codec::Mpeg2), "mpeg2video"); assert_eq!(fvi_codec_id(Codec::Mpeg1), "mpeg1video"); assert_eq!(fvi_codec_id(Codec::H264), "h264"); assert_eq!(fvi_codec_id(Codec::Hevc), "hevc"); assert_eq!(fvi_codec_id(Codec::Vc1), "vc1"); } #[test] fn header_from_title_pulls_video_facts() { let t = video_title( Codec::Mpeg2, Resolution::R576i, FrameRate::F25, ColorSpace::Bt470bg, ); let h = MapHeader::from_title(&t, src(Medium::Iso, "iso://x.iso", 2)); assert_eq!(h.stream.codec, "mpeg2video"); assert_eq!((h.stream.width, h.stream.height), (720, 576)); assert_eq!(h.stream.dar, (720, 576)); // square-pixel fallback assert_eq!(h.stream.frame_rate, (25, 1)); assert_eq!(h.stream.scan, Scan::Interlaced); assert_eq!(h.stream.colour.matrix, 5); assert_eq!(h.source.path, "iso://x.iso"); assert_eq!(h.source.title, 2); assert_eq!(h.source.medium, Medium::Iso); } #[test] fn header_audio_only_title_is_neutral_not_panic() { let t = DiscTitle::empty(); let h = MapHeader::from_title(&t, SourceInfo::default()); assert_eq!(h.stream.codec, "unknown"); assert_eq!((h.stream.width, h.stream.height), (0, 0)); } #[test] fn append_frame_numbers_records_in_order() { let t = video_title( Codec::Mpeg2, Resolution::R1080p, FrameRate::F23_976, ColorSpace::Bt709, ); let mut map = VideoMap::new(&t, SourceInfo::default()); map.append_frame(&vframe( Some(i_picture()), 0, Some(SourcePos::at_byte(2048)), )); map.append_frame(&vframe( Some(mpeg2_pic(CodingType::B)), 42, Some(SourcePos::at_byte(4096)), )); assert_eq!(map.records().len(), 2); assert_eq!(map.records()[0].n, 0); assert_eq!(map.records()[1].n, 1); assert_eq!(map.records()[0].source.unwrap().sector, 1); assert_eq!(map.records()[1].pts_ns, Some(42)); } }