Skip to main content

ssai_runtime/
splice.rs

1//! Splice-point conditioning: aligning a SCTE-35 cue's target presentation
2//! time (`splice_time().pts_time`, ANSI/SCTE 35 2023r1 §9.8.1, or a DASH
3//! `emsg`'s `presentation_time`) against the primary content's actual
4//! segment/keyframe boundaries.
5//!
6//! Real cues are frequently **not** IDR-aligned. `fixtures/scte35-ssai/PROVENANCE.md`
7//! documents a genuine DASH-IF `livesim2` capture whose nearest video
8//! keyframe lands 6000 ticks (67 ms) *after* the cue's nominal presentation
9//! time at the shared 90 kHz clock — see `ssai-runtime/examples/condition_real_cue.rs`,
10//! which reproduces that exact measurement through this module rather than
11//! asserting it. [`condition_splice_point`] measures that offset instead of
12//! assuming it away, so a caller (the playlist renderer, or a transmux-side
13//! splicer) can make an informed choice: snap to the actual boundary and
14//! accept the drift, or reject the cue as un-splice-able when nothing is
15//! close enough.
16//!
17//! This module works in whatever clock unit the caller's candidates use — it
18//! does no 90 kHz-specific math and does not itself decode SCTE-35 or emsg
19//! (that's `scte35-splice` / `mp4-emsg`'s job; this crate's core takes plain
20//! tick counts so it never needs those crates as a runtime dependency).
21
22use crate::error::{Error, Result};
23
24/// The 33-bit modulus of an MPEG-2 PTS/DTS and of a SCTE-35 `pts_time`
25/// (ISO/IEC 13818-1 §2.4.3.7 `PTS[32..0]`; ANSI/SCTE 35 2023r1 §9.8.1
26/// `pts_time` is a 33-bit field), in 90 kHz ticks: 2^33 ≈ 26.5 hours.
27pub const PTS_MODULUS_33: u64 = broadcast_common::clock33::WRAP_33BIT;
28
29/// Where the chosen boundary landed relative to the requested instant.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum SnapDirection {
33    /// The candidate matches the requested instant exactly.
34    Exact,
35    /// The candidate is before the requested instant.
36    Before,
37    /// The candidate is after the requested instant — the common case for a
38    /// real, non-IDR-aligned cue (see the module docs).
39    After,
40}
41
42impl SnapDirection {
43    /// Stable label.
44    pub fn name(&self) -> &'static str {
45        match self {
46            SnapDirection::Exact => "exact",
47            SnapDirection::Before => "before",
48            SnapDirection::After => "after",
49        }
50    }
51}
52broadcast_common::impl_spec_display!(SnapDirection);
53
54/// The result of conditioning one splice point against a set of candidate
55/// boundaries (segment starts, or sync-sample/IDR timestamps).
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct ConditionedSplicePoint {
58    /// The cue's nominal target, in whatever clock unit the caller's
59    /// candidates share (this module does no unit conversion).
60    pub requested_pts: u64,
61    /// The nearest candidate boundary actually chosen.
62    pub snapped_pts: u64,
63    /// `|snapped_pts - requested_pts|`.
64    pub delta_ticks: u64,
65    /// Whether the snap landed before, after, or exactly on the request.
66    pub direction: SnapDirection,
67}
68
69impl ConditionedSplicePoint {
70    /// Whether the snap was exact (no drift at all).
71    pub fn is_exact(&self) -> bool {
72        self.delta_ticks == 0
73    }
74}
75
76/// Find the candidate boundary nearest `requested_pts`, and error out if the
77/// nearest one is farther than `max_delta_ticks` away — the caller decides
78/// how much drift is tolerable (a live low-latency splice may need a tight
79/// bound; a VOD ad break can tolerate a full GOP).
80///
81/// `candidates` need not be sorted; every entry is checked. The candidate
82/// sets this crate is used with (segment or sync-sample boundaries within
83/// one GOP of the cue) are small enough that a linear scan is the right
84/// tool: no allocation, no requirement that the caller maintain sorted
85/// order.
86///
87/// Returns [`Error::NoCandidates`] if `candidates` is empty, and
88/// [`Error::NoAlignedBoundary`] if the nearest candidate exceeds
89/// `max_delta_ticks` — conditioning never silently accepts a splice point
90/// nothing is actually close to.
91pub fn condition_splice_point(
92    requested_pts: u64,
93    candidates: &[u64],
94    max_delta_ticks: u64,
95) -> Result<ConditionedSplicePoint> {
96    let mut nearest: Option<(u64, u64)> = None; // (candidate, delta)
97    for &c in candidates {
98        let delta = c.abs_diff(requested_pts);
99        // Equidistant candidates: prefer the one after the request (a splice
100        // is cut at or after the cue, never before it, when there is a
101        // choice) — so the result does not depend on slice order.
102        if nearest.is_none_or(|(best_c, best)| {
103            delta < best || (delta == best && c > requested_pts && best_c < requested_pts)
104        }) {
105            nearest = Some((c, delta));
106        }
107    }
108    let (snapped_pts, delta_ticks) = nearest.ok_or(Error::NoCandidates)?;
109    if delta_ticks > max_delta_ticks {
110        return Err(Error::NoAlignedBoundary {
111            requested_pts,
112            tolerance_ticks: max_delta_ticks,
113            nearest_delta_ticks: delta_ticks,
114        });
115    }
116    let direction = if snapped_pts == requested_pts {
117        SnapDirection::Exact
118    } else if snapped_pts < requested_pts {
119        SnapDirection::Before
120    } else {
121        SnapDirection::After
122    };
123    Ok(ConditionedSplicePoint {
124        requested_pts,
125        snapped_pts,
126        delta_ticks,
127        direction,
128    })
129}
130
131/// [`condition_splice_point`] for a **wrapping** clock — the 33-bit PTS of a
132/// transport stream or of a SCTE-35 `pts_time` (pass [`PTS_MODULUS_33`]) —
133/// issue #1125 / audit r14-SSAI-W2.
134///
135/// Distances and the snap direction are measured on the circle of
136/// `modulus` ticks: a cue at `2^33 - 100` whose nearest real boundary is at
137/// `50` (just after the wrap, about every 26.5 h on a 24/7 channel) is
138/// 150 ticks [`SnapDirection::After`], where the linear version measures
139/// ~8.6 × 10⁹ ticks and drops the break. The shorter way round wins; on an
140/// exact half-circle tie the forward ([`SnapDirection::After`]) way is
141/// taken. [`ConditionedSplicePoint::delta_ticks`] is that circular distance.
142///
143/// Returns [`Error::PtsOutOfRange`] if `modulus` is zero or `requested_pts`
144/// or any candidate is not `< modulus` (a value already wrapped is the
145/// contract; silently reducing it would hide an unrolled timeline).
146pub fn condition_splice_point_wrapping(
147    requested_pts: u64,
148    candidates: &[u64],
149    max_delta_ticks: u64,
150    modulus: u64,
151) -> Result<ConditionedSplicePoint> {
152    if modulus == 0 || requested_pts >= modulus {
153        return Err(Error::PtsOutOfRange {
154            pts: requested_pts,
155            modulus,
156        });
157    }
158    let m = u128::from(modulus);
159    let r = u128::from(requested_pts);
160    // (candidate, circular delta, forward?)
161    let mut nearest: Option<(u64, u64, bool)> = None;
162    for &c in candidates {
163        if c >= modulus {
164            return Err(Error::PtsOutOfRange { pts: c, modulus });
165        }
166        let cw = u128::from(c);
167        let forward = (cw + m - r) % m;
168        let backward = (r + m - cw) % m;
169        let (delta, is_forward) = if forward <= backward {
170            (forward, true)
171        } else {
172            (backward, false)
173        };
174        // `delta <= modulus / 2` always fits a u64; saturate rather than cast.
175        let delta = u64::try_from(delta).unwrap_or(u64::MAX);
176        // Equidistant candidates: prefer `After` (see
177        // `condition_splice_point`), independent of slice order.
178        if nearest.is_none_or(|(_, best, best_forward)| {
179            delta < best || (delta == best && is_forward && !best_forward)
180        }) {
181            nearest = Some((c, delta, is_forward));
182        }
183    }
184    let (snapped_pts, delta_ticks, is_forward) = nearest.ok_or(Error::NoCandidates)?;
185    if delta_ticks > max_delta_ticks {
186        return Err(Error::NoAlignedBoundary {
187            requested_pts,
188            tolerance_ticks: max_delta_ticks,
189            nearest_delta_ticks: delta_ticks,
190        });
191    }
192    let direction = if delta_ticks == 0 {
193        SnapDirection::Exact
194    } else if is_forward {
195        SnapDirection::After
196    } else {
197        SnapDirection::Before
198    };
199    Ok(ConditionedSplicePoint {
200        requested_pts,
201        snapped_pts,
202        delta_ticks,
203        direction,
204    })
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn snaps_to_nearest_candidate() {
213        let result = condition_splice_point(1_000, &[500, 1_050, 2_000], 100).unwrap();
214        assert_eq!(result.snapped_pts, 1_050);
215        assert_eq!(result.delta_ticks, 50);
216        assert_eq!(result.direction, SnapDirection::After);
217        assert!(!result.is_exact());
218    }
219
220    #[test]
221    fn exact_match_reports_zero_delta() {
222        let result = condition_splice_point(1_000, &[1_000], 0).unwrap();
223        assert_eq!(result.delta_ticks, 0);
224        assert_eq!(result.direction, SnapDirection::Exact);
225        assert!(result.is_exact());
226    }
227
228    #[test]
229    fn before_direction_when_candidate_precedes_request() {
230        let result = condition_splice_point(1_000, &[900], 200).unwrap();
231        assert_eq!(result.snapped_pts, 900);
232        assert_eq!(result.direction, SnapDirection::Before);
233    }
234
235    #[test]
236    fn rejects_a_candidate_outside_tolerance() {
237        let err = condition_splice_point(1_000, &[2_000], 500).unwrap_err();
238        match err {
239            Error::NoAlignedBoundary {
240                requested_pts,
241                tolerance_ticks,
242                nearest_delta_ticks,
243            } => {
244                assert_eq!(requested_pts, 1_000);
245                assert_eq!(tolerance_ticks, 500);
246                assert_eq!(nearest_delta_ticks, 1_000);
247            }
248            other => panic!("expected NoAlignedBoundary, got {other:?}"),
249        }
250    }
251
252    #[test]
253    fn rejects_empty_candidates() {
254        let err = condition_splice_point(1_000, &[], 500).unwrap_err();
255        assert!(matches!(err, Error::NoCandidates));
256    }
257
258    /// Audit r14-SSAI-W2: cue just before the 33-bit wrap, boundary just
259    /// after it. The linear function drops the break; the wrapping one
260    /// reports the true 150-tick forward distance.
261    #[test]
262    fn wrapping_snaps_across_the_33_bit_boundary() {
263        let cue = PTS_MODULUS_33 - 100;
264        assert!(matches!(
265            condition_splice_point(cue, &[50], 1_000),
266            Err(Error::NoAlignedBoundary { .. })
267        ));
268        let r = condition_splice_point_wrapping(cue, &[50], 1_000, PTS_MODULUS_33).unwrap();
269        assert_eq!(r.snapped_pts, 50);
270        assert_eq!(r.delta_ticks, 150);
271        assert_eq!(r.direction, SnapDirection::After);
272    }
273
274    #[test]
275    fn wrapping_direction_before_across_the_wrap() {
276        let r = condition_splice_point_wrapping(50, &[PTS_MODULUS_33 - 100], 1_000, PTS_MODULUS_33)
277            .unwrap();
278        assert_eq!(r.delta_ticks, 150);
279        assert_eq!(r.direction, SnapDirection::Before);
280    }
281
282    #[test]
283    fn wrapping_picks_nearest_among_both_sides_of_the_wrap() {
284        let cue = PTS_MODULUS_33 - 100;
285        let r = condition_splice_point_wrapping(
286            cue,
287            &[PTS_MODULUS_33 - 500, 90, 4_000],
288            10_000,
289            PTS_MODULUS_33,
290        )
291        .unwrap();
292        assert_eq!(r.snapped_pts, 90);
293        assert_eq!(r.delta_ticks, 190);
294        let r = condition_splice_point_wrapping(
295            cue,
296            &[PTS_MODULUS_33 - 150, 90],
297            10_000,
298            PTS_MODULUS_33,
299        )
300        .unwrap();
301        assert_eq!(r.snapped_pts, PTS_MODULUS_33 - 150);
302        assert_eq!(r.delta_ticks, 50);
303        assert_eq!(r.direction, SnapDirection::Before);
304    }
305
306    #[test]
307    fn wrapping_exact_and_tolerance_and_tie() {
308        let r = condition_splice_point_wrapping(7, &[7], 0, PTS_MODULUS_33).unwrap();
309        assert_eq!(r.direction, SnapDirection::Exact);
310        assert!(r.is_exact());
311        let err = condition_splice_point_wrapping(PTS_MODULUS_33 - 100, &[50], 149, PTS_MODULUS_33)
312            .unwrap_err();
313        assert!(matches!(
314            err,
315            Error::NoAlignedBoundary {
316                nearest_delta_ticks: 150,
317                tolerance_ticks: 149,
318                ..
319            }
320        ));
321        let r = condition_splice_point_wrapping(0, &[5], 5, 10).unwrap();
322        assert_eq!((r.delta_ticks, r.direction), (5, SnapDirection::After));
323    }
324
325    #[test]
326    fn wrapping_rejects_out_of_range_inputs_and_empty_sets() {
327        assert!(matches!(
328            condition_splice_point_wrapping(PTS_MODULUS_33, &[1], 10, PTS_MODULUS_33),
329            Err(Error::PtsOutOfRange { .. })
330        ));
331        assert!(matches!(
332            condition_splice_point_wrapping(1, &[PTS_MODULUS_33], 10, PTS_MODULUS_33),
333            Err(Error::PtsOutOfRange { .. })
334        ));
335        assert!(matches!(
336            condition_splice_point_wrapping(0, &[0], 10, 0),
337            Err(Error::PtsOutOfRange { .. })
338        ));
339        assert!(matches!(
340            condition_splice_point_wrapping(1, &[], 10, PTS_MODULUS_33),
341            Err(Error::NoCandidates)
342        ));
343        // Hostile: a modulus near u64::MAX must not overflow.
344        let r = condition_splice_point_wrapping(u64::MAX - 2, &[1], u64::MAX, u64::MAX).unwrap();
345        assert_eq!(r.delta_ticks, 3);
346    }
347
348    /// Two candidates equally far either side of the cue: `After` wins, in
349    /// either slice order, for both the linear and the wrapping form.
350    #[test]
351    fn an_equidistant_tie_prefers_after_regardless_of_order() {
352        for candidates in [[900u64, 1_100], [1_100, 900]] {
353            let r = condition_splice_point(1_000, &candidates, 200).unwrap();
354            assert_eq!((r.snapped_pts, r.direction), (1_100, SnapDirection::After));
355        }
356        for candidates in [[900u64, 1_100], [1_100, 900]] {
357            let r =
358                condition_splice_point_wrapping(1_000, &candidates, 200, PTS_MODULUS_33).unwrap();
359            assert_eq!((r.snapped_pts, r.direction), (1_100, SnapDirection::After));
360        }
361        // Across the wrap: 100 ticks either side of 0.
362        for candidates in [[PTS_MODULUS_33 - 100, 100], [100, PTS_MODULUS_33 - 100]] {
363            let r = condition_splice_point_wrapping(0, &candidates, 200, PTS_MODULUS_33).unwrap();
364            assert_eq!((r.snapped_pts, r.direction), (100, SnapDirection::After));
365        }
366    }
367
368    #[test]
369    fn direction_labels() {
370        assert_eq!(SnapDirection::Exact.name(), "exact");
371        assert_eq!(alloc::format!("{}", SnapDirection::After), "after");
372    }
373}