Closes the three residual holes where a concealed/lost gap could still let a dangling-reference frame reach the muxer (degraded/undecryptable-disc path only; clean rips are byte-identical and untouched). Root cause: the discontinuity signal was reconstructed from the 4-bit continuity counter and applied per-PES, both of which are lossy. Three coordinated changes: 1. CC-INDEPENDENT marker. fill_null_ts_unit now tags its NULL packets with an adaptation-field discontinuity_indicator; the demuxer recognises a 0x1FFF packet carrying it as a concealed gap and forces a discontinuity on every tracked PID (the lost unit's PID is unknowable). This survives a loss that is an exact multiple of 16 packets (CC aliases to in-sequence — hole 3) and a loss at a PID's very start (no prior CC — hole 4); it also drops any open, potentially-truncated partial PES. 2. PUSI ATTRIBUTION. A gap landing on a PES boundary now flags the PES STARTING after it, not the one flushed at the boundary (hole 1) — stamping the pre-gap frame could arm-then-disarm the gate on a keyframe and admit the real post-gap inter frame. 3. PER-FRAME signal. codec::Frame gains `discontinuity`; each parser propagates it onto the first post-gap frame. MPEG-2 buffers whole GOPs asynchronously, so it associates the gap by ES OFFSET (like PTS/source), landing it on the exact post-gap picture mid-GOP (hole 2) — a per-PES flag stamped the previous picture. consume_ts (and the EOF flush drain) gate on frame.discontinuity. Tests: CC-independent marker with in-sequence CC + leading-loss; PUSI attribution flags the post-gap PES; MPEG-2 offset-mark stamps the post-gap picture through GOP reorder, not the previous one. Existing B1 gate + EOF tests still green (2270 lib tests).
567 lines
23 KiB
Rust
567 lines
23 KiB
Rust
//! HDMV PGS (Presentation Graphics Stream) subtitle parser.
|
|
//!
|
|
//! PGS segments: PCS, WDS, PDS, ODS, END. Each PES packet starts with
|
|
//! one of those (segment_type byte at offset 0).
|
|
//!
|
|
//! Subtitle display lifecycle (BD spec):
|
|
//! - A "display" PCS (number_of_composition_objects > 0) starts a
|
|
//! visible subtitle. Its WDS/PDS/ODS follow.
|
|
//! - A later "empty" PCS (number_of_composition_objects == 0) clears
|
|
//! the screen.
|
|
//!
|
|
//! For Matroska output we collapse that pair into one block with
|
|
//! `BlockDuration` set to (clear_pts - display_pts). Without a
|
|
//! duration, hardware players linger on the last bitmap until the
|
|
//! next subtitle replaces it — which can be many seconds, and on a
|
|
//! disc where the final subtitle has no follower, until end of file.
|
|
|
|
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
|
|
|
const SEGMENT_PCS: u8 = 0x16;
|
|
// Upper bound on a pending display set's accumulated bytes. Real PGS
|
|
// display sets are small (a 1080p RLE bitmap plus palette is well under
|
|
// 1 MB); a stream that keeps appending non-PCS segments without ever
|
|
// emitting a PCS is malformed. Cap accumulation to bound memory and
|
|
// drop further appends until the next PCS resyncs the parser. Mirrors
|
|
// the MAX_*_BYTES / MAX_*_BUF caps in the DTS and AC-3 parsers.
|
|
const MAX_PGS_PENDING_BYTES: usize = 4 * 1024 * 1024;
|
|
// Offset within the PES payload at which number_of_composition_objects
|
|
// lives in a PCS: 3-byte segment header + 10 bytes of PCS fields
|
|
// (video_w/h, frame_rate, comp_num, comp_state, palette_update,
|
|
// palette_id_ref) = 13.
|
|
const PCS_NUM_OBJECTS_OFFSET: usize = 13;
|
|
|
|
/// Stateful parser that collapses PGS display/clear PCS pairs into
|
|
/// duration-bearing Matroska frames. Implements [`CodecParser`].
|
|
pub struct PgsParser {
|
|
pending: Option<(i64, Vec<u8>)>,
|
|
}
|
|
|
|
impl Default for PgsParser {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl PgsParser {
|
|
/// Create a fresh PGS parser with no pending display set.
|
|
pub fn new() -> Self {
|
|
Self { pending: None }
|
|
}
|
|
|
|
/// Take the pending display set (if any) and emit it as a Frame whose
|
|
/// duration runs from its start PTS to `end_pts_ns` (the PTS of the PCS that
|
|
/// closes or replaces it), clamped to >= 0. Shared by the clear-PCS and
|
|
/// replace-PCS arms so the Frame shape stays in one place.
|
|
fn emit_pending(&mut self, end_pts_ns: i64) -> Option<Frame> {
|
|
let (start_pts, data) = self.pending.take()?;
|
|
let duration = end_pts_ns.saturating_sub(start_pts).max(0) as u64;
|
|
Some(Frame {
|
|
discontinuity: false,
|
|
coding: None,
|
|
source: None,
|
|
pts_ns: start_pts,
|
|
keyframe: true,
|
|
data,
|
|
duration_ns: Some(duration),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl CodecParser for PgsParser {
|
|
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
|
if pes.data.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
// Keep PTS as Option: a PCS with no PTS has an UNKNOWN start/clear time.
|
|
// Collapsing it to a 0 sentinel produces a frame with a wrong start time
|
|
// and an absurd duration (the full elapsed time of the disc). PGS PCS
|
|
// packets carry a PTS on well-formed BD streams, so a missing PTS is a
|
|
// malformed-stream path that we skip cleanly rather than corrupt.
|
|
let pts = pes.pts.map(pts_to_ns);
|
|
|
|
let is_pcs = pes.data[0] == SEGMENT_PCS;
|
|
|
|
// A PCS too short to carry number_of_composition_objects is malformed.
|
|
// Don't let it fall through to the non-PCS arm (where it would pollute
|
|
// the pending display set or pass through as a lone frame): close any
|
|
// pending set undurated (mirroring the no-PTS display path) and drop
|
|
// the truncated header so the parser resyncs on the next PCS.
|
|
if is_pcs && pes.data.len() <= PCS_NUM_OBJECTS_OFFSET {
|
|
return self
|
|
.pending
|
|
.take()
|
|
.map(|(start_pts, data)| {
|
|
vec![Frame {
|
|
discontinuity: false,
|
|
coding: None,
|
|
source: None,
|
|
pts_ns: start_pts,
|
|
keyframe: true,
|
|
data,
|
|
duration_ns: None,
|
|
}]
|
|
})
|
|
.unwrap_or_default();
|
|
}
|
|
|
|
let pcs_num_objects = if is_pcs {
|
|
Some(pes.data[PCS_NUM_OBJECTS_OFFSET])
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let mut out = Vec::new();
|
|
match pcs_num_objects {
|
|
// Clear/empty PCS — closes any pending display. Drop the
|
|
// clear segment itself; BlockDuration covers the screen
|
|
// wipe. A clear PCS with no PTS can't time the duration, so
|
|
// emit the pending set with no duration (it lingers to EOF).
|
|
Some(0) => {
|
|
let frame = match pts {
|
|
Some(end) => self.emit_pending(end),
|
|
None => self.pending.take().map(|(start_pts, data)| Frame {
|
|
discontinuity: false,
|
|
coding: None,
|
|
source: None,
|
|
pts_ns: start_pts,
|
|
keyframe: true,
|
|
data,
|
|
duration_ns: None,
|
|
}),
|
|
};
|
|
out.extend(frame);
|
|
}
|
|
// Display PCS — start a new pending. If a prior display
|
|
// was never explicitly cleared (replace-without-clear),
|
|
// emit it with the new PCS's PTS as its end.
|
|
Some(_) => match pts {
|
|
Some(start) => {
|
|
out.extend(self.emit_pending(start));
|
|
self.pending = Some((start, pes.data.clone()));
|
|
}
|
|
// A display PCS with no PTS has an unknown start time. Don't
|
|
// store it with a 0 sentinel (wrong start, absurd duration).
|
|
// Flush any prior pending undurated and skip storing this one.
|
|
None => {
|
|
out.extend(self.pending.take().map(|(start_pts, data)| Frame {
|
|
discontinuity: false,
|
|
coding: None,
|
|
source: None,
|
|
pts_ns: start_pts,
|
|
keyframe: true,
|
|
data,
|
|
duration_ns: None,
|
|
}));
|
|
}
|
|
},
|
|
// Non-PCS first segment — either a continuation of the
|
|
// current display set, or non-standard layout. If we have
|
|
// a pending display, append; otherwise emit as-is.
|
|
None => {
|
|
if let Some((_, ref mut buf)) = self.pending {
|
|
// Bound accumulation: a well-formed display set is small.
|
|
// Past the cap, drop further appends (malformed stream);
|
|
// the next PCS will take/replace `pending` and resync.
|
|
if buf.len() + pes.data.len() <= MAX_PGS_PENDING_BYTES {
|
|
buf.extend_from_slice(&pes.data);
|
|
}
|
|
} else if pes.pts.is_some() {
|
|
// A lone non-PCS segment with a real PTS — pass it through.
|
|
// (A missing PTS falls through to the drop path below: a
|
|
// bitmap with no timing reference would land at 00:00:00.)
|
|
out.push(Frame {
|
|
discontinuity: false,
|
|
coding: None,
|
|
source: None,
|
|
pts_ns: pts.unwrap_or(0),
|
|
keyframe: true,
|
|
data: pes.data.clone(),
|
|
duration_ns: None,
|
|
});
|
|
}
|
|
// No pending set AND no PTS: drop it. Emitting at pts_ns=0 would
|
|
// place a stray bitmap at 00:00:00.000 with no timing reference;
|
|
// the no-PTS PCS arms above avoid the 0 sentinel for the same
|
|
// reason.
|
|
}
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
fn flush(&mut self) -> Vec<Frame> {
|
|
// A display set is only emitted when the *next* PCS arrives
|
|
// (either an empty clear PCS or a replacing display PCS). At
|
|
// end-of-stream there is no follower, so without this the last
|
|
// subtitle of every PGS track would be silently dropped. Emit
|
|
// the pending set with no duration — the trailing block lingers
|
|
// until end of file, which is exactly the desired behavior for
|
|
// the final on-screen subtitle (see the module doc).
|
|
match self.pending.take() {
|
|
Some((start_pts, data)) => vec![Frame {
|
|
discontinuity: false,
|
|
coding: None,
|
|
source: None,
|
|
pts_ns: start_pts,
|
|
keyframe: true,
|
|
data,
|
|
duration_ns: None,
|
|
}],
|
|
None => Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::mux::ts::PesPacket;
|
|
|
|
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
|
PesPacket {
|
|
source: None,
|
|
pid: 0x1200,
|
|
pts,
|
|
dts: None,
|
|
data,
|
|
discontinuity: false,
|
|
}
|
|
}
|
|
|
|
// Minimum-viable PCS bytes: type 0x16, segment_length (2 bytes),
|
|
// then 11 bytes of PCS fields ending in number_of_composition_objects.
|
|
fn pcs_bytes(num_objects: u8) -> Vec<u8> {
|
|
let mut v = vec![SEGMENT_PCS, 0x00, 0x0B];
|
|
v.extend_from_slice(&[0x07, 0x80, 0x04, 0x38]); // 1920x1080
|
|
v.push(0x10); // frame_rate
|
|
v.extend_from_slice(&[0x00, 0x01]); // composition_number
|
|
v.push(0x80); // composition_state = EpochStart
|
|
v.push(0x00); // palette_update + reserved
|
|
v.push(0x00); // palette_id_ref
|
|
v.push(num_objects);
|
|
v
|
|
}
|
|
|
|
#[test]
|
|
fn display_then_clear_yields_duration() {
|
|
let mut parser = PgsParser::new();
|
|
|
|
// Display PCS at PTS 90000 (= 1s)
|
|
let display = pcs_bytes(1);
|
|
let frames = parser.parse(&make_pes(display.clone(), Some(90000)));
|
|
assert!(frames.is_empty(), "display PCS should be pending");
|
|
|
|
// Empty PCS at PTS 270000 (= 3s)
|
|
let clear = pcs_bytes(0);
|
|
let frames = parser.parse(&make_pes(clear, Some(270000)));
|
|
assert_eq!(frames.len(), 1);
|
|
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
|
assert_eq!(frames[0].duration_ns, Some(2_000_000_000));
|
|
assert_eq!(frames[0].data, display);
|
|
}
|
|
|
|
#[test]
|
|
fn replace_without_clear_still_emits_prior_with_duration() {
|
|
let mut parser = PgsParser::new();
|
|
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
|
|
let frames = parser.parse(&make_pes(pcs_bytes(1), Some(180000)));
|
|
assert_eq!(frames.len(), 1);
|
|
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
|
assert_eq!(frames[0].duration_ns, Some(1_000_000_000));
|
|
}
|
|
|
|
#[test]
|
|
fn non_pcs_segment_appends_to_pending() {
|
|
let mut parser = PgsParser::new();
|
|
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
|
|
// ODS-like segment (type 0x15)
|
|
let frames = parser.parse(&make_pes(vec![0x15, 0x00, 0x02, 0xAA, 0xBB], Some(90000)));
|
|
assert!(frames.is_empty());
|
|
// Clear closes the set; data should include the appended bytes.
|
|
let frames = parser.parse(&make_pes(pcs_bytes(0), Some(180000)));
|
|
assert_eq!(frames.len(), 1);
|
|
let data = &frames[0].data;
|
|
assert!(data.windows(5).any(|w| w == [0x15, 0x00, 0x02, 0xAA, 0xBB]));
|
|
}
|
|
|
|
#[test]
|
|
fn pending_buffer_is_capped() {
|
|
let mut parser = PgsParser::new();
|
|
// Open a display set.
|
|
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
|
|
|
|
// Flood with non-PCS segments far exceeding the cap.
|
|
let chunk = vec![0x15u8; 256 * 1024]; // 256 KB ODS-like segment
|
|
let floods = (MAX_PGS_PENDING_BYTES / chunk.len()) + 32;
|
|
for _ in 0..floods {
|
|
let frames = parser.parse(&make_pes(chunk.clone(), Some(90000)));
|
|
assert!(frames.is_empty(), "non-PCS appends should not emit");
|
|
}
|
|
|
|
// The pending buffer must not have grown without bound.
|
|
let pending_len = parser.pending.as_ref().map(|(_, b)| b.len()).unwrap_or(0);
|
|
assert!(
|
|
pending_len <= MAX_PGS_PENDING_BYTES,
|
|
"pending buffer {pending_len} exceeded cap {MAX_PGS_PENDING_BYTES}"
|
|
);
|
|
|
|
// A following PCS still resyncs and emits the (capped) pending set.
|
|
let frames = parser.parse(&make_pes(pcs_bytes(0), Some(180000)));
|
|
assert_eq!(frames.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn flush_emits_final_pending_subtitle() {
|
|
let mut parser = PgsParser::new();
|
|
|
|
// Display PCS at PTS 90000 — buffered as pending, no follower.
|
|
let display = pcs_bytes(1);
|
|
let frames = parser.parse(&make_pes(display.clone(), Some(90000)));
|
|
assert!(frames.is_empty(), "display PCS should be pending");
|
|
|
|
// EOF: without flush() this last subtitle would be dropped.
|
|
let frames = parser.flush();
|
|
assert_eq!(frames.len(), 1, "final pending subtitle must flush");
|
|
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
|
assert_eq!(frames[0].data, display);
|
|
// Trailing block lingers to EOF — no duration per module doc.
|
|
assert_eq!(frames[0].duration_ns, None);
|
|
}
|
|
|
|
#[test]
|
|
fn display_pcs_without_pts_is_not_stored_with_zero_start() {
|
|
// A display PCS with no PTS has an unknown start time. It must NOT be
|
|
// stored with a 0 sentinel — otherwise a later clear PCS at real PTS T
|
|
// would emit a frame with pts_ns=0 and duration_ns=T (hours of ns for a
|
|
// mid-disc subtitle). The malformed display PCS is skipped instead.
|
|
let mut parser = PgsParser::new();
|
|
let frames = parser.parse(&make_pes(pcs_bytes(1), None));
|
|
assert!(frames.is_empty(), "no-PTS display PCS emits nothing");
|
|
assert!(
|
|
parser.pending.is_none(),
|
|
"no-PTS display PCS must not be stored as pending"
|
|
);
|
|
|
|
// A subsequent well-formed display + clear pair must time correctly,
|
|
// unpolluted by the skipped no-PTS PCS.
|
|
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
|
|
let f = parser.parse(&make_pes(pcs_bytes(0), Some(270000)));
|
|
assert_eq!(f.len(), 1);
|
|
assert_eq!(f[0].pts_ns, 1_000_000_000);
|
|
assert_eq!(f[0].duration_ns, Some(2_000_000_000));
|
|
}
|
|
|
|
#[test]
|
|
fn clear_pcs_without_pts_emits_pending_undurated() {
|
|
// A clear PCS that lacks a PTS can't compute a duration; the pending
|
|
// display is still emitted, but with no duration (lingers to EOF)
|
|
// instead of a bogus absurd one.
|
|
let mut parser = PgsParser::new();
|
|
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
|
|
let f = parser.parse(&make_pes(pcs_bytes(0), None));
|
|
assert_eq!(f.len(), 1);
|
|
assert_eq!(f[0].pts_ns, 1_000_000_000, "pending keeps its real start");
|
|
assert_eq!(f[0].duration_ns, None, "no duration without a clear PTS");
|
|
}
|
|
|
|
#[test]
|
|
fn truncated_pcs_flushes_pending_and_resyncs() {
|
|
// A PCS too short to carry number_of_composition_objects arriving with a
|
|
// pending display must close that display (undurated) and drop the
|
|
// truncated header, not append its bytes into the pending bitmap.
|
|
let mut parser = PgsParser::new();
|
|
let display = pcs_bytes(1);
|
|
assert!(
|
|
parser
|
|
.parse(&make_pes(display.clone(), Some(90000)))
|
|
.is_empty()
|
|
);
|
|
|
|
// A 13-byte (<= PCS_NUM_OBJECTS_OFFSET) PCS: truncated.
|
|
let truncated = vec![SEGMENT_PCS; PCS_NUM_OBJECTS_OFFSET];
|
|
let frames = parser.parse(&make_pes(truncated, Some(180000)));
|
|
assert_eq!(frames.len(), 1, "pending display flushed on truncated PCS");
|
|
assert_eq!(frames[0].data, display, "pending bitmap not polluted");
|
|
assert_eq!(frames[0].duration_ns, None, "flushed undurated");
|
|
assert!(parser.pending.is_none(), "parser resynced");
|
|
}
|
|
|
|
#[test]
|
|
fn lone_non_pcs_without_pts_is_dropped() {
|
|
// A non-PCS segment with no pending set and no PTS must be dropped, not
|
|
// emitted at pts_ns = 0 (which would land a stray bitmap at time zero).
|
|
let mut parser = PgsParser::new();
|
|
let frames = parser.parse(&make_pes(vec![0x15, 0x00, 0x02, 0xAA], None));
|
|
assert!(frames.is_empty(), "no pending + no PTS → dropped");
|
|
}
|
|
|
|
#[test]
|
|
fn lone_non_pcs_with_pts_passes_through() {
|
|
// A lone non-PCS segment WITH a PTS still passes through.
|
|
let mut parser = PgsParser::new();
|
|
let frames = parser.parse(&make_pes(vec![0x15, 0x00, 0x02, 0xAA], Some(90000)));
|
|
assert_eq!(frames.len(), 1);
|
|
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
|
}
|
|
|
|
#[test]
|
|
fn flush_with_nothing_pending_is_empty() {
|
|
let mut parser = PgsParser::new();
|
|
assert!(parser.flush().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn codec_private_none() {
|
|
let parser = PgsParser::new();
|
|
assert!(parser.codec_private().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_empty_pes() {
|
|
let mut parser = PgsParser::new();
|
|
let pes = make_pes(Vec::new(), Some(0));
|
|
assert!(parser.parse(&pes).is_empty());
|
|
}
|
|
|
|
// --- number_of_composition_objects lives at byte 13 ---
|
|
|
|
#[test]
|
|
fn num_objects_read_from_offset_13() {
|
|
// PCS_NUM_OBJECTS_OFFSET = 3-byte seg header + 10 PCS field bytes = 13.
|
|
// A byte at offset 13 of 0 = clear, > 0 = display. Build a PCS where
|
|
// every byte before 13 is non-zero noise and byte 13 alone decides.
|
|
let mut display = vec![SEGMENT_PCS];
|
|
display.extend_from_slice(&[0xFF; 12]); // bytes 1..=12 noise
|
|
display.push(1); // byte 13: num_objects = 1 → display
|
|
let mut parser = PgsParser::new();
|
|
assert!(
|
|
parser.parse(&make_pes(display, Some(90000))).is_empty(),
|
|
"byte 13 == 1 → display PCS (pending), no emit yet"
|
|
);
|
|
// Now a clear: byte 13 == 0.
|
|
let mut clear = vec![SEGMENT_PCS];
|
|
clear.extend_from_slice(&[0xFF; 12]);
|
|
clear.push(0); // byte 13 = 0 → clear
|
|
let f = parser.parse(&make_pes(clear, Some(270000)));
|
|
assert_eq!(f.len(), 1, "byte 13 == 0 closes the pending display");
|
|
}
|
|
|
|
// --- duration computation and clamping ---
|
|
|
|
#[test]
|
|
fn duration_clamps_to_zero_when_clear_precedes_display() {
|
|
// A clear PTS earlier than the display PTS (corrupt/out-of-order stream)
|
|
// must clamp duration to 0 via saturating_sub, never wrap to a huge u64.
|
|
let mut parser = PgsParser::new();
|
|
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(270000))); // display @ 3s
|
|
let f = parser.parse(&make_pes(pcs_bytes(0), Some(90000))); // clear @ 1s
|
|
assert_eq!(f.len(), 1);
|
|
assert_eq!(f[0].pts_ns, 3_000_000_000, "keeps display start");
|
|
assert_eq!(
|
|
f[0].duration_ns,
|
|
Some(0),
|
|
"clear-before-display clamps to 0, no u64 wrap"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn duration_zero_when_equal_pts() {
|
|
let mut parser = PgsParser::new();
|
|
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
|
|
let f = parser.parse(&make_pes(pcs_bytes(0), Some(90000)));
|
|
assert_eq!(f[0].duration_ns, Some(0));
|
|
}
|
|
|
|
// --- clear / replace edge cases ---
|
|
|
|
#[test]
|
|
fn clear_with_no_pending_emits_nothing() {
|
|
// An empty PCS arriving with no pending display is a no-op.
|
|
let mut parser = PgsParser::new();
|
|
let f = parser.parse(&make_pes(pcs_bytes(0), Some(90000)));
|
|
assert!(f.is_empty(), "clear with nothing pending → no frame");
|
|
assert!(parser.pending.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn three_displays_each_close_the_previous() {
|
|
// Successive display PCS (no intervening clear) each emit the prior one
|
|
// timed to the new display's PTS. display@1s, display@2s, display@3s →
|
|
// emits [1s dur 1s], [2s dur 1s]; the last (3s) is held.
|
|
let mut parser = PgsParser::new();
|
|
let f0 = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
|
|
assert!(f0.is_empty());
|
|
let f1 = parser.parse(&make_pes(pcs_bytes(1), Some(180000)));
|
|
assert_eq!(f1.len(), 1);
|
|
assert_eq!(f1[0].pts_ns, 1_000_000_000);
|
|
assert_eq!(f1[0].duration_ns, Some(1_000_000_000));
|
|
let f2 = parser.parse(&make_pes(pcs_bytes(1), Some(270000)));
|
|
assert_eq!(f2.len(), 1);
|
|
assert_eq!(f2[0].pts_ns, 2_000_000_000);
|
|
assert_eq!(f2[0].duration_ns, Some(1_000_000_000));
|
|
// Third held; flush emits it undurated.
|
|
let tail = parser.flush();
|
|
assert_eq!(tail.len(), 1);
|
|
assert_eq!(tail[0].pts_ns, 3_000_000_000);
|
|
assert_eq!(tail[0].duration_ns, None);
|
|
}
|
|
|
|
#[test]
|
|
fn pcs_exactly_at_offset_boundary_is_truncated() {
|
|
// A PCS of EXACTLY PCS_NUM_OBJECTS_OFFSET (13) bytes has no byte at index
|
|
// 13 → treated as truncated (`<= PCS_NUM_OBJECTS_OFFSET`). With a pending
|
|
// display it flushes that undurated and resyncs.
|
|
let mut parser = PgsParser::new();
|
|
let display = pcs_bytes(1);
|
|
let _ = parser.parse(&make_pes(display.clone(), Some(90000)));
|
|
let exactly_13 = vec![SEGMENT_PCS; PCS_NUM_OBJECTS_OFFSET]; // 13 bytes
|
|
let f = parser.parse(&make_pes(exactly_13, Some(180000)));
|
|
assert_eq!(f.len(), 1, "13-byte PCS is truncated → flush pending");
|
|
assert_eq!(f[0].duration_ns, None);
|
|
assert!(parser.pending.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn pcs_one_byte_past_offset_reads_num_objects() {
|
|
// A PCS of PCS_NUM_OBJECTS_OFFSET + 1 (14) bytes is the minimum that can
|
|
// carry number_of_composition_objects (index 13 exists). It must be read
|
|
// as a real PCS, not truncated.
|
|
let mut parser = PgsParser::new();
|
|
let mut display = vec![SEGMENT_PCS; PCS_NUM_OBJECTS_OFFSET];
|
|
display.push(1); // index 13 = 1 → display, 14 bytes total
|
|
assert!(
|
|
parser.parse(&make_pes(display, Some(90000))).is_empty(),
|
|
"14-byte display PCS is pending (not truncated)"
|
|
);
|
|
assert!(parser.pending.is_some(), "stored as pending display");
|
|
}
|
|
|
|
#[test]
|
|
fn non_pcs_without_pending_with_pts_passes_through_keyframe() {
|
|
// A lone non-PCS segment (first byte != 0x16) with a PTS and no pending
|
|
// set passes through as a keyframe frame at its PTS.
|
|
let mut parser = PgsParser::new();
|
|
let f = parser.parse(&make_pes(vec![0x14, 0x00, 0x01, 0xAA], Some(90000)));
|
|
assert_eq!(f.len(), 1);
|
|
assert!(f[0].keyframe);
|
|
assert_eq!(f[0].pts_ns, 1_000_000_000);
|
|
assert_eq!(f[0].duration_ns, None);
|
|
}
|
|
|
|
#[test]
|
|
fn display_pcs_data_preserved_verbatim() {
|
|
// The emitted frame data is the display PCS bytes (plus any appended
|
|
// non-PCS continuation), verbatim — the bitmap must not be altered.
|
|
let mut parser = PgsParser::new();
|
|
let display = pcs_bytes(2); // num_objects = 2
|
|
let _ = parser.parse(&make_pes(display.clone(), Some(90000)));
|
|
let f = parser.parse(&make_pes(pcs_bytes(0), Some(180000)));
|
|
assert_eq!(f[0].data, display, "display PCS data emitted verbatim");
|
|
}
|
|
}
|