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}