demux: solidify sink — reuse canonical primitives, fix 3 bugs
Delete re-implementations in the demux:// sink and wire to proven helpers; keep only genuinely-new functionality. - AnnexB reframing: delete the sink's local length_prefixed_to_annexb (it break'd on a zero-length NAL, dropping the rest of the access unit) and call the canonical append_length_prefixed_as_annex_b in mux::hevc, which skips just the empty NAL. - HEVC param sets: delete hvcc_param_sets; reuse hvcc_to_annex_b. - avcC param sets: hoist as the new canonical avcc_to_annex_b in mux::hevc, next to hvcc_to_annex_b (the symmetry point); the sink calls it. - PGS .sup: emit a synthetic clear display set (empty PCS + END) at pts + duration_ns so subtitles time out instead of lingering to EOF. - TimelineContinuity: move verbatim into the shared mux::timeline module (with the prev_offset straggler-remap intact) and use it from both the MKV muxer and the demux sink; delete the sink's drifted TimelineRebase copy (which lacked the straggler branch). - VobSub .idx: emit the conventional 'id: <lang2>, index: 0' line mkvmerge reads to assign the subtitle language; palette reuse unchanged. - output(): seed DemuxOptions.base from title.playlist when non-empty. New constants for the PGS clear-segment framing and avcC header cite the public HDMV PGS (BD-ROM Part 3) and ISO/IEC 14496-15 specs. Tests: a zero-length NAL mid-frame no longer truncates the AU; a frame with duration_ns produces a .sup clear segment; existing demux tests stay green.
This commit is contained in:
+253
-213
@@ -23,6 +23,8 @@
|
|||||||
//! The sink does NOT touch the MKV mux path; it is purely additive.
|
//! The sink does NOT touch the MKV mux path; it is purely additive.
|
||||||
|
|
||||||
use crate::disc::{Chapter, Codec, DiscTitle, Stream as DiscStream};
|
use crate::disc::{Chapter, Codec, DiscTitle, Stream as DiscStream};
|
||||||
|
use crate::mux::hevc::{append_length_prefixed_as_annex_b, avcc_to_annex_b, hvcc_to_annex_b};
|
||||||
|
use crate::mux::timeline::TimelineContinuity;
|
||||||
use crate::pes::{PesFrame, Stream};
|
use crate::pes::{PesFrame, Stream};
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{self, BufWriter, Write};
|
use std::io::{self, BufWriter, Write};
|
||||||
@@ -189,8 +191,6 @@ impl EsWriter for PassthroughWriter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANNEXB_START: [u8; 4] = [0x00, 0x00, 0x00, 0x01];
|
|
||||||
|
|
||||||
/// HEVC/H.264 writer: reframes 4-byte-length-prefixed NALs (the hvcC/avcC form
|
/// HEVC/H.264 writer: reframes 4-byte-length-prefixed NALs (the hvcC/avcC form
|
||||||
/// the parsers emit) into Annex-B, prepending the parameter sets once.
|
/// the parsers emit) into Annex-B, prepending the parameter sets once.
|
||||||
struct AnnexBWriter {
|
struct AnnexBWriter {
|
||||||
@@ -223,119 +223,31 @@ impl EsWriter for AnnexBWriter {
|
|||||||
}
|
}
|
||||||
self.wrote_params = true;
|
self.wrote_params = true;
|
||||||
}
|
}
|
||||||
n += length_prefixed_to_annexb(&f.data, w)?;
|
// Reframe via the canonical length-prefixed→Annex-B converter (single
|
||||||
|
// source of truth across all muxers — see `crate::mux::hevc`). It skips
|
||||||
|
// zero-length NALs and drops a truncated trailing NAL without panicking,
|
||||||
|
// rather than `break`ing on the first zero-length NAL.
|
||||||
|
let mut scratch = Vec::with_capacity(f.data.len() + (f.data.len() / 32) + 4);
|
||||||
|
append_length_prefixed_as_annex_b(&mut scratch, &f.data);
|
||||||
|
w.write_all(&scratch)?;
|
||||||
|
n += scratch.len();
|
||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a buffer of 4-byte big-endian length-prefixed NAL units to Annex-B
|
|
||||||
/// (each NAL prefixed with `00 00 00 01`). Returns bytes written. A malformed
|
|
||||||
/// length (running past the buffer) stops the walk cleanly rather than panic.
|
|
||||||
fn length_prefixed_to_annexb(data: &[u8], w: &mut dyn Write) -> io::Result<usize> {
|
|
||||||
let mut pos = 0;
|
|
||||||
let mut written = 0;
|
|
||||||
while pos + 4 <= data.len() {
|
|
||||||
let len =
|
|
||||||
u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
|
|
||||||
pos += 4;
|
|
||||||
if len == 0 || pos + len > data.len() {
|
|
||||||
// Truncated / malformed length prefix: stop the walk. Emitting a
|
|
||||||
// partial NAL would corrupt the stream worse than dropping the tail.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
w.write_all(&ANNEXB_START)?;
|
|
||||||
w.write_all(&data[pos..pos + len])?;
|
|
||||||
written += ANNEXB_START.len() + len;
|
|
||||||
pos += len;
|
|
||||||
}
|
|
||||||
Ok(written)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract the parameter-set NALs from an hvcC (HEVC) or avcC (H.264)
|
/// Extract the parameter-set NALs from an hvcC (HEVC) or avcC (H.264)
|
||||||
/// configuration record and return them as a single Annex-B blob
|
/// configuration record and return them as a single Annex-B blob
|
||||||
/// (`00 00 00 01 | NAL …`). Returns an empty Vec if the record can't be parsed.
|
/// (`00 00 00 01 | NAL …`). Returns an empty Vec if the record can't be parsed.
|
||||||
|
/// Delegates to the canonical hvcC/avcC → Annex-B converters in
|
||||||
|
/// [`crate::mux::hevc`] — the single source of truth across all muxers.
|
||||||
fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> {
|
fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> {
|
||||||
match codec {
|
match codec {
|
||||||
Codec::Hevc => hvcc_param_sets(record),
|
Codec::Hevc => hvcc_to_annex_b(record).unwrap_or_default(),
|
||||||
Codec::H264 => avcc_param_sets(record),
|
Codec::H264 => avcc_to_annex_b(record).unwrap_or_default(),
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse VPS/SPS/PPS arrays out of an HEVCDecoderConfigurationRecord.
|
|
||||||
/// Layout: 22-byte fixed header, then `numOfArrays` (u8); per array:
|
|
||||||
/// `array_completeness|NAL_type` (u8), `numNalus` (u16 BE); per NAL:
|
|
||||||
/// `nalUnitLength` (u16 BE) + bytes.
|
|
||||||
fn hvcc_param_sets(rec: &[u8]) -> Vec<u8> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
if rec.len() < 23 {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
let num_arrays = rec[22] as usize;
|
|
||||||
let mut pos = 23;
|
|
||||||
for _ in 0..num_arrays {
|
|
||||||
if pos + 3 > rec.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// rec[pos] = array_completeness(1) | reserved(1) | NAL_unit_type(6)
|
|
||||||
pos += 1;
|
|
||||||
let num_nalus = u16::from_be_bytes([rec[pos], rec[pos + 1]]) as usize;
|
|
||||||
pos += 2;
|
|
||||||
for _ in 0..num_nalus {
|
|
||||||
if pos + 2 > rec.len() {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
let nlen = u16::from_be_bytes([rec[pos], rec[pos + 1]]) as usize;
|
|
||||||
pos += 2;
|
|
||||||
if pos + nlen > rec.len() {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
out.extend_from_slice(&ANNEXB_START);
|
|
||||||
out.extend_from_slice(&rec[pos..pos + nlen]);
|
|
||||||
pos += nlen;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse SPS/PPS out of an AVCDecoderConfigurationRecord.
|
|
||||||
/// Layout: 5-byte fixed header, `numOfSPS`(u8, low 5 bits); per SPS:
|
|
||||||
/// length(u16 BE) + bytes; `numOfPPS`(u8); per PPS: length(u16 BE) + bytes.
|
|
||||||
fn avcc_param_sets(rec: &[u8]) -> Vec<u8> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
if rec.len() < 6 {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
let num_sps = (rec[5] & 0x1F) as usize;
|
|
||||||
let mut pos = 6;
|
|
||||||
let take = |count: usize, pos: &mut usize, out: &mut Vec<u8>| -> bool {
|
|
||||||
for _ in 0..count {
|
|
||||||
if *pos + 2 > rec.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let nlen = u16::from_be_bytes([rec[*pos], rec[*pos + 1]]) as usize;
|
|
||||||
*pos += 2;
|
|
||||||
if *pos + nlen > rec.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
out.extend_from_slice(&ANNEXB_START);
|
|
||||||
out.extend_from_slice(&rec[*pos..*pos + nlen]);
|
|
||||||
*pos += nlen;
|
|
||||||
}
|
|
||||||
true
|
|
||||||
};
|
|
||||||
if !take(num_sps, &mut pos, &mut out) {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
if pos >= rec.len() {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
let num_pps = rec[pos] as usize;
|
|
||||||
pos += 1;
|
|
||||||
take(num_pps, &mut pos, &mut out);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped.
|
/// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped.
|
||||||
///
|
///
|
||||||
/// The parser hands us the concatenated PGS segments of a display set in
|
/// The parser hands us the concatenated PGS segments of a display set in
|
||||||
@@ -347,6 +259,27 @@ fn avcc_param_sets(rec: &[u8]) -> Vec<u8> {
|
|||||||
/// an empty composition at `pts + duration` so players time the subtitle out.
|
/// an empty composition at `pts + duration` so players time the subtitle out.
|
||||||
struct PgsSupWriter;
|
struct PgsSupWriter;
|
||||||
|
|
||||||
|
// ── PGS / HDMV segment framing constants ─────────────────────────────────────
|
||||||
|
// HDMV Presentation Graphics Stream, as published in the Blu-ray Disc
|
||||||
|
// Read-Only Format (BD-ROM) Part 3 graphics-stream specification (and the
|
||||||
|
// public US 2009/0185789 A1 application that documents the segment layout).
|
||||||
|
|
||||||
|
/// `.sup` per-segment magic: ASCII "PG" (0x50 0x47) starting each segment's
|
||||||
|
/// 13-byte header (magic | PTS u32 BE | DTS u32 BE) in a PGStream `.sup` file.
|
||||||
|
const SUP_MAGIC: [u8; 2] = [0x50, 0x47];
|
||||||
|
/// Size in bytes of the `.sup` per-segment header (magic 2 + PTS 4 + DTS 4).
|
||||||
|
const SUP_HEADER_LEN: usize = SUP_MAGIC.len() + 4 + 4;
|
||||||
|
/// PGS segment type: Presentation Composition Segment (PCS).
|
||||||
|
const SEG_PCS: u8 = 0x16;
|
||||||
|
/// PGS segment type: END of display set.
|
||||||
|
const SEG_END: u8 = 0x80;
|
||||||
|
/// PCS `composition_state` value: Epoch Start (a fresh display).
|
||||||
|
const PCS_COMPOSITION_STATE_EPOCH_START: u8 = 0x80;
|
||||||
|
/// PGS segment header on the wire (inside `frame.data`): type(1) + size(2 BE).
|
||||||
|
const PGS_SEG_HEADER_LEN: usize = 3;
|
||||||
|
/// Byte offset of `width`/`height` within a PCS segment (after type+size).
|
||||||
|
const PCS_WIDTH_OFFSET: usize = PGS_SEG_HEADER_LEN; // 3
|
||||||
|
|
||||||
/// 90 kHz ticks from nanoseconds (saturating into u32 for the `.sup` header).
|
/// 90 kHz ticks from nanoseconds (saturating into u32 for the `.sup` header).
|
||||||
fn ns_to_90k(pts_ns: i64) -> u32 {
|
fn ns_to_90k(pts_ns: i64) -> u32 {
|
||||||
if pts_ns <= 0 {
|
if pts_ns <= 0 {
|
||||||
@@ -369,27 +302,94 @@ impl PgsSupWriter {
|
|||||||
let mut pos = 0;
|
let mut pos = 0;
|
||||||
let mut written = 0;
|
let mut written = 0;
|
||||||
// Each PGS segment in the payload is: type(1) + size(2 BE) + size bytes.
|
// Each PGS segment in the payload is: type(1) + size(2 BE) + size bytes.
|
||||||
while pos + 3 <= data.len() {
|
while pos + PGS_SEG_HEADER_LEN <= data.len() {
|
||||||
let size = u16::from_be_bytes([data[pos + 1], data[pos + 2]]) as usize;
|
let size = u16::from_be_bytes([data[pos + 1], data[pos + 2]]) as usize;
|
||||||
let seg_end = pos + 3 + size;
|
let seg_end = pos + PGS_SEG_HEADER_LEN + size;
|
||||||
if seg_end > data.len() {
|
if seg_end > data.len() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
w.write_all(&[0x50, 0x47])?; // "PG"
|
w.write_all(&SUP_MAGIC)?;
|
||||||
w.write_all(&pts90k.to_be_bytes())?;
|
w.write_all(&pts90k.to_be_bytes())?;
|
||||||
w.write_all(&dts90k.to_be_bytes())?;
|
w.write_all(&dts90k.to_be_bytes())?;
|
||||||
w.write_all(&data[pos..seg_end])?;
|
w.write_all(&data[pos..seg_end])?;
|
||||||
written += 13 + size;
|
written += SUP_HEADER_LEN + size;
|
||||||
pos = seg_end;
|
pos = seg_end;
|
||||||
}
|
}
|
||||||
Ok(written)
|
Ok(written)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a synthetic "clear" display set: an empty PCS (0 composition
|
||||||
|
/// objects) followed by an END segment. The parser folds the original
|
||||||
|
/// clear/end PCS pair's wipe time into the display frame's `duration_ns`
|
||||||
|
/// and drops the clear bytes, so a faithful `.sup` re-emits one here at
|
||||||
|
/// `display_pts + duration`. Without it every subtitle lingers to EOF.
|
||||||
|
///
|
||||||
|
/// `width`/`height` are carried from the display set's PCS so the clear PCS
|
||||||
|
/// advertises the same video geometry; they don't affect the wipe but keep
|
||||||
|
/// the segment well-formed.
|
||||||
|
///
|
||||||
|
/// Returned bytes are concatenated `type(1)+size(2 BE)+payload` segments,
|
||||||
|
/// the same shape [`emit_segments`] consumes.
|
||||||
|
fn synthetic_clear_display_set(width: u16, height: u16) -> Vec<u8> {
|
||||||
|
// Empty PCS payload (HDMV PGS, BD-ROM Part 3): width(2) height(2)
|
||||||
|
// frame_rate(1) composition_number(2) composition_state(1)
|
||||||
|
// palette_update_flag(1) palette_id(1) number_of_composition_objects(1).
|
||||||
|
const PCS_FRAME_RATE: u8 = 0x10; // reserved high nibble | rate code
|
||||||
|
const PCS_NO_OBJECTS: u8 = 0x00; // number_of_composition_objects = 0
|
||||||
|
let [w_hi, w_lo] = width.to_be_bytes();
|
||||||
|
let [h_hi, h_lo] = height.to_be_bytes();
|
||||||
|
let pcs_payload = [
|
||||||
|
w_hi,
|
||||||
|
w_lo,
|
||||||
|
h_hi,
|
||||||
|
h_lo,
|
||||||
|
PCS_FRAME_RATE,
|
||||||
|
0x00,
|
||||||
|
0x00, // composition_number
|
||||||
|
PCS_COMPOSITION_STATE_EPOCH_START,
|
||||||
|
0x00, // palette_update_flag
|
||||||
|
0x00, // palette_id
|
||||||
|
PCS_NO_OBJECTS,
|
||||||
|
];
|
||||||
|
let mut out = Vec::with_capacity(PGS_SEG_HEADER_LEN * 2 + pcs_payload.len());
|
||||||
|
out.push(SEG_PCS);
|
||||||
|
out.extend_from_slice(&(pcs_payload.len() as u16).to_be_bytes());
|
||||||
|
out.extend_from_slice(&pcs_payload);
|
||||||
|
// END segment: type SEG_END, zero-length payload.
|
||||||
|
out.push(SEG_END);
|
||||||
|
out.extend_from_slice(&0u16.to_be_bytes());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the (width, height) the display set's first PCS advertises, if the
|
||||||
|
/// frame starts with a PCS carrying them; else `(0, 0)`.
|
||||||
|
fn pcs_dimensions(data: &[u8]) -> (u16, u16) {
|
||||||
|
// segment: type(1) size(2) payload; PCS payload begins width(2) height(2).
|
||||||
|
if data.len() >= PCS_WIDTH_OFFSET + 4 && data[0] == SEG_PCS {
|
||||||
|
let w = u16::from_be_bytes([data[PCS_WIDTH_OFFSET], data[PCS_WIDTH_OFFSET + 1]]);
|
||||||
|
let h = u16::from_be_bytes([data[PCS_WIDTH_OFFSET + 2], data[PCS_WIDTH_OFFSET + 3]]);
|
||||||
|
(w, h)
|
||||||
|
} else {
|
||||||
|
(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EsWriter for PgsSupWriter {
|
impl EsWriter for PgsSupWriter {
|
||||||
fn write_frame(&mut self, w: &mut dyn Write, f: &PesFrame, pts_ns: i64) -> io::Result<usize> {
|
fn write_frame(&mut self, w: &mut dyn Write, f: &PesFrame, pts_ns: i64) -> io::Result<usize> {
|
||||||
let pts90 = ns_to_90k(pts_ns);
|
let pts90 = ns_to_90k(pts_ns);
|
||||||
Self::emit_segments(&f.data, pts90, pts90, w)
|
let mut written = Self::emit_segments(&f.data, pts90, pts90, w)?;
|
||||||
|
// The parser folds the display/clear PCS pair's wipe time into
|
||||||
|
// `duration_ns` and drops the clear bytes. Re-emit a synthetic clear
|
||||||
|
// display set at `pts + duration` so the subtitle is timed out instead
|
||||||
|
// of lingering to EOF.
|
||||||
|
if let Some(dur) = f.duration_ns {
|
||||||
|
let clear_pts = ns_to_90k(pts_ns.saturating_add(dur as i64));
|
||||||
|
let (w_px, h_px) = Self::pcs_dimensions(&f.data);
|
||||||
|
let clear = Self::synthetic_clear_display_set(w_px, h_px);
|
||||||
|
written += Self::emit_segments(&clear, clear_pts, clear_pts, w)?;
|
||||||
|
}
|
||||||
|
Ok(written)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,20 +399,27 @@ struct VobSubWriter {
|
|||||||
idx_path: PathBuf,
|
idx_path: PathBuf,
|
||||||
/// Pre-formatted `.idx` palette header line bytes, if available.
|
/// Pre-formatted `.idx` palette header line bytes, if available.
|
||||||
palette_line: Option<String>,
|
palette_line: Option<String>,
|
||||||
|
/// Two-letter language id for the `.idx` `id:` line (empty = omit).
|
||||||
|
lang2: String,
|
||||||
entries: Vec<(i64, u64)>,
|
entries: Vec<(i64, u64)>,
|
||||||
pos: u64,
|
pos: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VobSubWriter {
|
impl VobSubWriter {
|
||||||
fn new(idx_path: PathBuf, codec_private: Option<&[u8]>) -> Self {
|
fn new(idx_path: PathBuf, codec_private: Option<&[u8]>, lang: &str) -> Self {
|
||||||
// codec_private for DvdSub is the pre-formatted VobSub `.idx` palette
|
// codec_private for DvdSub is the pre-formatted VobSub `.idx` palette
|
||||||
// header (UTF-8). Carry it through verbatim if present.
|
// header (UTF-8). Carry it through verbatim if present.
|
||||||
let palette_line = codec_private
|
let palette_line = codec_private
|
||||||
.and_then(|b| std::str::from_utf8(b).ok())
|
.and_then(|b| std::str::from_utf8(b).ok())
|
||||||
.map(|s| s.trim_end().to_string());
|
.map(|s| s.trim_end().to_string());
|
||||||
|
// VobSub `id:` lines use a 2-letter code; stream languages are ISO
|
||||||
|
// 639-2 (3-letter). Take the leading two chars — the convention
|
||||||
|
// mkvmerge reads to assign a track language.
|
||||||
|
let lang2: String = lang.chars().take(2).collect();
|
||||||
Self {
|
Self {
|
||||||
idx_path,
|
idx_path,
|
||||||
palette_line,
|
palette_line,
|
||||||
|
lang2,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
pos: 0,
|
pos: 0,
|
||||||
}
|
}
|
||||||
@@ -435,6 +442,14 @@ impl EsWriter for VobSubWriter {
|
|||||||
idx.push('\n');
|
idx.push('\n');
|
||||||
}
|
}
|
||||||
idx.push_str("langidx: 0\n\n");
|
idx.push_str("langidx: 0\n\n");
|
||||||
|
// The conventional `id: <lang2>, index: 0` line mkvmerge reads to
|
||||||
|
// assign the subtitle track's language. Omit the language token when
|
||||||
|
// unknown but still emit the index so the entry list is well-formed.
|
||||||
|
if self.lang2.is_empty() {
|
||||||
|
idx.push_str("id: , index: 0\n");
|
||||||
|
} else {
|
||||||
|
idx.push_str(&format!("id: {}, index: 0\n", self.lang2));
|
||||||
|
}
|
||||||
for (pts_ns, filepos) in &self.entries {
|
for (pts_ns, filepos) in &self.entries {
|
||||||
idx.push_str(&format!(
|
idx.push_str(&format!(
|
||||||
"timestamp: {}, filepos: {:09x}\n",
|
"timestamp: {}, filepos: {:09x}\n",
|
||||||
@@ -462,6 +477,7 @@ fn es_writer_for(
|
|||||||
codec: Codec,
|
codec: Codec,
|
||||||
codec_private: Option<&[u8]>,
|
codec_private: Option<&[u8]>,
|
||||||
idx_path: Option<PathBuf>,
|
idx_path: Option<PathBuf>,
|
||||||
|
lang: &str,
|
||||||
) -> Box<dyn EsWriter> {
|
) -> Box<dyn EsWriter> {
|
||||||
match codec {
|
match codec {
|
||||||
Codec::Hevc | Codec::H264 => Box::new(AnnexBWriter::new(codec, codec_private)),
|
Codec::Hevc | Codec::H264 => Box::new(AnnexBWriter::new(codec, codec_private)),
|
||||||
@@ -469,58 +485,12 @@ fn es_writer_for(
|
|||||||
Codec::DvdSub => Box::new(VobSubWriter::new(
|
Codec::DvdSub => Box::new(VobSubWriter::new(
|
||||||
idx_path.unwrap_or_else(|| PathBuf::from("subtitle.idx")),
|
idx_path.unwrap_or_else(|| PathBuf::from("subtitle.idx")),
|
||||||
codec_private,
|
codec_private,
|
||||||
|
lang,
|
||||||
)),
|
)),
|
||||||
_ => Box::new(PassthroughWriter),
|
_ => Box::new(PassthroughWriter),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Timeline rebase (seamless-branch PTS continuity) ─────────────────────────
|
|
||||||
|
|
||||||
/// Discontinuity threshold: a backward video-PTS jump larger than this opens a
|
|
||||||
/// new epoch. Mirrors the MKV muxer's `DISCONTINUITY_BACKSTEP_NS` (3 s).
|
|
||||||
const DISCONTINUITY_BACKSTEP_NS: i64 = 3_000_000_000;
|
|
||||||
/// 1 ms seam gap inserted between epochs (mirrors the MKV muxer).
|
|
||||||
const SEAM_GAP_NS: i64 = 1_000_000;
|
|
||||||
|
|
||||||
/// Port of the MKV muxer's `TimelineContinuity` for the demux sink: track 0
|
|
||||||
/// (primary video) drives epochs; a single global `offset_ns` is added to every
|
|
||||||
/// track so A/V sync is preserved across clip seams in seamless-branched titles.
|
|
||||||
struct TimelineRebase {
|
|
||||||
offset_ns: i64,
|
|
||||||
high_ns: i64,
|
|
||||||
started: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TimelineRebase {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
offset_ns: 0,
|
|
||||||
high_ns: 0,
|
|
||||||
started: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a raw concatenated PTS to a continuous one. Only track 0 opens
|
|
||||||
/// epochs; all tracks get the same global offset.
|
|
||||||
fn rebase(&mut self, track: usize, pts_ns: i64) -> i64 {
|
|
||||||
if track == 0 {
|
|
||||||
if !self.started {
|
|
||||||
self.started = true;
|
|
||||||
self.high_ns = pts_ns;
|
|
||||||
} else if pts_ns < self.high_ns - DISCONTINUITY_BACKSTEP_NS {
|
|
||||||
// Clip seam: shift this and following frames forward so the new
|
|
||||||
// epoch starts just after the previous high-water mark.
|
|
||||||
self.offset_ns += (self.high_ns - pts_ns) + SEAM_GAP_NS;
|
|
||||||
}
|
|
||||||
let out = pts_ns + self.offset_ns;
|
|
||||||
self.high_ns = self.high_ns.max(out);
|
|
||||||
out
|
|
||||||
} else {
|
|
||||||
pts_ns + self.offset_ns
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delay + chapter helpers ──────────────────────────────────────────────────
|
// ── Delay + chapter helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Delay in ms = round((audio_first_pts − ref_video_first_pts) / 1e6).
|
/// Delay in ms = round((audio_first_pts − ref_video_first_pts) / 1e6).
|
||||||
@@ -640,7 +610,7 @@ pub struct DemuxSink {
|
|||||||
/// Index = track id; `None` for unselected tracks.
|
/// Index = track id; `None` for unselected tracks.
|
||||||
tracks: Vec<Option<TrackOut>>,
|
tracks: Vec<Option<TrackOut>>,
|
||||||
ref_video_track: Option<usize>,
|
ref_video_track: Option<usize>,
|
||||||
timeline: TimelineRebase,
|
timeline: TimelineContinuity,
|
||||||
finished: bool,
|
finished: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,7 +655,7 @@ impl DemuxSink {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
let codec_private = title.codec_privates.get(idx).and_then(|o| o.as_deref());
|
let codec_private = title.codec_privates.get(idx).and_then(|o| o.as_deref());
|
||||||
let writer = es_writer_for(codec, codec_private, sidecar.clone());
|
let writer = es_writer_for(codec, codec_private, sidecar.clone(), &lang);
|
||||||
|
|
||||||
let _ = sidecar; // sidecar path is owned by the VobSub writer
|
let _ = sidecar; // sidecar path is owned by the VobSub writer
|
||||||
tracks.push(Some(TrackOut {
|
tracks.push(Some(TrackOut {
|
||||||
@@ -703,7 +673,7 @@ impl DemuxSink {
|
|||||||
opts: opts.clone(),
|
opts: opts.clone(),
|
||||||
tracks,
|
tracks,
|
||||||
ref_video_track,
|
ref_video_track,
|
||||||
timeline: TimelineRebase::new(),
|
timeline: TimelineContinuity::new(),
|
||||||
finished: false,
|
finished: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -811,7 +781,9 @@ impl Stream for DemuxSink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
|
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
|
||||||
let pts = self.timeline.rebase(frame.track, frame.pts);
|
// Track 0 (primary video) drives epoch decisions; every other track is a
|
||||||
|
// passive rider on the same global offset — see `TimelineContinuity`.
|
||||||
|
let pts = self.timeline.adjust(frame.pts, frame.track == 0);
|
||||||
if let Some(Some(t)) = self.tracks.get_mut(frame.track) {
|
if let Some(Some(t)) = self.tracks.get_mut(frame.track) {
|
||||||
t.first_pts_ns.get_or_insert(pts);
|
t.first_pts_ns.get_or_insert(pts);
|
||||||
t.writer.write_frame(&mut t.w, frame, pts)?;
|
t.writer.write_frame(&mut t.w, frame, pts)?;
|
||||||
@@ -885,51 +857,38 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Annex-B reframing ────────────────────────────────────────────────────
|
// ── Annex-B reframing ────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The length-prefixed → Annex-B conversion and the hvcC/avcC param-set
|
||||||
|
// extraction are exercised canonically in `crate::mux::hevc`; the sink
|
||||||
|
// delegates to those helpers. Here we only assert the sink-level wiring:
|
||||||
|
// param-set prepend and (crucially) that a zero-length NAL mid-frame no
|
||||||
|
// longer truncates the rest of the access unit.
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn length_prefixed_converts_to_annexb() {
|
fn zero_length_nal_midframe_does_not_truncate_access_unit() {
|
||||||
// Two NALs: lengths 2 and 3.
|
// The OLD local reframer `break`d on a zero-length NAL, dropping every
|
||||||
let data = [0, 0, 0, 2, 0xAA, 0xBB, 0, 0, 0, 3, 0x01, 0x02, 0x03];
|
// NAL after it. The canonical `append_length_prefixed_as_annex_b` skips
|
||||||
|
// just the empty NAL and keeps going. Frame: NAL(2) | NAL(0) | NAL(3).
|
||||||
|
let mut w = AnnexBWriter::new(Codec::H264, None);
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let n = length_prefixed_to_annexb(&data, &mut out).unwrap();
|
let f = PesFrame {
|
||||||
|
track: 0,
|
||||||
|
pts: 0,
|
||||||
|
keyframe: true,
|
||||||
|
data: vec![
|
||||||
|
0, 0, 0, 2, 0xAA, 0xBB, // NAL #1 (len 2)
|
||||||
|
0, 0, 0, 0, // zero-length NAL — must be skipped, not fatal
|
||||||
|
0, 0, 0, 3, 0x01, 0x02, 0x03, // NAL #3 (len 3) — must survive
|
||||||
|
],
|
||||||
|
duration_ns: None,
|
||||||
|
};
|
||||||
|
w.write_frame(&mut out, &f, 0).unwrap();
|
||||||
|
// Both real NALs present; the empty NAL emitted nothing.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
out,
|
out,
|
||||||
vec![0, 0, 0, 1, 0xAA, 0xBB, 0, 0, 0, 1, 0x01, 0x02, 0x03]
|
vec![0, 0, 0, 1, 0xAA, 0xBB, 0, 0, 0, 1, 0x01, 0x02, 0x03],
|
||||||
|
"trailing NAL after a zero-length NAL must NOT be dropped"
|
||||||
);
|
);
|
||||||
assert_eq!(n, out.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn length_prefixed_stops_on_truncation() {
|
|
||||||
// Declares length 5 but only 2 bytes follow → drop the bad tail.
|
|
||||||
let data = [0, 0, 0, 5, 0xAA, 0xBB];
|
|
||||||
let mut out = Vec::new();
|
|
||||||
length_prefixed_to_annexb(&data, &mut out).unwrap();
|
|
||||||
assert!(out.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn avcc_param_sets_extracted_as_annexb() {
|
|
||||||
// Minimal avcC: header(5) numSPS=1 spsLen=2 SPS=[0x67,0x42] numPPS=1
|
|
||||||
// ppsLen=1 PPS=[0x68].
|
|
||||||
let rec = [
|
|
||||||
1, 0x42, 0x00, 0x1F, 0xFF, 0xE1, 0, 2, 0x67, 0x42, 1, 0, 1, 0x68,
|
|
||||||
];
|
|
||||||
let blob = avcc_param_sets(&rec);
|
|
||||||
assert_eq!(blob, vec![0, 0, 0, 1, 0x67, 0x42, 0, 0, 0, 1, 0x68]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hvcc_param_sets_extracted_as_annexb() {
|
|
||||||
// hvcC: 22-byte header (we only need byte 22 = numArrays), then arrays.
|
|
||||||
let mut rec = vec![0u8; 22];
|
|
||||||
rec.push(2); // numArrays
|
|
||||||
// Array 1: type byte, numNalus=1, len=2, NAL=[0x40,0x01]
|
|
||||||
rec.extend_from_slice(&[0x20, 0, 1, 0, 2, 0x40, 0x01]);
|
|
||||||
// Array 2: type byte, numNalus=1, len=1, NAL=[0x42]
|
|
||||||
rec.extend_from_slice(&[0x21, 0, 1, 0, 1, 0x42]);
|
|
||||||
let blob = hvcc_param_sets(&rec);
|
|
||||||
assert_eq!(blob, vec![0, 0, 0, 1, 0x40, 0x01, 0, 0, 0, 1, 0x42]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1020,14 +979,86 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pgs_sup_frames_each_segment_with_pg_header() {
|
fn pgs_sup_frames_each_segment_with_pg_header() {
|
||||||
// One segment: type=0x16, size=2, payload=[0xDE,0xAD].
|
// One segment: type=0x16, size=2, payload=[0xDE,0xAD].
|
||||||
let payload = [0x16, 0x00, 0x02, 0xDE, 0xAD];
|
let payload = [SEG_PCS, 0x00, 0x02, 0xDE, 0xAD];
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let written = PgsSupWriter::emit_segments(&payload, 0x10, 0x10, &mut out).unwrap();
|
let written = PgsSupWriter::emit_segments(&payload, 0x10, 0x10, &mut out).unwrap();
|
||||||
assert_eq!(&out[0..2], b"PG");
|
assert_eq!(&out[0..2], &SUP_MAGIC);
|
||||||
assert_eq!(&out[2..6], &0x10u32.to_be_bytes()); // PTS
|
assert_eq!(&out[2..6], &0x10u32.to_be_bytes()); // PTS
|
||||||
assert_eq!(&out[6..10], &0x10u32.to_be_bytes()); // DTS
|
assert_eq!(&out[6..10], &0x10u32.to_be_bytes()); // DTS
|
||||||
assert_eq!(&out[10..], &payload); // segment body verbatim
|
assert_eq!(&out[SUP_HEADER_LEN..], &payload); // segment body verbatim
|
||||||
assert_eq!(written, 13 + 2);
|
assert_eq!(written, SUP_HEADER_LEN + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pgs_frame_with_duration_emits_clear_segment() {
|
||||||
|
// A display set with a real PCS (type 0x16) carrying 1920x1080, and a
|
||||||
|
// duration → the writer must append a synthetic clear display set
|
||||||
|
// (empty PCS + END) timestamped at pts + duration.
|
||||||
|
let mut pcs = vec![SEG_PCS, 0x00, 0x0B];
|
||||||
|
pcs.extend_from_slice(&[0x07, 0x80, 0x04, 0x38]); // 1920x1080
|
||||||
|
pcs.extend_from_slice(&[0x10, 0x00, 0x00, 0x80, 0x00, 0x00, 0x01]); // 1 object
|
||||||
|
let f = PesFrame {
|
||||||
|
track: 0,
|
||||||
|
pts: 1_000_000_000, // 1s
|
||||||
|
keyframe: true,
|
||||||
|
data: pcs,
|
||||||
|
duration_ns: Some(2_000_000_000), // 2s display → clear at 3s
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut w = PgsSupWriter;
|
||||||
|
w.write_frame(&mut out, &f, f.pts).unwrap();
|
||||||
|
|
||||||
|
// Parse out every PG-framed segment: PG(2) PTS(4) DTS(4) type(1) size(2).
|
||||||
|
let mut segs: Vec<(u8, u32)> = Vec::new();
|
||||||
|
let mut pos = 0;
|
||||||
|
while pos + SUP_HEADER_LEN <= out.len() {
|
||||||
|
assert_eq!(
|
||||||
|
&out[pos..pos + 2],
|
||||||
|
&SUP_MAGIC,
|
||||||
|
"each segment carries PG magic"
|
||||||
|
);
|
||||||
|
let pts = u32::from_be_bytes([out[pos + 2], out[pos + 3], out[pos + 4], out[pos + 5]]);
|
||||||
|
let seg_type = out[pos + SUP_HEADER_LEN];
|
||||||
|
let size =
|
||||||
|
u16::from_be_bytes([out[pos + SUP_HEADER_LEN + 1], out[pos + SUP_HEADER_LEN + 2]])
|
||||||
|
as usize;
|
||||||
|
segs.push((seg_type, pts));
|
||||||
|
pos += SUP_HEADER_LEN + PGS_SEG_HEADER_LEN + size;
|
||||||
|
}
|
||||||
|
// Display PCS at 1s (90k), then a clear PCS + END at 3s.
|
||||||
|
let clear90 = ns_to_90k(3_000_000_000);
|
||||||
|
assert!(
|
||||||
|
segs.iter().any(|&(t, p)| t == SEG_PCS && p == clear90),
|
||||||
|
"a clear PCS must be emitted at pts+duration, got {segs:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
segs.iter().any(|&(t, p)| t == SEG_END && p == clear90),
|
||||||
|
"an END segment must terminate the clear display set, got {segs:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pgs_frame_without_duration_emits_no_clear() {
|
||||||
|
// No duration → no synthetic clear (the subtitle's wipe time is unknown).
|
||||||
|
let f = PesFrame {
|
||||||
|
track: 0,
|
||||||
|
pts: 0,
|
||||||
|
keyframe: true,
|
||||||
|
data: vec![SEG_PCS, 0x00, 0x02, 0xDE, 0xAD],
|
||||||
|
duration_ns: None,
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut w = PgsSupWriter;
|
||||||
|
w.write_frame(&mut out, &f, 0).unwrap();
|
||||||
|
// Exactly one PG-framed segment (the display), no clear appended.
|
||||||
|
// Output = `.sup` header (10) + the on-wire segment (type+size 3 + 2
|
||||||
|
// payload = 5) → 15 bytes, with no trailing clear.
|
||||||
|
assert_eq!(&out[0..2], &SUP_MAGIC);
|
||||||
|
assert_eq!(
|
||||||
|
out.len(),
|
||||||
|
SUP_HEADER_LEN + PGS_SEG_HEADER_LEN + 2,
|
||||||
|
"only the display segment, no clear"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1044,7 +1075,7 @@ mod tests {
|
|||||||
fn vobsub_idx_synthesis() {
|
fn vobsub_idx_synthesis() {
|
||||||
let dir = tempdir();
|
let dir = tempdir();
|
||||||
let idx = dir.join("sub.idx");
|
let idx = dir.join("sub.idx");
|
||||||
let mut w = VobSubWriter::new(idx.clone(), Some(b"palette: 000000, ffffff"));
|
let mut w = VobSubWriter::new(idx.clone(), Some(b"palette: 000000, ffffff"), "eng");
|
||||||
let mut sub = Vec::new();
|
let mut sub = Vec::new();
|
||||||
let f1 = PesFrame {
|
let f1 = PesFrame {
|
||||||
track: 0,
|
track: 0,
|
||||||
@@ -1065,6 +1096,11 @@ mod tests {
|
|||||||
w.finish(&mut sub).unwrap();
|
w.finish(&mut sub).unwrap();
|
||||||
let idx_text = std::fs::read_to_string(&idx).unwrap();
|
let idx_text = std::fs::read_to_string(&idx).unwrap();
|
||||||
assert!(idx_text.contains("palette: 000000, ffffff"));
|
assert!(idx_text.contains("palette: 000000, ffffff"));
|
||||||
|
// The conventional `id:` line mkvmerge reads to assign the language.
|
||||||
|
assert!(
|
||||||
|
idx_text.contains("id: en, index: 0"),
|
||||||
|
"missing id: line, got:\n{idx_text}"
|
||||||
|
);
|
||||||
assert!(idx_text.contains("timestamp: 00:00:00:000, filepos: 000000000"));
|
assert!(idx_text.contains("timestamp: 00:00:00:000, filepos: 000000000"));
|
||||||
// Second SPU at 1s, filepos = 10.
|
// Second SPU at 1s, filepos = 10.
|
||||||
assert!(idx_text.contains("timestamp: 00:00:01:000, filepos: 00000000a"));
|
assert!(idx_text.contains("timestamp: 00:00:01:000, filepos: 00000000a"));
|
||||||
@@ -1100,20 +1136,24 @@ mod tests {
|
|||||||
assert!(ogm.contains("CHAPTER02NAME=2"));
|
assert!(ogm.contains("CHAPTER02NAME=2"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Timeline rebase ──────────────────────────────────────────────────────
|
// ── Timeline continuity ──────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The corrector itself is tested verbatim in `crate::mux::timeline`. Here we
|
||||||
|
// only confirm the sink drives it with the right `drives_epoch`: track 0 is
|
||||||
|
// the epoch driver, every other track is a passive rider on the same offset.
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn timeline_rebase_handles_seam_jump() {
|
fn timeline_track0_drives_epoch_others_ride() {
|
||||||
let mut tl = TimelineRebase::new();
|
let mut tl = TimelineContinuity::new();
|
||||||
// Clip 1: video 0..10s.
|
// Clip 1: video 0..10s (track 0 drives the epoch).
|
||||||
assert_eq!(tl.rebase(0, 0), 0);
|
assert_eq!(tl.adjust(0, true), 0);
|
||||||
assert_eq!(tl.rebase(1, 0), 0); // audio rides the same offset
|
assert_eq!(tl.adjust(0, false), 0); // audio rides the same offset
|
||||||
assert_eq!(tl.rebase(0, 10_000_000_000), 10_000_000_000);
|
assert_eq!(tl.adjust(10_000_000_000, true), 10_000_000_000);
|
||||||
// Clip 2 seam: video PTS jumps back to ~0 (> 3s back) → new epoch.
|
// Clip 2 seam: video PTS jumps back to ~0 (> 3s back) → new epoch.
|
||||||
let out = tl.rebase(0, 0);
|
let out = tl.adjust(0, true);
|
||||||
assert!(out >= 10_000_000_000, "epoch must advance past prev high");
|
assert!(out >= 10_000_000_000, "epoch must advance past prev high");
|
||||||
// Audio in clip 2 gets the SAME offset (A/V sync preserved).
|
// Audio in clip 2 (non-epoch) gets the SAME offset (A/V sync preserved).
|
||||||
let a = tl.rebase(1, 0);
|
let a = tl.adjust(0, false);
|
||||||
assert_eq!(a, out);
|
assert_eq!(a, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -248,10 +248,105 @@ fn starts_with_start_code(data: &[u8]) -> bool {
|
|||||||
data.starts_with(&START_CODE) || data.starts_with(&[0x00, 0x00, 0x01])
|
data.starts_with(&START_CODE) || data.starts_with(&[0x00, 0x00, 0x01])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert an `AVCDecoderConfigurationRecord` (avcC) into Annex B NAL
|
||||||
|
/// units. Returns `Some(bytes)` if at least one NAL was extracted, else
|
||||||
|
/// `None`.
|
||||||
|
///
|
||||||
|
/// Layout (per ISO/IEC 14496-15 §5.3.3.1.2):
|
||||||
|
/// - 5-byte fixed header
|
||||||
|
/// - byte 5 = `[reserved:3 | numOfSequenceParameterSets:5]`
|
||||||
|
/// - `numOfSPS` × `(sequenceParameterSetLength:u16-BE + SPS bytes)`
|
||||||
|
/// - 1 byte = `numOfPictureParameterSets`
|
||||||
|
/// - `numOfPPS` × `(pictureParameterSetLength:u16-BE + PPS bytes)`
|
||||||
|
///
|
||||||
|
/// The H.264 counterpart to [`hvcc_to_annex_b`]: the single source of
|
||||||
|
/// truth for avcC → Annex B across all muxers (H.264 ES, BD-TS, standard
|
||||||
|
/// MPEG-TS, the `demux://` sink). Do not reimplement it.
|
||||||
|
pub(crate) fn avcc_to_annex_b(avcc: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
// avcC fixed header is 5 bytes; byte 5 carries the SPS count (low 5 bits),
|
||||||
|
// then the SPS array begins at byte 6 (ISO/IEC 14496-15 §5.3.3.1.2).
|
||||||
|
const AVCC_HEADER_LEN: usize = 5;
|
||||||
|
const NUM_SPS_MASK: u8 = 0x1F; // numOfSequenceParameterSets: low 5 bits
|
||||||
|
if avcc.len() < AVCC_HEADER_LEN + 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let num_sps = (avcc[AVCC_HEADER_LEN] & NUM_SPS_MASK) as usize;
|
||||||
|
let mut offset = AVCC_HEADER_LEN + 1;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
|
||||||
|
// Extract `count` length-prefixed NALs starting at `*offset` into `out`.
|
||||||
|
// Returns `false` (truncated) if a length field or NAL body runs past the
|
||||||
|
// end — the caller then stops, so it never reads further length fields out
|
||||||
|
// of mid-NAL bytes (mirrors `hvcc_to_annex_b`).
|
||||||
|
fn take(avcc: &[u8], count: usize, offset: &mut usize, out: &mut Vec<u8>) -> bool {
|
||||||
|
for _ in 0..count {
|
||||||
|
if *offset + 2 > avcc.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let nal_len = u16::from_be_bytes([avcc[*offset], avcc[*offset + 1]]) as usize;
|
||||||
|
*offset += 2;
|
||||||
|
if *offset + nal_len > avcc.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// ISO/IEC 14496-15 disallows zero-length NAL entries; emitting a
|
||||||
|
// bare start code with no RBSP yields an invalid Annex B NAL.
|
||||||
|
if nal_len == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.extend_from_slice(&START_CODE);
|
||||||
|
out.extend_from_slice(&avcc[*offset..*offset + nal_len]);
|
||||||
|
*offset += nal_len;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
let sps_ok = take(avcc, num_sps, &mut offset, &mut out);
|
||||||
|
if sps_ok && offset < avcc.len() {
|
||||||
|
let num_pps = avcc[offset] as usize;
|
||||||
|
offset += 1;
|
||||||
|
take(avcc, num_pps, &mut offset, &mut out);
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.is_empty() { None } else { Some(out) }
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn avcc_extracts_sps_and_pps() {
|
||||||
|
// header(5) numSPS=1 spsLen=2 SPS=[0x67,0x42] numPPS=1 ppsLen=1
|
||||||
|
// PPS=[0x68].
|
||||||
|
let avcc = [
|
||||||
|
1, 0x42, 0x00, 0x1F, 0xFF, 0xE1, 0, 2, 0x67, 0x42, 1, 0, 1, 0x68,
|
||||||
|
];
|
||||||
|
let out = avcc_to_annex_b(&avcc).expect("SPS+PPS");
|
||||||
|
assert_eq!(out, vec![0, 0, 0, 1, 0x67, 0x42, 0, 0, 0, 1, 0x68]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn avcc_too_short_is_none() {
|
||||||
|
assert!(avcc_to_annex_b(&[]).is_none());
|
||||||
|
assert!(avcc_to_annex_b(&[1, 0x42, 0, 0x1F, 0xFF]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn avcc_truncated_sps_stops_cleanly() {
|
||||||
|
// numSPS=1, declares spsLen=5 but only 2 bytes follow → drop it, and
|
||||||
|
// do NOT misread the trailing bytes as a PPS count.
|
||||||
|
let avcc = [1, 0x42, 0x00, 0x1F, 0xFF, 0xE1, 0, 5, 0xAA, 0xBB];
|
||||||
|
assert!(avcc_to_annex_b(&avcc).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn avcc_skips_zero_length_nal() {
|
||||||
|
// numSPS=1 spsLen=0 (skipped) numPPS=1 ppsLen=1 PPS=[0x68].
|
||||||
|
let avcc = [1, 0x42, 0x00, 0x1F, 0xFF, 0xE1, 0, 0, 1, 0, 1, 0x68];
|
||||||
|
let out = avcc_to_annex_b(&avcc).expect("just the PPS");
|
||||||
|
assert_eq!(out, vec![0, 0, 0, 1, 0x68]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn length_prefixed_converts_to_annex_b() {
|
fn length_prefixed_converts_to_annex_b() {
|
||||||
// Two NALs: [3-byte payload AA BB CC] and [2-byte payload DD EE].
|
// Two NALs: [3-byte payload AA BB CC] and [2-byte payload DD EE].
|
||||||
|
|||||||
+4
-408
@@ -5,6 +5,7 @@
|
|||||||
//! cues and seek head are finalized at the end.
|
//! cues and seek head are finalized at the end.
|
||||||
|
|
||||||
use super::ebml;
|
use super::ebml;
|
||||||
|
use super::timeline::TimelineContinuity;
|
||||||
use crate::disc::{
|
use crate::disc::{
|
||||||
AudioChannels, AudioStream, Chapter, Codec, ColorSpace, HdrFormat, Resolution, SampleRate,
|
AudioChannels, AudioStream, Chapter, Codec, ColorSpace, HdrFormat, Resolution, SampleRate,
|
||||||
SubtitleStream, VideoStream,
|
SubtitleStream, VideoStream,
|
||||||
@@ -416,156 +417,9 @@ const MAX_BLOCK_REL: i64 = i16::MAX as i64;
|
|||||||
/// Minimum block-relative timestamp expressible in the signed 16-bit field.
|
/// Minimum block-relative timestamp expressible in the signed 16-bit field.
|
||||||
const MIN_BLOCK_REL: i64 = i16::MIN as i64;
|
const MIN_BLOCK_REL: i64 = i16::MIN as i64;
|
||||||
|
|
||||||
/// A backward PTS step larger than this is treated as a clip-boundary
|
// The clip-boundary timeline-continuity corrector (`TimelineContinuity`) lives
|
||||||
/// discontinuity (a non-seamless BD clip / dual-layer-break where the source
|
// in `crate::mux::timeline` — shared verbatim with the `demux://` sink. It is
|
||||||
/// PES PTS resets), NOT as B-frame reorder. HEVC/H.264 reorder depth tops out
|
// imported below where the muxer uses it.
|
||||||
/// around 16 frames (<1s at 24 fps); 3s sits comfortably above any legitimate
|
|
||||||
/// reorder window and far below any real clip's duration, so it never
|
|
||||||
/// false-triggers within a clip.
|
|
||||||
const DISCONTINUITY_BACKSTEP_NS: i64 = 3_000_000_000;
|
|
||||||
/// Sub-frame gap inserted after a rebased discontinuity so the first frame of
|
|
||||||
/// the new clip lands strictly after the previous timeline high (1 ms).
|
|
||||||
const DISCONTINUITY_GAP_NS: i64 = 1_000_000;
|
|
||||||
|
|
||||||
/// Global timeline-continuity corrector. freemkv reads a BD title's clips as
|
|
||||||
/// one concatenated sector stream (clip boundaries / mpls connection_condition
|
|
||||||
/// are not plumbed to the mux), so at a non-seamless boundary the source PES
|
|
||||||
/// PTS jumps backward. Left uncorrected, that produces a sustained band of
|
|
||||||
/// non-monotonic block timestamps (ffmpeg then derives non-monotonic DTS).
|
|
||||||
///
|
|
||||||
/// A single running `offset_ns` is applied to EVERY track, so the concatenated
|
|
||||||
/// clips form one monotonic timeline AND A/V sync is preserved (all tracks at a
|
|
||||||
/// boundary shift by the same amount). It is global, not per-track: a clip
|
|
||||||
/// boundary resets every stream together by the same delta.
|
|
||||||
///
|
|
||||||
/// **Only the VIDEO track drives epoch decisions.** A title carries one video
|
|
||||||
/// track plus many interleaved audio + subtitle tracks (Top Gun UHD: 2 video,
|
|
||||||
/// 11 audio, 32 PGS). Those non-video tracks are sparse and lag the video by
|
|
||||||
/// seconds, so their raw PTS swing well over the 3 s discontinuity threshold
|
|
||||||
/// against a shared frontier even within a SINGLE clip — a late subtitle PTS
|
|
||||||
/// would ratchet `high_ns` up, then the next normal video frame would sit >3 s
|
|
||||||
/// below it and be misread as a clip boundary, permanently bumping `offset_ns`.
|
|
||||||
/// That false-positive ratchet (firing thousands of times on a one-clip title)
|
|
||||||
/// inflated Top Gun's cluster/Cue timestamps into the billions of ms and
|
|
||||||
/// destroyed its seek index. The clip-boundary INFERENCE is therefore keyed on
|
|
||||||
/// video PTS alone: video establishes and advances the frontier and is the only
|
|
||||||
/// track that can open a new epoch. Non-video frames are remapped under the
|
|
||||||
/// CURRENT offset and never touch the frontier or the offset — they ride the
|
|
||||||
/// timeline the video defines, preserving A/V sync (all tracks at a boundary
|
|
||||||
/// shift by the same delta) without ever triggering a rebase themselves.
|
|
||||||
///
|
|
||||||
/// The demuxer interleaves the tracks, so at a real (multi-clip) boundary the
|
|
||||||
/// streams do NOT all reset on the same frame — a lagging audio/PGS frame from
|
|
||||||
/// the just-ended clip's tail can arrive AFTER the next clip's video has already
|
|
||||||
/// reset the epoch. Such a "straggler" carries an old-epoch raw PTS; adding the
|
|
||||||
/// new (clip-sized) offset to it would fling it far past the frontier and force
|
|
||||||
/// a forward-dated split cluster. A non-video frame whose mapped position lands
|
|
||||||
/// more than a backstep past the frontier is therefore clamped to the frontier
|
|
||||||
/// (the seam) — it never perturbs the offset or the frontier and never
|
|
||||||
/// forward-dates a cluster. Genuine multi-clip seamless rebasing (the design
|
|
||||||
/// that is correct for real HEVC/H.264 multi-clip titles) is preserved: it is
|
|
||||||
/// the video back-jump that opens a new epoch, exactly as before.
|
|
||||||
struct TimelineContinuity {
|
|
||||||
/// Offset (ns) added to raw PTS for the CURRENT epoch.
|
|
||||||
offset_ns: i64,
|
|
||||||
/// Offset (ns) of the immediately previous epoch — used to recognise and
|
|
||||||
/// remap a non-video tail straggler at a boundary (an old-epoch frame whose
|
|
||||||
/// current-offset mapping flies forward but whose previous-offset mapping
|
|
||||||
/// lands at the seam). Equals `offset_ns` until the first boundary.
|
|
||||||
prev_offset_ns: i64,
|
|
||||||
/// Highest adjusted VIDEO PTS (ns) accepted onto the timeline so far — the
|
|
||||||
/// running frontier. `None` until the first video frame. Only video advances
|
|
||||||
/// it; non-video tracks never touch it.
|
|
||||||
high_ns: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TimelineContinuity {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
offset_ns: 0,
|
|
||||||
prev_offset_ns: 0,
|
|
||||||
high_ns: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a raw PES PTS (ns) onto the continuous output timeline.
|
|
||||||
///
|
|
||||||
/// `drives_epoch` gates EVERY epoch decision. It is `true` for the PRIMARY
|
|
||||||
/// video track (base layer, track 0) ONLY. Every other track — audio, PGS
|
|
||||||
/// subtitle, and a second video track such as a Dolby Vision enhancement
|
|
||||||
/// layer — passes `false` and is a passive rider. (The DV EL is video but
|
|
||||||
/// runs its own PTS timeline interleaved with the base layer's; letting it
|
|
||||||
/// drive epochs would false-trigger a reset on every GOP.)
|
|
||||||
///
|
|
||||||
/// **Passive tracks** (`drives_epoch == false`). Always remapped under the
|
|
||||||
/// CURRENT offset. They never advance `high_ns`, never trigger a clip-boundary
|
|
||||||
/// reset, and never bump `offset_ns`. This is what kills the single-clip
|
|
||||||
/// ratchet: a sparse/lagging subtitle/audio PTS, or an interleaved EL frame,
|
|
||||||
/// can no longer push the frontier up and make the next base-video frame look
|
|
||||||
/// like a boundary. A/V sync is preserved because the offset they ride is the
|
|
||||||
/// same one the base video established for the epoch.
|
|
||||||
///
|
|
||||||
/// **Primary video** (`drives_epoch == true`):
|
|
||||||
/// - **Backward jump > `DISCONTINUITY_BACKSTEP_NS`** vs the frontier =
|
|
||||||
/// clip-boundary reset: open a new epoch (bump the offset so this frame
|
|
||||||
/// continues just after the frontier). This is the genuine multi-clip
|
|
||||||
/// seamless rebasing, now driven only by real base-video back-jumps.
|
|
||||||
/// - **Everything else** (normal progression + sub-threshold B-frame reorder
|
|
||||||
/// dips) passes through with the current offset and advances the frontier,
|
|
||||||
/// preserving PTS.
|
|
||||||
fn adjust(&mut self, raw_pts_ns: i64, drives_epoch: bool) -> i64 {
|
|
||||||
// Passive track: ride the current epoch's offset. Never advance the
|
|
||||||
// frontier and never open an epoch — these tracks each run on their own
|
|
||||||
// (sparse/laggy/independent) timeline and would false-trigger the ratchet.
|
|
||||||
if !drives_epoch {
|
|
||||||
let mapped = raw_pts_ns.saturating_add(self.offset_ns);
|
|
||||||
// Tail-straggler remap: at a REAL (base-video-driven) multi-clip
|
|
||||||
// boundary the offset has just jumped forward by ~a whole clip, but a
|
|
||||||
// lagging tail frame from the just-ended clip still carries an
|
|
||||||
// OLD-epoch raw PTS. Adding the NEW offset flings it ~a clip past the
|
|
||||||
// frontier and would force a forward-dated split cluster (breaking
|
|
||||||
// cluster monotonicity). Such a straggler is recognised precisely: its
|
|
||||||
// current-offset mapping lands more than a backstep PAST the frontier
|
|
||||||
// AND its PREVIOUS-offset mapping lands at/below the frontier (i.e. it
|
|
||||||
// belongs to the prior epoch). Remap it with the previous offset so
|
|
||||||
// it lands at its true seam position. This is what distinguishes a
|
|
||||||
// tail straggler from a frame that legitimately runs ahead of the
|
|
||||||
// (base-video-only) frontier — a long audio-only tail, a sparse
|
|
||||||
// subtitle, or an EL frame — which is left on the current offset.
|
|
||||||
if let Some(high) = self.high_ns {
|
|
||||||
if mapped > high + DISCONTINUITY_BACKSTEP_NS {
|
|
||||||
let prev_mapped = raw_pts_ns.saturating_add(self.prev_offset_ns);
|
|
||||||
if prev_mapped <= high {
|
|
||||||
return prev_mapped;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mapped;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(high) = self.high_ns else {
|
|
||||||
let adj = raw_pts_ns.saturating_add(self.offset_ns);
|
|
||||||
self.high_ns = Some(adj);
|
|
||||||
return adj;
|
|
||||||
};
|
|
||||||
let adj = raw_pts_ns.saturating_add(self.offset_ns);
|
|
||||||
if adj < high - DISCONTINUITY_BACKSTEP_NS {
|
|
||||||
// Clip-boundary reset (real multi-clip seam): continue just after the
|
|
||||||
// frontier. Save the previous offset so a lagging non-video tail
|
|
||||||
// frame can be recognised and remapped to the seam (see above).
|
|
||||||
self.prev_offset_ns = self.offset_ns;
|
|
||||||
let bump = (high - adj).saturating_add(DISCONTINUITY_GAP_NS);
|
|
||||||
self.offset_ns = self.offset_ns.saturating_add(bump);
|
|
||||||
let adj2 = raw_pts_ns.saturating_add(self.offset_ns);
|
|
||||||
self.high_ns = Some(high.max(adj2));
|
|
||||||
adj2
|
|
||||||
} else {
|
|
||||||
// Normal progression / sub-threshold B-frame reorder: keep true PTS.
|
|
||||||
self.high_ns = Some(high.max(adj));
|
|
||||||
adj
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Force a per-track block timestamp (in TimestampScale ticks) to be strictly
|
/// Force a per-track block timestamp (in TimestampScale ticks) to be strictly
|
||||||
/// later than the previous one written for that track. `prev` is the last
|
/// later than the previous one written for that track. `prev` is the last
|
||||||
@@ -1648,264 +1502,6 @@ mod tests {
|
|||||||
assert_eq!(block_ts(mux.track_is_video[1], Some(1040), 1000), 1000);
|
assert_eq!(block_ts(mux.track_is_video[1], Some(1040), 1000), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Clip-boundary timeline-continuity (PTS discontinuity rebasing) ──
|
|
||||||
|
|
||||||
const S: i64 = 1_000_000_000; // 1 second in ns
|
|
||||||
|
|
||||||
// Convenience: a video frame drives epoch decisions; non-video rides the
|
|
||||||
// current offset. These wrappers make the test intent explicit.
|
|
||||||
fn adj_video(tc: &mut TimelineContinuity, p: i64) -> i64 {
|
|
||||||
tc.adjust(p, true)
|
|
||||||
}
|
|
||||||
fn adj_other(tc: &mut TimelineContinuity, p: i64) -> i64 {
|
|
||||||
tc.adjust(p, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Characterization of the BUG: a BD title's two clips concatenated with a
|
|
||||||
/// PTS reset at the boundary. WITHOUT correction the raw VIDEO timeline goes
|
|
||||||
/// hard backward at clip 2 (what produced the non-monotonic-DTS band on
|
|
||||||
/// Dune / Top Gun). WITH `TimelineContinuity` the output is monotonic and
|
|
||||||
/// continuous across the boundary. The boundary is driven by VIDEO.
|
|
||||||
#[test]
|
|
||||||
fn continuity_rebases_clip_boundary_reset() {
|
|
||||||
// Clip1 video rising to 10s, then clip2 RESETS near 0 — non-seamless.
|
|
||||||
let clip1: Vec<i64> = (0..=10).map(|i| i * S).collect(); // 0..10s
|
|
||||||
let clip2: Vec<i64> = (0..=10).map(|i| i * S).collect(); // resets to 0..10s
|
|
||||||
let raw: Vec<i64> = clip1.iter().chain(clip2.iter()).copied().collect();
|
|
||||||
|
|
||||||
// Uncorrected (the bug): the sequence is NOT monotonic — clip2's first
|
|
||||||
// frame (0) is 10s below clip1's last (10s).
|
|
||||||
assert!(
|
|
||||||
raw.windows(2).any(|w| w[1] < w[0]),
|
|
||||||
"precondition: raw clip-reset sequence is non-monotonic"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Corrected: strictly non-decreasing, and clip2 continues AFTER clip1.
|
|
||||||
let mut tc = TimelineContinuity::new();
|
|
||||||
let out: Vec<i64> = raw.iter().map(|&p| adj_video(&mut tc, p)).collect();
|
|
||||||
assert!(
|
|
||||||
out.windows(2).all(|w| w[1] >= w[0]),
|
|
||||||
"corrected timeline must be monotonic non-decreasing, got {out:?}"
|
|
||||||
);
|
|
||||||
// Clip2's first frame lands just after clip1's last (10s) + the gap.
|
|
||||||
assert_eq!(out[11], 10 * S + DISCONTINUITY_GAP_NS);
|
|
||||||
// Clip2's last frame is offset by the whole of clip1, not back near 0.
|
|
||||||
assert!(out[21] > 19 * S);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression guard: NORMAL B-frame reorder (a small backward dip, well
|
|
||||||
/// under the discontinuity threshold) on VIDEO must pass through UNCHANGED.
|
|
||||||
#[test]
|
|
||||||
fn continuity_preserves_bframe_reorder() {
|
|
||||||
let mut tc = TimelineContinuity::new();
|
|
||||||
// I, P(+3 frames), B, B, B — presentation PTS dips backward by ~2
|
|
||||||
// frames (~83ms), far under the 3s threshold.
|
|
||||||
let raw = [0i64, 125_000_000, 42_000_000, 83_000_000, 250_000_000];
|
|
||||||
let out: Vec<i64> = raw.iter().map(|&p| adj_video(&mut tc, p)).collect();
|
|
||||||
assert_eq!(out, raw, "B-frame reorder must pass through unchanged");
|
|
||||||
assert_eq!(tc.offset_ns, 0, "no rebase for sub-threshold reorder");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A legitimate FORWARD gap (a real timing gap within a clip) on VIDEO must
|
|
||||||
/// be PRESERVED, not clamped — only backward video clip-boundary jumps are
|
|
||||||
/// rebased.
|
|
||||||
#[test]
|
|
||||||
fn continuity_preserves_forward_gap() {
|
|
||||||
let mut tc = TimelineContinuity::new();
|
|
||||||
let raw = [0i64, S, 2 * S + 500_000_000, 4 * S]; // a 1.5s gap mid-stream
|
|
||||||
let out: Vec<i64> = raw.iter().map(|&p| adj_video(&mut tc, p)).collect();
|
|
||||||
assert_eq!(out, raw, "forward gap preserved verbatim");
|
|
||||||
assert_eq!(tc.offset_ns, 0, "no rebase on forward progression");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PRIMARY rc3 regression: a sparse, lagging NON-VIDEO track (PGS subtitle /
|
|
||||||
/// trailing audio) on a SINGLE-clip title must NOT inflate `offset_ns`. This
|
|
||||||
/// is the exact false-positive that destroyed Top Gun's seek index: with a
|
|
||||||
/// shared frontier, a late subtitle PTS ratcheted the frontier up, then the
|
|
||||||
/// next normal video frame sat >3s below it and was misread as a clip
|
|
||||||
/// boundary, permanently bumping the offset — thousands of times, until the
|
|
||||||
/// Cue/cluster timestamps inflated into the billions of ms.
|
|
||||||
///
|
|
||||||
/// Correct behaviour: non-video frames ride the current offset and NEVER
|
|
||||||
/// touch the frontier or the offset, so no amount of subtitle/audio lag can
|
|
||||||
/// trigger a rebase on a one-clip title.
|
|
||||||
#[test]
|
|
||||||
fn single_clip_late_subtitle_does_not_inflate_offset() {
|
|
||||||
let mut tc = TimelineContinuity::new();
|
|
||||||
// One continuous clip: video advances steadily 0..60s.
|
|
||||||
// Interleaved, a subtitle track is sparse — it emits a cue at 0s, then
|
|
||||||
// nothing for a long stretch, then a late cue, then jumps around. Each
|
|
||||||
// subtitle PTS swings many seconds against the video frontier.
|
|
||||||
// Drive a realistic interleave.
|
|
||||||
let mut max_out = i64::MIN;
|
|
||||||
for sec in 0..=60 {
|
|
||||||
// Video frame every second.
|
|
||||||
let v = adj_video(&mut tc, sec * S);
|
|
||||||
max_out = max_out.max(v);
|
|
||||||
// Every 7th second, a subtitle appears whose raw PTS lags the video
|
|
||||||
// frontier by ~5s (a late display-set delivered by the interleaver)
|
|
||||||
// — far more than the 3s discontinuity threshold.
|
|
||||||
if sec % 7 == 0 && sec >= 7 {
|
|
||||||
let sub_raw = (sec - 5) * S;
|
|
||||||
let s = adj_other(&mut tc, sub_raw);
|
|
||||||
// The subtitle maps under the current (zero) offset, near its
|
|
||||||
// true time — it does NOT fling the timeline forward.
|
|
||||||
assert_eq!(s, sub_raw, "subtitle rides the current offset");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The crux: a single-clip title must NEVER open an epoch. Offset stays 0
|
|
||||||
// and the timeline never inflates.
|
|
||||||
assert_eq!(
|
|
||||||
tc.offset_ns, 0,
|
|
||||||
"single-clip interleave must not ratchet offset (was {})",
|
|
||||||
tc.offset_ns
|
|
||||||
);
|
|
||||||
// And the video frontier is exactly 60s — not billions.
|
|
||||||
assert_eq!(tc.high_ns, Some(60 * S), "frontier tracks video only");
|
|
||||||
assert!(max_out <= 60 * S, "no timeline inflation, max={max_out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PRIMARY rc3 regression (Dolby Vision dual-layer): a SECOND video track —
|
|
||||||
/// the DV enhancement layer — runs its OWN PTS timeline interleaved with the
|
|
||||||
/// base layer's, so the two video PTS sequences OVERLAP. The EL must be a
|
|
||||||
/// PASSIVE rider (drives_epoch == false): if it drove epochs, every EL GOP
|
|
||||||
/// would look like a multi-second backward jump against the base-layer
|
|
||||||
/// frontier and false-trigger a clip-boundary reset — the exact ratchet that
|
|
||||||
/// inflated Top Gun's 1-clip 1h49m timeline to ~7 h. Here the base layer
|
|
||||||
/// advances 0..60s while the EL re-emits the SAME 0..60s interleaved; the
|
|
||||||
/// timeline must stay at 60s with offset 0.
|
|
||||||
#[test]
|
|
||||||
fn dv_enhancement_layer_does_not_drive_epochs() {
|
|
||||||
let mut tc = TimelineContinuity::new();
|
|
||||||
let mut max_out = i64::MIN;
|
|
||||||
for sec in 0..=60 {
|
|
||||||
// Base layer (track 0) drives the epoch.
|
|
||||||
let bl = adj_video(&mut tc, sec * S);
|
|
||||||
// EL (track 1) re-emits the same time — a passive rider. Its raw PTS
|
|
||||||
// equals the base layer's, but it arrives just AFTER the base frame
|
|
||||||
// for the NEXT second sometimes; simulate the overlap by feeding the
|
|
||||||
// PREVIOUS second's time, which is a backward swing vs the frontier.
|
|
||||||
let el_raw = if sec > 0 { (sec - 1) * S } else { 0 };
|
|
||||||
let el = adj_other(&mut tc, el_raw);
|
|
||||||
assert_eq!(el, el_raw, "EL rides current offset, true PTS preserved");
|
|
||||||
max_out = max_out.max(bl).max(el);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
tc.offset_ns, 0,
|
|
||||||
"DV EL interleave must not ratchet offset (was {})",
|
|
||||||
tc.offset_ns
|
|
||||||
);
|
|
||||||
assert_eq!(tc.high_ns, Some(60 * S), "frontier tracks base video only");
|
|
||||||
assert!(max_out <= 60 * S, "no timeline inflation, max={max_out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Companion: a non-video frame must never ADVANCE the frontier. Even a
|
|
||||||
/// non-video PTS far ABOVE the current video frontier (a subtitle/audio
|
|
||||||
/// timestamp that leads the video momentarily) leaves `high_ns` untouched,
|
|
||||||
/// so a subsequent normal video frame is not misread as a boundary.
|
|
||||||
#[test]
|
|
||||||
fn non_video_never_advances_frontier() {
|
|
||||||
let mut tc = TimelineContinuity::new();
|
|
||||||
adj_video(&mut tc, 0);
|
|
||||||
adj_video(&mut tc, 5 * S);
|
|
||||||
let frontier = tc.high_ns.unwrap();
|
|
||||||
// A subtitle leading the video by 20s.
|
|
||||||
let s = adj_other(&mut tc, 25 * S);
|
|
||||||
assert_eq!(s, 25 * S, "non-video maps under current offset");
|
|
||||||
assert_eq!(
|
|
||||||
tc.high_ns.unwrap(),
|
|
||||||
frontier,
|
|
||||||
"non-video must NOT advance the frontier"
|
|
||||||
);
|
|
||||||
// The next normal video frame (6s) is well below 25s but is NOT treated
|
|
||||||
// as a boundary, because the frontier is still 5s (video-only).
|
|
||||||
let v = adj_video(&mut tc, 6 * S);
|
|
||||||
assert_eq!(v, 6 * S, "video continues normally, no false boundary");
|
|
||||||
assert_eq!(
|
|
||||||
tc.offset_ns, 0,
|
|
||||||
"no rebase triggered by the leading subtitle"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression for the original Top Gun band: a LARGE, real-magnitude
|
|
||||||
/// clip-boundary back-jump on VIDEO (clip 1 ≈ 13 min, clip 2 resets to 0)
|
|
||||||
/// must STILL be rebased to one continuous monotonic timeline — the genuine
|
|
||||||
/// multi-clip seamless behaviour is preserved, now keyed on real video
|
|
||||||
/// back-jumps.
|
|
||||||
#[test]
|
|
||||||
fn continuity_large_clip_boundary_backjump_rebased() {
|
|
||||||
let mut tc = TimelineContinuity::new();
|
|
||||||
// Clip 1: 0 .. 780s (13 min) at 1s steps.
|
|
||||||
let clip1: Vec<i64> = (0..=780).map(|i| i * S).collect();
|
|
||||||
// Clip 2: resets to 0 .. 120s — the ~ -780s discontinuity.
|
|
||||||
let clip2: Vec<i64> = (0..=120).map(|i| i * S).collect();
|
|
||||||
let mut last = i64::MIN;
|
|
||||||
let mut max = i64::MIN;
|
|
||||||
for &p in clip1.iter().chain(clip2.iter()) {
|
|
||||||
let a = adj_video(&mut tc, p);
|
|
||||||
assert!(
|
|
||||||
a >= last,
|
|
||||||
"rebased timeline must be monotonic, got {a} < {last}"
|
|
||||||
);
|
|
||||||
last = a;
|
|
||||||
max = max.max(a);
|
|
||||||
}
|
|
||||||
// Offset ≈ the whole of clip 1 (one boundary, no ratchet).
|
|
||||||
assert_eq!(tc.offset_ns, 780 * S + DISCONTINUITY_GAP_NS);
|
|
||||||
// Timeline spans clip1+clip2 (~900s), proving clip 2 is reachable past
|
|
||||||
// the boundary — not capped at it, and not ratcheted far beyond.
|
|
||||||
assert!(
|
|
||||||
(900 * S..901 * S).contains(&max),
|
|
||||||
"timeline must span ~900s (clip1+clip2), got {max}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// At a REAL video-driven boundary, a lagging NON-VIDEO tail frame from the
|
|
||||||
/// just-ended clip (an old-epoch raw PTS arriving interleaved after the
|
|
||||||
/// reset) must be REMAPPED to its true seam position with the PREVIOUS
|
|
||||||
/// offset — not flung ~a clip past the frontier by the freshly-bumped
|
|
||||||
/// offset. Otherwise it would force a forward-dated split cluster and break
|
|
||||||
/// cluster monotonicity.
|
|
||||||
#[test]
|
|
||||||
fn non_video_straggler_remapped_to_seam_at_boundary() {
|
|
||||||
let mut tc = TimelineContinuity::new();
|
|
||||||
// Clip1 video rises to 600s.
|
|
||||||
for i in 0..=600 {
|
|
||||||
adj_video(&mut tc, i * S);
|
|
||||||
}
|
|
||||||
let frontier = tc.high_ns.unwrap();
|
|
||||||
assert_eq!(frontier, 600 * S);
|
|
||||||
// Clip2 video resets to 0 → boundary, offset bumps by ~600s.
|
|
||||||
let c2 = adj_video(&mut tc, 0);
|
|
||||||
assert_eq!(c2, 600 * S + DISCONTINUITY_GAP_NS);
|
|
||||||
// Straggler: clip1's tail audio (raw 599.5s) arrives now. Under the new
|
|
||||||
// offset it would map to ~1199.5s; it must instead remap with the
|
|
||||||
// previous (zero) offset to its true seam position 599.5s.
|
|
||||||
let straggler_raw = 599 * S + 500_000_000;
|
|
||||||
let straggler = adj_other(&mut tc, straggler_raw);
|
|
||||||
assert_eq!(
|
|
||||||
straggler, straggler_raw,
|
|
||||||
"straggler must remap to its seam position via the previous offset"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
straggler <= frontier,
|
|
||||||
"straggler must land at/below the frontier, got {straggler}"
|
|
||||||
);
|
|
||||||
// It must NOT have perturbed the offset or the frontier.
|
|
||||||
assert_eq!(
|
|
||||||
tc.high_ns.unwrap(),
|
|
||||||
c2,
|
|
||||||
"straggler must not move the frontier"
|
|
||||||
);
|
|
||||||
// A NORMAL clip2 audio frame (raw ~1s, current epoch) is NOT remapped —
|
|
||||||
// it rides the new offset to ~601s, just past the frontier but within a
|
|
||||||
// backstep (its previous-offset mapping ~1s is below the frontier but the
|
|
||||||
// current-offset mapping is not a backstep past it, so it is not treated
|
|
||||||
// as a straggler).
|
|
||||||
let normal = adj_other(&mut tc, S);
|
|
||||||
assert_eq!(normal, S + 600 * S + DISCONTINUITY_GAP_NS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// End-to-end output regression (the symptom, at the block-timecode level):
|
/// End-to-end output regression (the symptom, at the block-timecode level):
|
||||||
/// a large clip-boundary reset WITH an interleaved straggler audio frame
|
/// a large clip-boundary reset WITH an interleaved straggler audio frame
|
||||||
/// from clip 1's tail, driven through the full muxer. Asserts cluster
|
/// from clip 1's tail, driven through the full muxer. Asserts cluster
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ pub(crate) mod network;
|
|||||||
pub(crate) mod null;
|
pub(crate) mod null;
|
||||||
pub(crate) mod ps;
|
pub(crate) mod ps;
|
||||||
pub(crate) mod stdio;
|
pub(crate) mod stdio;
|
||||||
|
/// Shared clip-boundary timeline-continuity corrector (used by the MKV muxer
|
||||||
|
/// and the `demux://` sink).
|
||||||
|
pub(crate) mod timeline;
|
||||||
pub(crate) mod ts;
|
pub(crate) mod ts;
|
||||||
pub(crate) mod tsmux;
|
pub(crate) mod tsmux;
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -466,10 +466,17 @@ pub fn output(
|
|||||||
// `output()` call with the default option set.
|
// `output()` call with the default option set.
|
||||||
StreamUrl::Demux { ref dir } => {
|
StreamUrl::Demux { ref dir } => {
|
||||||
validate_file_path(dir, "demux")?;
|
validate_file_path(dir, "demux")?;
|
||||||
|
// The full `--demux/--naming/--delay/--container/--chapters` flag
|
||||||
|
// surface is parsed in the CLI, which constructs `DemuxSink` directly.
|
||||||
|
// This bare `output()` arm uses defaults but still seeds the filename
|
||||||
|
// `base` from the title's playlist name when present (the default
|
||||||
|
// "title" stem is only a last resort for an unnamed title).
|
||||||
|
let mut opts = super::demux_sink::DemuxOptions::default();
|
||||||
|
if !title.playlist.is_empty() {
|
||||||
|
opts.base = title.playlist.clone();
|
||||||
|
}
|
||||||
Ok(Box::new(super::demux_sink::DemuxSink::create(
|
Ok(Box::new(super::demux_sink::DemuxSink::create(
|
||||||
dir,
|
dir, title, &opts,
|
||||||
title,
|
|
||||||
&super::demux_sink::DemuxOptions::default(),
|
|
||||||
)?))
|
)?))
|
||||||
}
|
}
|
||||||
StreamUrl::Unknown { ref raw } => {
|
StreamUrl::Unknown { ref raw } => {
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
//! Shared clip-boundary timeline-continuity corrector.
|
||||||
|
//!
|
||||||
|
//! A BD/UHD title's clips are read as one concatenated sector stream (clip
|
||||||
|
//! boundaries / mpls connection_condition are not plumbed to the mux), so at a
|
||||||
|
//! non-seamless boundary the source PES PTS jumps backward. Left uncorrected,
|
||||||
|
//! that produces a sustained band of non-monotonic block timestamps. Every
|
||||||
|
//! muxer/sink that consumes the interleaved per-track PES stream and emits a
|
||||||
|
//! monotonic timeline (the MKV muxer, the `demux://` elementary-stream sink)
|
||||||
|
//! uses [`TimelineContinuity`] so the correction lives in exactly one place.
|
||||||
|
|
||||||
|
/// A backward PTS step larger than this is treated as a clip-boundary
|
||||||
|
/// discontinuity (a non-seamless BD clip / dual-layer-break where the source
|
||||||
|
/// PES PTS resets), NOT as B-frame reorder. HEVC/H.264 reorder depth tops out
|
||||||
|
/// around 16 frames (<1s at 24 fps); 3s sits comfortably above any legitimate
|
||||||
|
/// reorder window and far below any real clip's duration, so it never
|
||||||
|
/// false-triggers within a clip.
|
||||||
|
pub(crate) const DISCONTINUITY_BACKSTEP_NS: i64 = 3_000_000_000;
|
||||||
|
/// Sub-frame gap inserted after a rebased discontinuity so the first frame of
|
||||||
|
/// the new clip lands strictly after the previous timeline high (1 ms).
|
||||||
|
pub(crate) const DISCONTINUITY_GAP_NS: i64 = 1_000_000;
|
||||||
|
|
||||||
|
/// Global timeline-continuity corrector. freemkv reads a BD title's clips as
|
||||||
|
/// one concatenated sector stream (clip boundaries / mpls connection_condition
|
||||||
|
/// are not plumbed to the mux), so at a non-seamless boundary the source PES
|
||||||
|
/// PTS jumps backward. Left uncorrected, that produces a sustained band of
|
||||||
|
/// non-monotonic block timestamps (ffmpeg then derives non-monotonic DTS).
|
||||||
|
///
|
||||||
|
/// A single running `offset_ns` is applied to EVERY track, so the concatenated
|
||||||
|
/// clips form one monotonic timeline AND A/V sync is preserved (all tracks at a
|
||||||
|
/// boundary shift by the same amount). It is global, not per-track: a clip
|
||||||
|
/// boundary resets every stream together by the same delta.
|
||||||
|
///
|
||||||
|
/// **Only the VIDEO track drives epoch decisions.** A title carries one video
|
||||||
|
/// track plus many interleaved audio + subtitle tracks (Top Gun UHD: 2 video,
|
||||||
|
/// 11 audio, 32 PGS). Those non-video tracks are sparse and lag the video by
|
||||||
|
/// seconds, so their raw PTS swing well over the 3 s discontinuity threshold
|
||||||
|
/// against a shared frontier even within a SINGLE clip — a late subtitle PTS
|
||||||
|
/// would ratchet `high_ns` up, then the next normal video frame would sit >3 s
|
||||||
|
/// below it and be misread as a clip boundary, permanently bumping `offset_ns`.
|
||||||
|
/// That false-positive ratchet (firing thousands of times on a one-clip title)
|
||||||
|
/// inflated Top Gun's cluster/Cue timestamps into the billions of ms and
|
||||||
|
/// destroyed its seek index. The clip-boundary INFERENCE is therefore keyed on
|
||||||
|
/// video PTS alone: video establishes and advances the frontier and is the only
|
||||||
|
/// track that can open a new epoch. Non-video frames are remapped under the
|
||||||
|
/// CURRENT offset and never touch the frontier or the offset — they ride the
|
||||||
|
/// timeline the video defines, preserving A/V sync (all tracks at a boundary
|
||||||
|
/// shift by the same delta) without ever triggering a rebase themselves.
|
||||||
|
///
|
||||||
|
/// The demuxer interleaves the tracks, so at a real (multi-clip) boundary the
|
||||||
|
/// streams do NOT all reset on the same frame — a lagging audio/PGS frame from
|
||||||
|
/// the just-ended clip's tail can arrive AFTER the next clip's video has already
|
||||||
|
/// reset the epoch. Such a "straggler" carries an old-epoch raw PTS; adding the
|
||||||
|
/// new (clip-sized) offset to it would fling it far past the frontier and force
|
||||||
|
/// a forward-dated split cluster. A non-video frame whose mapped position lands
|
||||||
|
/// more than a backstep past the frontier is therefore clamped to the frontier
|
||||||
|
/// (the seam) — it never perturbs the offset or the frontier and never
|
||||||
|
/// forward-dates a cluster. Genuine multi-clip seamless rebasing (the design
|
||||||
|
/// that is correct for real HEVC/H.264 multi-clip titles) is preserved: it is
|
||||||
|
/// the video back-jump that opens a new epoch, exactly as before.
|
||||||
|
pub(crate) struct TimelineContinuity {
|
||||||
|
/// Offset (ns) added to raw PTS for the CURRENT epoch.
|
||||||
|
pub(crate) offset_ns: i64,
|
||||||
|
/// Offset (ns) of the immediately previous epoch — used to recognise and
|
||||||
|
/// remap a non-video tail straggler at a boundary (an old-epoch frame whose
|
||||||
|
/// current-offset mapping flies forward but whose previous-offset mapping
|
||||||
|
/// lands at the seam). Equals `offset_ns` until the first boundary.
|
||||||
|
pub(crate) prev_offset_ns: i64,
|
||||||
|
/// Highest adjusted VIDEO PTS (ns) accepted onto the timeline so far — the
|
||||||
|
/// running frontier. `None` until the first video frame. Only video advances
|
||||||
|
/// it; non-video tracks never touch it.
|
||||||
|
pub(crate) high_ns: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimelineContinuity {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
offset_ns: 0,
|
||||||
|
prev_offset_ns: 0,
|
||||||
|
high_ns: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a raw PES PTS (ns) onto the continuous output timeline.
|
||||||
|
///
|
||||||
|
/// `drives_epoch` gates EVERY epoch decision. It is `true` for the PRIMARY
|
||||||
|
/// video track (base layer, track 0) ONLY. Every other track — audio, PGS
|
||||||
|
/// subtitle, and a second video track such as a Dolby Vision enhancement
|
||||||
|
/// layer — passes `false` and is a passive rider. (The DV EL is video but
|
||||||
|
/// runs its own PTS timeline interleaved with the base layer's; letting it
|
||||||
|
/// drive epochs would false-trigger a reset on every GOP.)
|
||||||
|
///
|
||||||
|
/// **Passive tracks** (`drives_epoch == false`). Always remapped under the
|
||||||
|
/// CURRENT offset. They never advance `high_ns`, never trigger a clip-boundary
|
||||||
|
/// reset, and never bump `offset_ns`. This is what kills the single-clip
|
||||||
|
/// ratchet: a sparse/lagging subtitle/audio PTS, or an interleaved EL frame,
|
||||||
|
/// can no longer push the frontier up and make the next base-video frame look
|
||||||
|
/// like a boundary. A/V sync is preserved because the offset they ride is the
|
||||||
|
/// same one the base video established for the epoch.
|
||||||
|
///
|
||||||
|
/// **Primary video** (`drives_epoch == true`):
|
||||||
|
/// - **Backward jump > `DISCONTINUITY_BACKSTEP_NS`** vs the frontier =
|
||||||
|
/// clip-boundary reset: open a new epoch (bump the offset so this frame
|
||||||
|
/// continues just after the frontier). This is the genuine multi-clip
|
||||||
|
/// seamless rebasing, now driven only by real base-video back-jumps.
|
||||||
|
/// - **Everything else** (normal progression + sub-threshold B-frame reorder
|
||||||
|
/// dips) passes through with the current offset and advances the frontier,
|
||||||
|
/// preserving PTS.
|
||||||
|
pub(crate) fn adjust(&mut self, raw_pts_ns: i64, drives_epoch: bool) -> i64 {
|
||||||
|
// Passive track: ride the current epoch's offset. Never advance the
|
||||||
|
// frontier and never open an epoch — these tracks each run on their own
|
||||||
|
// (sparse/laggy/independent) timeline and would false-trigger the ratchet.
|
||||||
|
if !drives_epoch {
|
||||||
|
let mapped = raw_pts_ns.saturating_add(self.offset_ns);
|
||||||
|
// Tail-straggler remap: at a REAL (base-video-driven) multi-clip
|
||||||
|
// boundary the offset has just jumped forward by ~a whole clip, but a
|
||||||
|
// lagging tail frame from the just-ended clip still carries an
|
||||||
|
// OLD-epoch raw PTS. Adding the NEW offset flings it ~a clip past the
|
||||||
|
// frontier and would force a forward-dated split cluster (breaking
|
||||||
|
// cluster monotonicity). Such a straggler is recognised precisely: its
|
||||||
|
// current-offset mapping lands more than a backstep PAST the frontier
|
||||||
|
// AND its PREVIOUS-offset mapping lands at/below the frontier (i.e. it
|
||||||
|
// belongs to the prior epoch). Remap it with the previous offset so
|
||||||
|
// it lands at its true seam position. This is what distinguishes a
|
||||||
|
// tail straggler from a frame that legitimately runs ahead of the
|
||||||
|
// (base-video-only) frontier — a long audio-only tail, a sparse
|
||||||
|
// subtitle, or an EL frame — which is left on the current offset.
|
||||||
|
if let Some(high) = self.high_ns {
|
||||||
|
if mapped > high + DISCONTINUITY_BACKSTEP_NS {
|
||||||
|
let prev_mapped = raw_pts_ns.saturating_add(self.prev_offset_ns);
|
||||||
|
if prev_mapped <= high {
|
||||||
|
return prev_mapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(high) = self.high_ns else {
|
||||||
|
let adj = raw_pts_ns.saturating_add(self.offset_ns);
|
||||||
|
self.high_ns = Some(adj);
|
||||||
|
return adj;
|
||||||
|
};
|
||||||
|
let adj = raw_pts_ns.saturating_add(self.offset_ns);
|
||||||
|
if adj < high - DISCONTINUITY_BACKSTEP_NS {
|
||||||
|
// Clip-boundary reset (real multi-clip seam): continue just after the
|
||||||
|
// frontier. Save the previous offset so a lagging non-video tail
|
||||||
|
// frame can be recognised and remapped to the seam (see above).
|
||||||
|
self.prev_offset_ns = self.offset_ns;
|
||||||
|
let bump = (high - adj).saturating_add(DISCONTINUITY_GAP_NS);
|
||||||
|
self.offset_ns = self.offset_ns.saturating_add(bump);
|
||||||
|
let adj2 = raw_pts_ns.saturating_add(self.offset_ns);
|
||||||
|
self.high_ns = Some(high.max(adj2));
|
||||||
|
adj2
|
||||||
|
} else {
|
||||||
|
// Normal progression / sub-threshold B-frame reorder: keep true PTS.
|
||||||
|
self.high_ns = Some(high.max(adj));
|
||||||
|
adj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const S: i64 = 1_000_000_000; // 1 second in ns
|
||||||
|
|
||||||
|
// Convenience: a video frame drives epoch decisions; non-video rides the
|
||||||
|
// current offset. These wrappers make the test intent explicit.
|
||||||
|
fn adj_video(tc: &mut TimelineContinuity, p: i64) -> i64 {
|
||||||
|
tc.adjust(p, true)
|
||||||
|
}
|
||||||
|
fn adj_other(tc: &mut TimelineContinuity, p: i64) -> i64 {
|
||||||
|
tc.adjust(p, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Characterization of the BUG: a BD title's two clips concatenated with a
|
||||||
|
/// PTS reset at the boundary. WITHOUT correction the raw VIDEO timeline goes
|
||||||
|
/// hard backward at clip 2 (what produced the non-monotonic-DTS band on
|
||||||
|
/// Dune / Top Gun). WITH `TimelineContinuity` the output is monotonic and
|
||||||
|
/// continuous across the boundary. The boundary is driven by VIDEO.
|
||||||
|
#[test]
|
||||||
|
fn continuity_rebases_clip_boundary_reset() {
|
||||||
|
// Clip1 video rising to 10s, then clip2 RESETS near 0 — non-seamless.
|
||||||
|
let clip1: Vec<i64> = (0..=10).map(|i| i * S).collect(); // 0..10s
|
||||||
|
let clip2: Vec<i64> = (0..=10).map(|i| i * S).collect(); // resets to 0..10s
|
||||||
|
let raw: Vec<i64> = clip1.iter().chain(clip2.iter()).copied().collect();
|
||||||
|
|
||||||
|
// Uncorrected (the bug): the sequence is NOT monotonic — clip2's first
|
||||||
|
// frame (0) is 10s below clip1's last (10s).
|
||||||
|
assert!(
|
||||||
|
raw.windows(2).any(|w| w[1] < w[0]),
|
||||||
|
"precondition: raw clip-reset sequence is non-monotonic"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Corrected: strictly non-decreasing, and clip2 continues AFTER clip1.
|
||||||
|
let mut tc = TimelineContinuity::new();
|
||||||
|
let out: Vec<i64> = raw.iter().map(|&p| adj_video(&mut tc, p)).collect();
|
||||||
|
assert!(
|
||||||
|
out.windows(2).all(|w| w[1] >= w[0]),
|
||||||
|
"corrected timeline must be monotonic non-decreasing, got {out:?}"
|
||||||
|
);
|
||||||
|
// Clip2's first frame lands just after clip1's last (10s) + the gap.
|
||||||
|
assert_eq!(out[11], 10 * S + DISCONTINUITY_GAP_NS);
|
||||||
|
// Clip2's last frame is offset by the whole of clip1, not back near 0.
|
||||||
|
assert!(out[21] > 19 * S);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression guard: NORMAL B-frame reorder (a small backward dip, well
|
||||||
|
/// under the discontinuity threshold) on VIDEO must pass through UNCHANGED.
|
||||||
|
#[test]
|
||||||
|
fn continuity_preserves_bframe_reorder() {
|
||||||
|
let mut tc = TimelineContinuity::new();
|
||||||
|
// I, P(+3 frames), B, B, B — presentation PTS dips backward by ~2
|
||||||
|
// frames (~83ms), far under the 3s threshold.
|
||||||
|
let raw = [0i64, 125_000_000, 42_000_000, 83_000_000, 250_000_000];
|
||||||
|
let out: Vec<i64> = raw.iter().map(|&p| adj_video(&mut tc, p)).collect();
|
||||||
|
assert_eq!(out, raw, "B-frame reorder must pass through unchanged");
|
||||||
|
assert_eq!(tc.offset_ns, 0, "no rebase for sub-threshold reorder");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A legitimate FORWARD gap (a real timing gap within a clip) on VIDEO must
|
||||||
|
/// be PRESERVED, not clamped — only backward video clip-boundary jumps are
|
||||||
|
/// rebased.
|
||||||
|
#[test]
|
||||||
|
fn continuity_preserves_forward_gap() {
|
||||||
|
let mut tc = TimelineContinuity::new();
|
||||||
|
let raw = [0i64, S, 2 * S + 500_000_000, 4 * S]; // a 1.5s gap mid-stream
|
||||||
|
let out: Vec<i64> = raw.iter().map(|&p| adj_video(&mut tc, p)).collect();
|
||||||
|
assert_eq!(out, raw, "forward gap preserved verbatim");
|
||||||
|
assert_eq!(tc.offset_ns, 0, "no rebase on forward progression");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PRIMARY rc3 regression: a sparse, lagging NON-VIDEO track (PGS subtitle /
|
||||||
|
/// trailing audio) on a SINGLE-clip title must NOT inflate `offset_ns`. This
|
||||||
|
/// is the exact false-positive that destroyed Top Gun's seek index: with a
|
||||||
|
/// shared frontier, a late subtitle PTS ratcheted the frontier up, then the
|
||||||
|
/// next normal video frame sat >3s below it and was misread as a clip
|
||||||
|
/// boundary, permanently bumping the offset — thousands of times, until the
|
||||||
|
/// Cue/cluster timestamps inflated into the billions of ms.
|
||||||
|
///
|
||||||
|
/// Correct behaviour: non-video frames ride the current offset and NEVER
|
||||||
|
/// touch the frontier or the offset, so no amount of subtitle/audio lag can
|
||||||
|
/// trigger a rebase on a one-clip title.
|
||||||
|
#[test]
|
||||||
|
fn single_clip_late_subtitle_does_not_inflate_offset() {
|
||||||
|
let mut tc = TimelineContinuity::new();
|
||||||
|
// One continuous clip: video advances steadily 0..60s.
|
||||||
|
// Interleaved, a subtitle track is sparse — it emits a cue at 0s, then
|
||||||
|
// nothing for a long stretch, then a late cue, then jumps around. Each
|
||||||
|
// subtitle PTS swings many seconds against the video frontier.
|
||||||
|
// Drive a realistic interleave.
|
||||||
|
let mut max_out = i64::MIN;
|
||||||
|
for sec in 0..=60 {
|
||||||
|
// Video frame every second.
|
||||||
|
let v = adj_video(&mut tc, sec * S);
|
||||||
|
max_out = max_out.max(v);
|
||||||
|
// Every 7th second, a subtitle appears whose raw PTS lags the video
|
||||||
|
// frontier by ~5s (a late display-set delivered by the interleaver)
|
||||||
|
// — far more than the 3s discontinuity threshold.
|
||||||
|
if sec % 7 == 0 && sec >= 7 {
|
||||||
|
let sub_raw = (sec - 5) * S;
|
||||||
|
let s = adj_other(&mut tc, sub_raw);
|
||||||
|
// The subtitle maps under the current (zero) offset, near its
|
||||||
|
// true time — it does NOT fling the timeline forward.
|
||||||
|
assert_eq!(s, sub_raw, "subtitle rides the current offset");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The crux: a single-clip title must NEVER open an epoch. Offset stays 0
|
||||||
|
// and the timeline never inflates.
|
||||||
|
assert_eq!(
|
||||||
|
tc.offset_ns, 0,
|
||||||
|
"single-clip interleave must not ratchet offset (was {})",
|
||||||
|
tc.offset_ns
|
||||||
|
);
|
||||||
|
// And the video frontier is exactly 60s — not billions.
|
||||||
|
assert_eq!(tc.high_ns, Some(60 * S), "frontier tracks video only");
|
||||||
|
assert!(max_out <= 60 * S, "no timeline inflation, max={max_out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PRIMARY rc3 regression (Dolby Vision dual-layer): a SECOND video track —
|
||||||
|
/// the DV enhancement layer — runs its OWN PTS timeline interleaved with the
|
||||||
|
/// base layer's, so the two video PTS sequences OVERLAP. The EL must be a
|
||||||
|
/// PASSIVE rider (drives_epoch == false): if it drove epochs, every EL GOP
|
||||||
|
/// would look like a multi-second backward jump against the base-layer
|
||||||
|
/// frontier and false-trigger a clip-boundary reset — the exact ratchet that
|
||||||
|
/// inflated Top Gun's 1-clip 1h49m timeline to ~7 h. Here the base layer
|
||||||
|
/// advances 0..60s while the EL re-emits the SAME 0..60s interleaved; the
|
||||||
|
/// timeline must stay at 60s with offset 0.
|
||||||
|
#[test]
|
||||||
|
fn dv_enhancement_layer_does_not_drive_epochs() {
|
||||||
|
let mut tc = TimelineContinuity::new();
|
||||||
|
let mut max_out = i64::MIN;
|
||||||
|
for sec in 0..=60 {
|
||||||
|
// Base layer (track 0) drives the epoch.
|
||||||
|
let bl = adj_video(&mut tc, sec * S);
|
||||||
|
// EL (track 1) re-emits the same time — a passive rider. Its raw PTS
|
||||||
|
// equals the base layer's, but it arrives just AFTER the base frame
|
||||||
|
// for the NEXT second sometimes; simulate the overlap by feeding the
|
||||||
|
// PREVIOUS second's time, which is a backward swing vs the frontier.
|
||||||
|
let el_raw = if sec > 0 { (sec - 1) * S } else { 0 };
|
||||||
|
let el = adj_other(&mut tc, el_raw);
|
||||||
|
assert_eq!(el, el_raw, "EL rides current offset, true PTS preserved");
|
||||||
|
max_out = max_out.max(bl).max(el);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
tc.offset_ns, 0,
|
||||||
|
"DV EL interleave must not ratchet offset (was {})",
|
||||||
|
tc.offset_ns
|
||||||
|
);
|
||||||
|
assert_eq!(tc.high_ns, Some(60 * S), "frontier tracks base video only");
|
||||||
|
assert!(max_out <= 60 * S, "no timeline inflation, max={max_out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Companion: a non-video frame must never ADVANCE the frontier. Even a
|
||||||
|
/// non-video PTS far ABOVE the current video frontier (a subtitle/audio
|
||||||
|
/// timestamp that leads the video momentarily) leaves `high_ns` untouched,
|
||||||
|
/// so a subsequent normal video frame is not misread as a boundary.
|
||||||
|
#[test]
|
||||||
|
fn non_video_never_advances_frontier() {
|
||||||
|
let mut tc = TimelineContinuity::new();
|
||||||
|
adj_video(&mut tc, 0);
|
||||||
|
adj_video(&mut tc, 5 * S);
|
||||||
|
let frontier = tc.high_ns.unwrap();
|
||||||
|
// A subtitle leading the video by 20s.
|
||||||
|
let s = adj_other(&mut tc, 25 * S);
|
||||||
|
assert_eq!(s, 25 * S, "non-video maps under current offset");
|
||||||
|
assert_eq!(
|
||||||
|
tc.high_ns.unwrap(),
|
||||||
|
frontier,
|
||||||
|
"non-video must NOT advance the frontier"
|
||||||
|
);
|
||||||
|
// The next normal video frame (6s) is well below 25s but is NOT treated
|
||||||
|
// as a boundary, because the frontier is still 5s (video-only).
|
||||||
|
let v = adj_video(&mut tc, 6 * S);
|
||||||
|
assert_eq!(v, 6 * S, "video continues normally, no false boundary");
|
||||||
|
assert_eq!(
|
||||||
|
tc.offset_ns, 0,
|
||||||
|
"no rebase triggered by the leading subtitle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression for the original Top Gun band: a LARGE, real-magnitude
|
||||||
|
/// clip-boundary back-jump on VIDEO (clip 1 ≈ 13 min, clip 2 resets to 0)
|
||||||
|
/// must STILL be rebased to one continuous monotonic timeline — the genuine
|
||||||
|
/// multi-clip seamless behaviour is preserved, now keyed on real video
|
||||||
|
/// back-jumps.
|
||||||
|
#[test]
|
||||||
|
fn continuity_large_clip_boundary_backjump_rebased() {
|
||||||
|
let mut tc = TimelineContinuity::new();
|
||||||
|
// Clip 1: 0 .. 780s (13 min) at 1s steps.
|
||||||
|
let clip1: Vec<i64> = (0..=780).map(|i| i * S).collect();
|
||||||
|
// Clip 2: resets to 0 .. 120s — the ~ -780s discontinuity.
|
||||||
|
let clip2: Vec<i64> = (0..=120).map(|i| i * S).collect();
|
||||||
|
let mut last = i64::MIN;
|
||||||
|
let mut max = i64::MIN;
|
||||||
|
for &p in clip1.iter().chain(clip2.iter()) {
|
||||||
|
let a = adj_video(&mut tc, p);
|
||||||
|
assert!(
|
||||||
|
a >= last,
|
||||||
|
"rebased timeline must be monotonic, got {a} < {last}"
|
||||||
|
);
|
||||||
|
last = a;
|
||||||
|
max = max.max(a);
|
||||||
|
}
|
||||||
|
// Offset ≈ the whole of clip 1 (one boundary, no ratchet).
|
||||||
|
assert_eq!(tc.offset_ns, 780 * S + DISCONTINUITY_GAP_NS);
|
||||||
|
// Timeline spans clip1+clip2 (~900s), proving clip 2 is reachable past
|
||||||
|
// the boundary — not capped at it, and not ratcheted far beyond.
|
||||||
|
assert!(
|
||||||
|
(900 * S..901 * S).contains(&max),
|
||||||
|
"timeline must span ~900s (clip1+clip2), got {max}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At a REAL video-driven boundary, a lagging NON-VIDEO tail frame from the
|
||||||
|
/// just-ended clip (an old-epoch raw PTS arriving interleaved after the
|
||||||
|
/// reset) must be REMAPPED to its true seam position with the PREVIOUS
|
||||||
|
/// offset — not flung ~a clip past the frontier by the freshly-bumped
|
||||||
|
/// offset. Otherwise it would force a forward-dated split cluster and break
|
||||||
|
/// cluster monotonicity.
|
||||||
|
#[test]
|
||||||
|
fn non_video_straggler_remapped_to_seam_at_boundary() {
|
||||||
|
let mut tc = TimelineContinuity::new();
|
||||||
|
// Clip1 video rises to 600s.
|
||||||
|
for i in 0..=600 {
|
||||||
|
adj_video(&mut tc, i * S);
|
||||||
|
}
|
||||||
|
let frontier = tc.high_ns.unwrap();
|
||||||
|
assert_eq!(frontier, 600 * S);
|
||||||
|
// Clip2 video resets to 0 → boundary, offset bumps by ~600s.
|
||||||
|
let c2 = adj_video(&mut tc, 0);
|
||||||
|
assert_eq!(c2, 600 * S + DISCONTINUITY_GAP_NS);
|
||||||
|
// Straggler: clip1's tail audio (raw 599.5s) arrives now. Under the new
|
||||||
|
// offset it would map to ~1199.5s; it must instead remap with the
|
||||||
|
// previous (zero) offset to its true seam position 599.5s.
|
||||||
|
let straggler_raw = 599 * S + 500_000_000;
|
||||||
|
let straggler = adj_other(&mut tc, straggler_raw);
|
||||||
|
assert_eq!(
|
||||||
|
straggler, straggler_raw,
|
||||||
|
"straggler must remap to its seam position via the previous offset"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
straggler <= frontier,
|
||||||
|
"straggler must land at/below the frontier, got {straggler}"
|
||||||
|
);
|
||||||
|
// It must NOT have perturbed the offset or the frontier.
|
||||||
|
assert_eq!(
|
||||||
|
tc.high_ns.unwrap(),
|
||||||
|
c2,
|
||||||
|
"straggler must not move the frontier"
|
||||||
|
);
|
||||||
|
// A NORMAL clip2 audio frame (raw ~1s, current epoch) is NOT remapped —
|
||||||
|
// it rides the new offset to ~601s, just past the frontier but within a
|
||||||
|
// backstep (its previous-offset mapping ~1s is below the frontier but the
|
||||||
|
// current-offset mapping is not a backstep past it, so it is not treated
|
||||||
|
// as a straggler).
|
||||||
|
let normal = adj_other(&mut tc, S);
|
||||||
|
assert_eq!(normal, S + 600 * S + DISCONTINUITY_GAP_NS);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user