Skip to main content

container/demux/mkv/
mod.rs

1/// Matroska / WebM demux, Colour element parsing, EBML raw scanner, and the
2/// `MkvStreamingDemuxer` implementation (Squad streaming-migration-55 P1).
3
4mod colour;
5mod ebml;
6
7use anyhow::{Context, Result, bail};
8use codec::frame::{ColorMetadata, ColorSpace, ContentLightLevel, PixelFormat, StreamInfo};
9use matroska_demuxer::{Frame as MkvFrame, MatroskaFile, TrackType as MkvTrackType};
10use std::io::Cursor;
11
12use crate::annexb::{
13    NaluCodec, ParamSetTracker, length_prefixed_to_annexb_tracked, parse_avcc, parse_hvcc,
14};
15use crate::streaming::{DemuxHeader, Sample, StreamingDemuxer};
16use crate::MkvColorInfo;
17
18use super::{AudioTrack, DemuxResult};
19
20use colour::{bitrate_from_tags, colour_to_pipeline};
21use ebml::scan_mkv_colour_raw;
22
23// Re-export the two VInt readers that `demux/tests.rs` pulls directly as
24// `super::mkv::{read_id_vint, read_size_vint}`.
25#[allow(unused_imports)] // used only by demux/tests.rs under #[cfg(test)]
26pub(crate) use ebml::{read_id_vint, read_size_vint};
27
28// ---------------------------------------------------------------------------
29// Public demux entry point
30// ---------------------------------------------------------------------------
31
32pub fn demux_mkv(data: &[u8]) -> Result<DemuxResult> {
33    let cursor = Cursor::new(data);
34    let mut mkv =
35        MatroskaFile::open(cursor).map_err(|e| anyhow::anyhow!("reading MKV header: {e}"))?;
36
37    // AVC/HEVC in MKV: CodecPrivate holds the avcC / hvcC configuration record
38    // verbatim. Length-prefixed Block samples need the same Annex-B conversion
39    // we do for MP4, plus VPS/SPS/PPS prepended to the first sample of the
40    // track. VP8/VP9/AV1 are self-contained and skip this dance.
41    //
42    // Snapshot every field we need off TrackEntry before `next_frame` starts
43    // mutating `mkv` below — TrackEntry borrows from `mkv` and hold times
44    // conflict with the &mut self on `next_frame`.
45    let (
46        track_number,
47        track_uid,
48        codec_id,
49        width,
50        height,
51        annexb_prepend,
52        length_size,
53        color_space,
54        mut color_metadata,
55        mut color_info,
56        track_default_duration_ns,
57    ) = {
58        let track_info = mkv
59            .tracks()
60            .iter()
61            .find(|t| t.track_type() == MkvTrackType::Video)
62            .context("no video track in MKV")?;
63
64        let track_number = track_info.track_number().get();
65        let track_uid = track_info.track_uid().get();
66        let codec_id = track_info.codec_id().to_string();
67        // Per-track DefaultDuration (`0x23E383`, ns per frame) — Matroska's
68        // canonical frame-rate hint. Used as the frame_rate fallback when the
69        // segment's `Duration` element is absent (live-recorded MKVs and some
70        // streaming WebMs ship without one). Squad-32: this fallback was
71        // previously missing — frame_rate would silently default to 30.0
72        // even when DefaultDuration cleanly described e.g. 23.976 / 60 fps.
73        let default_duration_ns = track_info.default_duration().map(|d| d.get());
74
75        // Parse avcC/hvcC CodecPrivate once to recover both the parameter
76        // sets and the recorded length_size_minus_one — 4-byte prefixes
77        // are the common case, but the spec allows 1 or 2 bytes.
78        let (annexb_prepend, length_size): (Vec<Vec<u8>>, u8) = if codec_id == "V_MPEG4/ISO/AVC" {
79            let priv_bytes = track_info
80                .codec_private()
81                .context("V_MPEG4/ISO/AVC CodecPrivate missing")?;
82            let cfg = parse_avcc(priv_bytes).context("V_MPEG4/ISO/AVC CodecPrivate malformed")?;
83            (cfg.parameter_sets, cfg.length_size)
84        } else if codec_id == "V_MPEGH/ISO/HEVC" {
85            let priv_bytes = track_info
86                .codec_private()
87                .context("V_MPEGH/ISO/HEVC CodecPrivate missing")?;
88            let cfg = parse_hvcc(priv_bytes).context("V_MPEGH/ISO/HEVC CodecPrivate malformed")?;
89            (cfg.parameter_sets, cfg.length_size)
90        } else {
91            (Vec::new(), 4)
92        };
93
94        if mkv_codec_needs_annexb(&codec_id) && annexb_prepend.is_empty() {
95            bail!("AVC/HEVC MKV CodecPrivate missing or empty — no parameter sets to prepend");
96        }
97
98        let video = track_info
99            .video()
100            .context("video track missing Video element")?;
101        let w = video.pixel_width().get() as u32;
102        let h = video.pixel_height().get() as u32;
103
104        // Parse the Colour element into a ColorMetadata + ColorSpace +
105        // extended MkvColorInfo. Legacy MKVs without Colour produce the
106        // SDR BT.709 default.
107        let (color_space, color_metadata, color_info) = match video.colour() {
108            Some(colour) => colour_to_pipeline(colour),
109            None => (
110                ColorSpace::Bt709,
111                ColorMetadata::default(),
112                MkvColorInfo::default(),
113            ),
114        };
115
116        (
117            track_number,
118            track_uid,
119            codec_id,
120            w,
121            h,
122            annexb_prepend,
123            length_size,
124            color_space,
125            color_metadata,
126            color_info,
127            default_duration_ns,
128        )
129    };
130
131    // Squad-21: matroska-demuxer 0.7's `Colour::new` reads MaxCLL/MaxFALL from
132    // the wrong ElementId offset (it actually reads MatrixCoefficients), and
133    // `MasteringMetadata::new` reads each `_chromaticity_y` from the matching
134    // `_chromaticity_x` ElementId — so all three primaries' y values come back
135    // holding the corresponding x value. Re-scan the raw EBML bytes to recover
136    // the canonical values; the same workaround already lives in
137    // `probe_mkv_color_info`. We MUST also clear the unified
138    // `ColorMetadata.content_light_level` and the mastering display y-fields
139    // we synthesized from the poisoned typed accessors so a scan miss doesn't
140    // leave the wrong value in place.
141    color_info.max_cll = None;
142    color_info.max_fall = None;
143    color_metadata.content_light_level = None;
144    if let Some(md) = color_metadata.mastering_display.as_mut() {
145        // The y values are poisoned with the matching x values — clear them
146        // in case the raw scan can't recover (defensive: leave 0 vs garbage).
147        md.primaries_r_y = 0;
148        md.primaries_g_y = 0;
149        md.primaries_b_y = 0;
150    }
151    if let Some(local) = color_info.mastering.as_mut() {
152        local.primary_r_chromaticity_y = None;
153        local.primary_g_chromaticity_y = None;
154        local.primary_b_chromaticity_y = None;
155    }
156    if let Some(fix) = scan_mkv_colour_raw(data) {
157        color_info.max_cll = fix.max_cll;
158        color_info.max_fall = fix.max_fall;
159        if fix.max_cll.is_some() || fix.max_fall.is_some() {
160            color_metadata.content_light_level = Some(ContentLightLevel {
161                max_cll: fix.max_cll.unwrap_or(0).min(u16::MAX as u32) as u16,
162                max_fall: fix.max_fall.unwrap_or(0).min(u16::MAX as u32) as u16,
163            });
164        }
165        // Re-fold the recovered y-chromaticities (HEVC SEI D.2.28 wire
166        // domain: 0.00002 increments → multiply by 50_000, saturate to u16).
167        let chrom = |v: f64| (v * 50_000.0).round().clamp(0.0, u16::MAX as f64) as u16;
168        if let Some(md) = color_metadata.mastering_display.as_mut() {
169            if let Some(y) = fix.primary_r_chromaticity_y {
170                md.primaries_r_y = chrom(y);
171            }
172            if let Some(y) = fix.primary_g_chromaticity_y {
173                md.primaries_g_y = chrom(y);
174            }
175            if let Some(y) = fix.primary_b_chromaticity_y {
176                md.primaries_b_y = chrom(y);
177            }
178        }
179        if let Some(local) = color_info.mastering.as_mut() {
180            if fix.primary_r_chromaticity_y.is_some() {
181                local.primary_r_chromaticity_y = fix.primary_r_chromaticity_y;
182            }
183            if fix.primary_g_chromaticity_y.is_some() {
184                local.primary_g_chromaticity_y = fix.primary_g_chromaticity_y;
185            }
186            if fix.primary_b_chromaticity_y.is_some() {
187                local.primary_b_chromaticity_y = fix.primary_b_chromaticity_y;
188            }
189        }
190    }
191
192    let needs_annexb = mkv_codec_needs_annexb(&codec_id);
193    let codec = match codec_id.as_str() {
194        "V_VP9" => "vp9".to_string(),
195        "V_VP8" => "vp8".to_string(),
196        "V_AV1" => "av1".to_string(),
197        "V_MPEG4/ISO/AVC" => "h264".to_string(),
198        "V_MPEGH/ISO/HEVC" => "h265".to_string(),
199        other => other.to_lowercase(),
200    };
201
202    let timestamp_scale = mkv.info().timestamp_scale().get();
203    let duration_ticks = mkv.info().duration().unwrap_or(0.0);
204    // timestamp_scale is in ns; duration is in ticks (float)
205    let duration = duration_ticks * (timestamp_scale as f64) / 1_000_000_000.0;
206
207    // Tag-based bitrate: preferred over the computed fallback when a
208    // muxer wrote a `BIT_RATE` Matroska Tag scoped to our track UID.
209    // See `bitrate_from_tags` for scope-resolution details.
210    let tag_bitrate = mkv
211        .tags()
212        .and_then(|tags| bitrate_from_tags(tags, track_uid));
213    // Emit the extended metadata we can't (yet) carry on `StreamInfo`
214    // on a structured log line — downstream work-items #HDR10 and mux
215    // SEI passthrough will read them via `probe_mkv_color_info`.
216    if color_info != MkvColorInfo::default() {
217        tracing::info!(
218            bits_per_channel = ?color_info.bits_per_channel,
219            max_cll = ?color_info.max_cll,
220            max_fall = ?color_info.max_fall,
221            mastering = ?color_info.mastering,
222            "MKV Colour: parsed HDR-adjacent metadata"
223        );
224    }
225
226    let mut samples: Vec<Vec<u8>> = Vec::new();
227    let mut frame = MkvFrame::default();
228    let mut total_video_bytes: u64 = 0;
229    // Same per-stream tracker as the MP4 path. MKV's CodecPrivate carries
230    // the avcC / hvcC bytes verbatim, so the same first-IRAP-prepend
231    // heuristic applies (and is more robust than the old
232    // `is_first_video_sample` flag, which assumed sample 0 was always IRAP).
233    let mut mkv_tracker = if needs_annexb {
234        Some(ParamSetTracker::new(if codec_id == "V_MPEG4/ISO/AVC" {
235            NaluCodec::Avc
236        } else {
237            NaluCodec::Hevc
238        }))
239    } else {
240        None
241    };
242    loop {
243        match mkv.next_frame(&mut frame) {
244            Ok(true) => {
245                if frame.track == track_number {
246                    let raw = std::mem::take(&mut frame.data);
247                    total_video_bytes += raw.len() as u64;
248                    if let Some(tracker) = mkv_tracker.as_mut() {
249                        let annexb = length_prefixed_to_annexb_tracked(
250                            &raw,
251                            length_size,
252                            tracker,
253                            &annexb_prepend,
254                        );
255                        samples.push(annexb);
256                    } else {
257                        samples.push(raw);
258                    }
259                }
260            }
261            Ok(false) => break,
262            Err(e) => bail!("MKV frame read error: {e}"),
263        }
264    }
265
266    let total_frames = samples.len() as u64;
267    // Frame rate fallback chain (Squad-32):
268    //   1. samples / segment_duration  (most accurate when both are known)
269    //   2. 1 / DefaultDuration          (Matroska's canonical per-frame ns)
270    //   3. 30.0                         (last-resort sentinel)
271    let frame_rate = if duration > 0.0 {
272        total_frames as f64 / duration
273    } else if let Some(dd_ns) = track_default_duration_ns.filter(|n| *n > 0) {
274        1_000_000_000.0 / dd_ns as f64
275    } else {
276        30.0
277    };
278
279    let detected_pf = codec::pixel_format::detect(&codec, &samples);
280
281    // Bitrate priority: Tag `BIT_RATE` if present → summed sample bytes
282    // over the segment duration. Never 0 unless the file has no samples
283    // AND no tag (in which case bitrate is genuinely unknowable and we
284    // keep the historical 0 sentinel).
285    let bitrate = match tag_bitrate {
286        Some(b) if b > 0 => b,
287        _ => {
288            if duration > 0.0 && total_video_bytes > 0 {
289                ((total_video_bytes as f64 * 8.0) / duration) as u64
290            } else {
291                0
292            }
293        }
294    };
295
296    let info = StreamInfo {
297        codec: codec.clone(),
298        width,
299        height,
300        frame_rate,
301        duration,
302        pixel_format: detected_pf,
303        color_space,
304        total_frames,
305        bitrate,
306        color_metadata,
307    };
308
309    // Audio passthrough uses its own MatroskaFile handle (re-opened) since
310    // next_frame above already consumed the stream.
311    let audio = super::audio::extract_mkv_audio(data);
312
313    Ok(DemuxResult {
314        codec,
315        info,
316        samples,
317        audio,
318    })
319}
320
321// ---------------------------------------------------------------------------
322// Streaming demuxer
323// ---------------------------------------------------------------------------
324
325/// MKV / WebM streaming demuxer. Wraps `MatroskaFile` whose `next_frame`
326/// API is already pull-shaped, so the streaming impl is a thin wrapper:
327/// pull next frame, filter to the video track, AVCC→Annex-B convert if
328/// AVC/HEVC, surface as a `Sample`.
329pub struct MkvStreamingDemuxer {
330    mkv: MatroskaFile<Cursor<Vec<u8>>>,
331    header: DemuxHeader,
332    audio: Option<AudioTrack>,
333    track_number: u64,
334    timestamp_scale: u64,
335    annexb_prepend: Vec<Vec<u8>>,
336    length_size: u8,
337    tracker: Option<ParamSetTracker>,
338    /// Default-duration in ns from the track header — used as the
339    /// fallback per-sample duration when the Block doesn't carry one.
340    default_duration_ns: Option<u64>,
341    /// Lazily set on the first `next_video_sample()` call by running
342    /// `pixel_format::detect` against the first emitted sample.
343    /// `header.info.pixel_format` is then patched in place. Subsequent
344    /// calls skip the probe (codec sequence headers don't change
345    /// mid-stream for the codecs we support).
346    pixel_format_detected: bool,
347}
348
349pub(crate) fn demux_mkv_streaming_init(data: &[u8]) -> Result<MkvStreamingDemuxer> {
350    let owned = data.to_vec();
351    // First pass: open with a borrow to harvest header metadata without
352    // consuming the buffer that backs the streaming reader.
353    let cursor = Cursor::new(owned.as_slice());
354    let probe =
355        MatroskaFile::open(cursor).map_err(|e| anyhow::anyhow!("reading MKV header: {e}"))?;
356
357    let (
358        track_number,
359        track_uid,
360        codec_id,
361        width,
362        height,
363        annexb_prepend,
364        length_size,
365        color_space,
366        mut color_metadata,
367        mut color_info,
368        track_default_duration_ns,
369    ) = {
370        let track_info = probe
371            .tracks()
372            .iter()
373            .find(|t| t.track_type() == MkvTrackType::Video)
374            .context("no video track in MKV")?;
375
376        let track_number = track_info.track_number().get();
377        let track_uid = track_info.track_uid().get();
378        let codec_id = track_info.codec_id().to_string();
379        let default_duration_ns = track_info.default_duration().map(|d| d.get());
380
381        let (annexb_prepend, length_size): (Vec<Vec<u8>>, u8) = if codec_id == "V_MPEG4/ISO/AVC" {
382            let priv_bytes = track_info
383                .codec_private()
384                .context("V_MPEG4/ISO/AVC CodecPrivate missing")?;
385            let cfg = parse_avcc(priv_bytes).context("V_MPEG4/ISO/AVC CodecPrivate malformed")?;
386            (cfg.parameter_sets, cfg.length_size)
387        } else if codec_id == "V_MPEGH/ISO/HEVC" {
388            let priv_bytes = track_info
389                .codec_private()
390                .context("V_MPEGH/ISO/HEVC CodecPrivate missing")?;
391            let cfg = parse_hvcc(priv_bytes).context("V_MPEGH/ISO/HEVC CodecPrivate malformed")?;
392            (cfg.parameter_sets, cfg.length_size)
393        } else {
394            (Vec::new(), 4)
395        };
396
397        if mkv_codec_needs_annexb(&codec_id) && annexb_prepend.is_empty() {
398            bail!("AVC/HEVC MKV CodecPrivate missing or empty — no parameter sets to prepend");
399        }
400
401        let video = track_info
402            .video()
403            .context("video track missing Video element")?;
404        let w = video.pixel_width().get() as u32;
405        let h = video.pixel_height().get() as u32;
406
407        let (color_space, color_metadata, color_info) = match video.colour() {
408            Some(colour) => colour_to_pipeline(colour),
409            None => (
410                ColorSpace::Bt709,
411                ColorMetadata::default(),
412                MkvColorInfo::default(),
413            ),
414        };
415
416        (
417            track_number,
418            track_uid,
419            codec_id,
420            w,
421            h,
422            annexb_prepend,
423            length_size,
424            color_space,
425            color_metadata,
426            color_info,
427            default_duration_ns,
428        )
429    };
430
431    // Apply the matroska-demuxer 0.7 raw-scan workarounds — same as the
432    // legacy demux_mkv path.
433    color_info.max_cll = None;
434    color_info.max_fall = None;
435    color_metadata.content_light_level = None;
436    if let Some(md) = color_metadata.mastering_display.as_mut() {
437        md.primaries_r_y = 0;
438        md.primaries_g_y = 0;
439        md.primaries_b_y = 0;
440    }
441    if let Some(local) = color_info.mastering.as_mut() {
442        local.primary_r_chromaticity_y = None;
443        local.primary_g_chromaticity_y = None;
444        local.primary_b_chromaticity_y = None;
445    }
446    if let Some(fix) = scan_mkv_colour_raw(&owned) {
447        color_info.max_cll = fix.max_cll;
448        color_info.max_fall = fix.max_fall;
449        if fix.max_cll.is_some() || fix.max_fall.is_some() {
450            color_metadata.content_light_level = Some(ContentLightLevel {
451                max_cll: fix.max_cll.unwrap_or(0).min(u16::MAX as u32) as u16,
452                max_fall: fix.max_fall.unwrap_or(0).min(u16::MAX as u32) as u16,
453            });
454        }
455        let chrom = |v: f64| (v * 50_000.0).round().clamp(0.0, u16::MAX as f64) as u16;
456        if let Some(md) = color_metadata.mastering_display.as_mut() {
457            if let Some(y) = fix.primary_r_chromaticity_y {
458                md.primaries_r_y = chrom(y);
459            }
460            if let Some(y) = fix.primary_g_chromaticity_y {
461                md.primaries_g_y = chrom(y);
462            }
463            if let Some(y) = fix.primary_b_chromaticity_y {
464                md.primaries_b_y = chrom(y);
465            }
466        }
467        if let Some(local) = color_info.mastering.as_mut() {
468            if fix.primary_r_chromaticity_y.is_some() {
469                local.primary_r_chromaticity_y = fix.primary_r_chromaticity_y;
470            }
471            if fix.primary_g_chromaticity_y.is_some() {
472                local.primary_g_chromaticity_y = fix.primary_g_chromaticity_y;
473            }
474            if fix.primary_b_chromaticity_y.is_some() {
475                local.primary_b_chromaticity_y = fix.primary_b_chromaticity_y;
476            }
477        }
478    }
479
480    let needs_annexb = mkv_codec_needs_annexb(&codec_id);
481    let codec = match codec_id.as_str() {
482        "V_VP9" => "vp9".to_string(),
483        "V_VP8" => "vp8".to_string(),
484        "V_AV1" => "av1".to_string(),
485        "V_MPEG4/ISO/AVC" => "h264".to_string(),
486        "V_MPEGH/ISO/HEVC" => "h265".to_string(),
487        other => other.to_lowercase(),
488    };
489
490    let timestamp_scale = probe.info().timestamp_scale().get();
491    let duration_ticks = probe.info().duration().unwrap_or(0.0);
492    let duration = duration_ticks * (timestamp_scale as f64) / 1_000_000_000.0;
493    let tag_bitrate = probe
494        .tags()
495        .and_then(|tags| bitrate_from_tags(tags, track_uid));
496    if color_info != MkvColorInfo::default() {
497        tracing::info!(
498            bits_per_channel = ?color_info.bits_per_channel,
499            max_cll = ?color_info.max_cll,
500            max_fall = ?color_info.max_fall,
501            mastering = ?color_info.mastering,
502            "MKV Colour: parsed HDR-adjacent metadata"
503        );
504    }
505
506    drop(probe);
507
508    // Audio: extract from the owned bytes via a separate MatroskaFile
509    // open (same as legacy demux_mkv). The video reader below needs its
510    // own clean cursor.
511    let audio = super::audio::extract_mkv_audio(&owned);
512
513    // Build the streaming MKV reader against the owned buffer.
514    let mkv = MatroskaFile::open(Cursor::new(owned.clone()))
515        .map_err(|e| anyhow::anyhow!("opening MKV streaming reader: {e}"))?;
516
517    // Bitrate / frame_rate / pixel_format are best-effort at construction
518    // time. Bitrate falls back to 0 (unknown) if no tag exists; the
519    // legacy path computes it by summing sample bytes which is fine for
520    // Vec-materialized output but blows the streaming budget. We surface
521    // the tag bitrate when present and 0 otherwise — pipeline already
522    // tolerates 0 (matches the AVI / TS behaviour).
523    let bitrate = tag_bitrate.unwrap_or(0);
524
525    // For frame_rate we apply the Squad-32 fallback chain as far as it
526    // goes without the materialized sample count. samples/duration is
527    // unknowable in streaming, so use DefaultDuration first then 30.0.
528    let frame_rate = if let Some(dd_ns) = track_default_duration_ns.filter(|n| *n > 0) {
529        1_000_000_000.0 / dd_ns as f64
530    } else if duration > 0.0 {
531        // duration-only fallback: assume 30 fps × duration as the floor.
532        // This matches what the legacy path produced when sample count
533        // was tiny; for normal media DefaultDuration is virtually always
534        // present.
535        30.0
536    } else {
537        30.0
538    };
539
540    // Pixel format detection requires a sample. For the streaming
541    // demuxer's StreamInfo we keep the codec-defaulted Yuv420p — the
542    // actual decoded format is whatever the decoder produces.
543    // (The legacy `demux_mkv()` adapter re-runs `pixel_format::detect`
544    // on the materialized samples after the drain.)
545    let pixel_format = PixelFormat::Yuv420p;
546
547    let info = StreamInfo {
548        codec: codec.clone(),
549        width,
550        height,
551        frame_rate,
552        duration,
553        pixel_format,
554        color_space,
555        total_frames: 0, // unknown until drained
556        bitrate,
557        color_metadata,
558    };
559
560    let tracker = if needs_annexb {
561        Some(ParamSetTracker::new(if codec_id == "V_MPEG4/ISO/AVC" {
562            NaluCodec::Avc
563        } else {
564            NaluCodec::Hevc
565        }))
566    } else {
567        None
568    };
569
570    let _ = needs_annexb; // tracker presence reflects this
571    Ok(MkvStreamingDemuxer {
572        mkv,
573        header: DemuxHeader { codec, info },
574        audio,
575        track_number,
576        timestamp_scale,
577        annexb_prepend,
578        length_size,
579        tracker,
580        default_duration_ns: track_default_duration_ns,
581        pixel_format_detected: false,
582    })
583}
584
585impl StreamingDemuxer for MkvStreamingDemuxer {
586    fn header(&self) -> &DemuxHeader {
587        &self.header
588    }
589
590    fn next_video_sample(&mut self) -> Result<Option<Sample>> {
591        let mut frame = MkvFrame::default();
592        loop {
593            match self.mkv.next_frame(&mut frame) {
594                Ok(true) => {
595                    if frame.track != self.track_number {
596                        continue;
597                    }
598                    let raw = std::mem::take(&mut frame.data);
599                    let data = if let Some(tracker) = self.tracker.as_mut() {
600                        length_prefixed_to_annexb_tracked(
601                            &raw,
602                            self.length_size,
603                            tracker,
604                            &self.annexb_prepend,
605                        )
606                    } else {
607                        raw
608                    };
609                    // Lazy pixel-format detection on the first sample.
610                    // `pixel_format::detect` only ever reads `samples[0]`,
611                    // so a one-shot probe against the first emitted sample
612                    // matches the legacy `demux_mkv()` behaviour without
613                    // requiring the full Vec to be materialised first.
614                    if !self.pixel_format_detected {
615                        let detected = codec::pixel_format::detect(
616                            &self.header.codec,
617                            std::slice::from_ref(&data),
618                        );
619                        self.header.info.pixel_format = detected;
620                        self.pixel_format_detected = true;
621                    }
622                    let pts_ticks = frame.timestamp.saturating_mul(self.timestamp_scale) as i64;
623                    let duration_ticks = frame
624                        .duration
625                        .or(self.default_duration_ns)
626                        .map(|ns| ns.min(u32::MAX as u64) as u32)
627                        .unwrap_or(0);
628                    return Ok(Some(Sample {
629                        data,
630                        pts_ticks,
631                        duration_ticks,
632                    }));
633                }
634                Ok(false) => return Ok(None),
635                Err(e) => bail!("MKV frame read error: {e}"),
636            }
637        }
638    }
639
640    fn audio(&self) -> Option<&AudioTrack> {
641        self.audio.as_ref()
642    }
643}
644
645// ---------------------------------------------------------------------------
646// Public probe helper
647// ---------------------------------------------------------------------------
648
649/// Re-open an MKV container solely to extract the extended Colour
650/// sub-elements that don't fit on `StreamInfo.color_metadata`
651/// (MaxCLL / MaxFALL / SMPTE-2086 mastering primaries / bits_per_channel /
652/// chroma siting). Intended for downstream paths that need HDR10 side
653/// data for muxing; returns `None` when the file has no video track,
654/// no `Colour` element, or isn't a well-formed MKV.
655pub fn probe_mkv_color_info(data: &[u8]) -> Option<MkvColorInfo> {
656    let cursor = Cursor::new(data);
657    let mkv = MatroskaFile::open(cursor).ok()?;
658    let track = mkv
659        .tracks()
660        .iter()
661        .find(|t| t.track_type() == MkvTrackType::Video)?;
662    let colour = track.video()?.colour()?;
663    let (_, _, mut info) = colour_to_pipeline(colour);
664
665    // matroska-demuxer 0.7 has two known bugs we work around with a raw
666    // EBML scan (see `scan_mkv_colour_raw` doc):
667    //   * `Colour::new` misreads MaxCLL/MaxFALL at the MatrixCoefficients
668    //     ElementId offset (so both come back holding the matrix value).
669    //   * `MasteringMetadata::new` misreads each `_chromaticity_y` at the
670    //     matching `_chromaticity_x` ElementId (so all three primaries' y
671    //     values come back holding the corresponding x value).
672    // Clear the poisoned fields before the raw scan overrides them so a
673    // scan miss doesn't leave the wrong value in place.
674    info.max_cll = None;
675    info.max_fall = None;
676    if let Some(local) = info.mastering.as_mut() {
677        local.primary_r_chromaticity_y = None;
678        local.primary_g_chromaticity_y = None;
679        local.primary_b_chromaticity_y = None;
680    }
681    if let Some(fix) = scan_mkv_colour_raw(data) {
682        info.max_cll = fix.max_cll;
683        info.max_fall = fix.max_fall;
684        if let Some(local) = info.mastering.as_mut() {
685            if fix.primary_r_chromaticity_y.is_some() {
686                local.primary_r_chromaticity_y = fix.primary_r_chromaticity_y;
687            }
688            if fix.primary_g_chromaticity_y.is_some() {
689                local.primary_g_chromaticity_y = fix.primary_g_chromaticity_y;
690            }
691            if fix.primary_b_chromaticity_y.is_some() {
692                local.primary_b_chromaticity_y = fix.primary_b_chromaticity_y;
693            }
694        }
695    }
696    Some(info)
697}
698
699// ---------------------------------------------------------------------------
700// Codec-ID helpers
701// ---------------------------------------------------------------------------
702
703/// True for MKV CodecIDs whose samples are length-prefixed (AVCC/HVCC) and
704/// require SPS/PPS pulled from the track's CodecPrivate to feed a decoder
705/// that expects Annex-B. demux_mkv bails on these until the Annex-B path is
706/// wired — currently only VP8/VP9/AV1 are safe through MKV.
707pub(super) fn mkv_codec_needs_annexb(codec_id: &str) -> bool {
708    matches!(codec_id, "V_MPEG4/ISO/AVC" | "V_MPEGH/ISO/HEVC")
709}
710