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/// Where the chosen boundary landed relative to the requested instant.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum SnapDirection {
28    /// The candidate matches the requested instant exactly.
29    Exact,
30    /// The candidate is before the requested instant.
31    Before,
32    /// The candidate is after the requested instant — the common case for a
33    /// real, non-IDR-aligned cue (see the module docs).
34    After,
35}
36
37impl SnapDirection {
38    /// Stable label.
39    pub fn name(&self) -> &'static str {
40        match self {
41            SnapDirection::Exact => "exact",
42            SnapDirection::Before => "before",
43            SnapDirection::After => "after",
44        }
45    }
46}
47broadcast_common::impl_spec_display!(SnapDirection);
48
49/// The result of conditioning one splice point against a set of candidate
50/// boundaries (segment starts, or sync-sample/IDR timestamps).
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct ConditionedSplicePoint {
53    /// The cue's nominal target, in whatever clock unit the caller's
54    /// candidates share (this module does no unit conversion).
55    pub requested_pts: u64,
56    /// The nearest candidate boundary actually chosen.
57    pub snapped_pts: u64,
58    /// `|snapped_pts - requested_pts|`.
59    pub delta_ticks: u64,
60    /// Whether the snap landed before, after, or exactly on the request.
61    pub direction: SnapDirection,
62}
63
64impl ConditionedSplicePoint {
65    /// Whether the snap was exact (no drift at all).
66    pub fn is_exact(&self) -> bool {
67        self.delta_ticks == 0
68    }
69}
70
71/// Find the candidate boundary nearest `requested_pts`, and error out if the
72/// nearest one is farther than `max_delta_ticks` away — the caller decides
73/// how much drift is tolerable (a live low-latency splice may need a tight
74/// bound; a VOD ad break can tolerate a full GOP).
75///
76/// `candidates` need not be sorted; every entry is checked. The candidate
77/// sets this crate is used with (segment or sync-sample boundaries within
78/// one GOP of the cue) are small enough that a linear scan is the right
79/// tool: no allocation, no requirement that the caller maintain sorted
80/// order.
81///
82/// Returns [`Error::NoCandidates`] if `candidates` is empty, and
83/// [`Error::NoAlignedBoundary`] if the nearest candidate exceeds
84/// `max_delta_ticks` — conditioning never silently accepts a splice point
85/// nothing is actually close to.
86pub fn condition_splice_point(
87    requested_pts: u64,
88    candidates: &[u64],
89    max_delta_ticks: u64,
90) -> Result<ConditionedSplicePoint> {
91    let mut nearest: Option<(u64, u64)> = None; // (candidate, delta)
92    for &c in candidates {
93        let delta = c.abs_diff(requested_pts);
94        if nearest.is_none_or(|(_, best)| delta < best) {
95            nearest = Some((c, delta));
96        }
97    }
98    let (snapped_pts, delta_ticks) = nearest.ok_or(Error::NoCandidates)?;
99    if delta_ticks > max_delta_ticks {
100        return Err(Error::NoAlignedBoundary {
101            requested_pts,
102            tolerance_ticks: max_delta_ticks,
103            nearest_delta_ticks: delta_ticks,
104        });
105    }
106    let direction = if snapped_pts == requested_pts {
107        SnapDirection::Exact
108    } else if snapped_pts < requested_pts {
109        SnapDirection::Before
110    } else {
111        SnapDirection::After
112    };
113    Ok(ConditionedSplicePoint {
114        requested_pts,
115        snapped_pts,
116        delta_ticks,
117        direction,
118    })
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn snaps_to_nearest_candidate() {
127        let result = condition_splice_point(1_000, &[500, 1_050, 2_000], 100).unwrap();
128        assert_eq!(result.snapped_pts, 1_050);
129        assert_eq!(result.delta_ticks, 50);
130        assert_eq!(result.direction, SnapDirection::After);
131        assert!(!result.is_exact());
132    }
133
134    #[test]
135    fn exact_match_reports_zero_delta() {
136        let result = condition_splice_point(1_000, &[1_000], 0).unwrap();
137        assert_eq!(result.delta_ticks, 0);
138        assert_eq!(result.direction, SnapDirection::Exact);
139        assert!(result.is_exact());
140    }
141
142    #[test]
143    fn before_direction_when_candidate_precedes_request() {
144        let result = condition_splice_point(1_000, &[900], 200).unwrap();
145        assert_eq!(result.snapped_pts, 900);
146        assert_eq!(result.direction, SnapDirection::Before);
147    }
148
149    #[test]
150    fn rejects_a_candidate_outside_tolerance() {
151        let err = condition_splice_point(1_000, &[2_000], 500).unwrap_err();
152        match err {
153            Error::NoAlignedBoundary {
154                requested_pts,
155                tolerance_ticks,
156                nearest_delta_ticks,
157            } => {
158                assert_eq!(requested_pts, 1_000);
159                assert_eq!(tolerance_ticks, 500);
160                assert_eq!(nearest_delta_ticks, 1_000);
161            }
162            other => panic!("expected NoAlignedBoundary, got {other:?}"),
163        }
164    }
165
166    #[test]
167    fn rejects_empty_candidates() {
168        let err = condition_splice_point(1_000, &[], 500).unwrap_err();
169        assert!(matches!(err, Error::NoCandidates));
170    }
171
172    #[test]
173    fn direction_labels() {
174        assert_eq!(SnapDirection::Exact.name(), "exact");
175        assert_eq!(alloc::format!("{}", SnapDirection::After), "after");
176    }
177}