Skip to main content

timed_metadata/
timeline.rs

1//! Stateful conversion session: holds the wall-clock anchor and unrolls 33-bit
2//! PTS wrap across a stream of events.
3use crate::anchor::TimeAnchor;
4use crate::convert::{EmsgConfig, scte35_to_daterange, scte35_to_emsg};
5use crate::daterange::DateRange;
6use crate::error::{Error, Result};
7use crate::event::{MediaTime, TimedEvent};
8use alloc::vec::Vec;
9use broadcast_common::traits::Parse;
10use scte35_splice::SpliceInfoSection;
11
12/// The 33-bit PTS modulus.
13pub const PTS_WRAP: u64 = broadcast_common::clock33::WRAP_33BIT;
14
15/// A stateful conversion session.
16#[derive(Debug, Default)]
17pub struct Timeline {
18    anchor: Option<TimeAnchor>,
19    unroller: PtsUnroller,
20}
21
22impl Timeline {
23    /// New session with no anchor.
24    pub fn new() -> Self {
25        Self::default()
26    }
27    /// New session with a wall-clock anchor.
28    pub fn with_anchor(anchor: TimeAnchor) -> Self {
29        Timeline {
30            anchor: Some(anchor),
31            unroller: PtsUnroller::default(),
32        }
33    }
34    /// Set / replace the anchor.
35    pub fn set_anchor(&mut self, anchor: TimeAnchor) {
36        self.anchor = Some(anchor);
37    }
38
39    /// Parse a SCTE-35 section; unroll its PTS into an absolute [`MediaTime`].
40    pub fn push_scte35(&mut self, bytes: &[u8]) -> Result<TimedEvent> {
41        let section = SpliceInfoSection::parse(bytes)?;
42        let mut ev = TimedEvent::from_scte35(&section, bytes)?;
43        if let Some(MediaTime(pts33)) = ev.at {
44            let abs = self.unroller.unroll(pts33);
45            ev.at = Some(MediaTime(abs));
46        }
47        Ok(ev)
48    }
49
50    /// Convert to a DATERANGE (requires an anchor).
51    pub fn to_daterange(&self, ev: &TimedEvent) -> Result<DateRange> {
52        let anchor = self.anchor.as_ref().ok_or(Error::MissingAnchor)?;
53        scte35_to_daterange(ev, anchor)
54    }
55
56    /// Convert to a serialized SCTE-35 `emsg` box.
57    pub fn to_emsg(&self, ev: &TimedEvent, cfg: &EmsgConfig) -> Result<Vec<u8>> {
58        match &ev.source {
59            crate::event::SourcePayload::Scte35 { raw } => scte35_to_emsg(raw, cfg),
60            crate::event::SourcePayload::Emsg { .. } => Err(Error::AttrParse(
61                alloc::string::String::from("event is not SCTE-35-sourced"),
62            )),
63        }
64    }
65}
66
67/// Per-signal 33-bit PTS unroller: turns a repeating 90 kHz wire counter into
68/// an absolute, ever-growing tick value.
69///
70/// Thin stateful wrapper around
71/// [`broadcast_common::clock33::unwrap_delta`] — the actual wrap-correction
72/// math lives there (shared with `transmux`'s demux-edge unroller and
73/// `media-doctor`/`compliance-probe`'s wrap-aware comparisons), so a fix
74/// there reaches every 33-bit clock consumer in the workspace instead of
75/// just this one. Used both by [`Timeline`] and by the caption diff-based
76/// boundary tracker (`crate::webvtt::cue::DiffState`) — a second reason not
77/// to hand-roll the (raw, epoch) bookkeeping a third time.
78///
79/// SCTE-35 cues and caption commit events never legitimately reorder across
80/// the 33-bit origin before any sample has been observed (there is no prior
81/// sample to wrap from), so `unroll` never sees a negative accumulator in
82/// practice; the `max(0)` below is a defensive clamp against malformed input
83/// rather than a real code path.
84#[derive(Debug, Default)]
85pub(crate) struct PtsUnroller {
86    /// `(previous raw 33-bit value, previous unwrapped accumulator)`.
87    state: Option<(u64, i128)>,
88}
89
90impl PtsUnroller {
91    pub(crate) fn unroll(&mut self, raw33: u64) -> u64 {
92        let unwrapped = match self.state {
93            Some((prev_raw, prev_unwrapped)) => {
94                broadcast_common::clock33::unwrap_delta(prev_unwrapped, prev_raw, raw33)
95            }
96            None => raw33 as i128,
97        };
98        self.state = Some((raw33, unwrapped));
99        unwrapped.max(0) as u64
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    fn splice_2002() -> alloc::vec::Vec<u8> {
108        let hex = "FC302100000000000000FFF01005000007D27FEF7F7E0020F580C0000000000088B9661D";
109        (0..hex.len())
110            .step_by(2)
111            .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
112            .collect()
113    }
114
115    #[test]
116    fn push_scte35_returns_event() {
117        let mut tl = Timeline::new();
118        let ev = tl.push_scte35(&splice_2002()).unwrap();
119        assert_eq!(ev.id, Some(2002));
120    }
121
122    #[test]
123    fn to_daterange_without_anchor_errors() {
124        let tl = Timeline::new();
125        let ev = Timeline::new().push_scte35(&splice_2002()).unwrap();
126        assert!(matches!(
127            tl.to_daterange(&ev),
128            Err(crate::Error::MissingAnchor)
129        ));
130    }
131
132    #[test]
133    fn wrap_unroll_adds_one_epoch() {
134        // A near-max previous value then a small next value crosses one wrap.
135        let mut u = PtsUnroller {
136            state: Some(((1u64 << 33) - 10, ((1u64 << 33) - 10) as i128)),
137        };
138        assert_eq!(u.unroll(5), 5 + (1u64 << 33));
139    }
140
141    #[test]
142    fn wrap_unroll_forward_delta_keeps_epoch() {
143        // A normal forward delta within range must NOT bump the epoch.
144        let mut u = PtsUnroller {
145            state: Some((1_000, 1_000)),
146        };
147        assert_eq!(u.unroll(2_000), 2_000);
148        // First call (no prior pts) returns the raw value.
149        let mut u2 = PtsUnroller::default();
150        assert_eq!(u2.unroll(42), 42);
151    }
152
153    /// MUTATION VERIFIED: the previous `unroll_pts` (a forward-only epoch
154    /// counter) could not distinguish a small backward reorder that
155    /// straddles the wrap origin from a huge forward jump — see
156    /// `broadcast_common::clock33::unwrap_delta`'s doc comment for the exact
157    /// case. Reproduced here at the `PtsUnroller` level: a splice/caption
158    /// event at raw tick `2`, then one at raw tick `2^33 - 3` (a legitimate
159    /// 5-tick backward step across the origin), must unroll to a small
160    /// value, not to `~2^33`.
161    #[test]
162    fn wrap_unroll_backward_reorder_across_origin_does_not_leap_forward() {
163        let mut u = PtsUnroller::default();
164        assert_eq!(u.unroll(2), 2);
165        let second = u.unroll((1u64 << 33) - 3);
166        assert!(
167            second < 1000,
168            "expected a small value from a 5-tick backward reorder, got {second}"
169        );
170    }
171}