Skip to main content

playout_runtime/
scte35.rs

1//! SCTE-35 emission points a transition implies (issue
2//! [#748](https://github.com/fishloa/rust-broadcast/issues/748)):
3//! `splice_insert()` — ANSI/SCTE 35 2023r1 §9.7.3, Table 10 — built and
4//! serialized with `scte35-splice`'s own
5//! [`Serialize`](broadcast_common::Serialize), not hand-assembled bytes.
6//!
7//! This module decides *what* cue to emit and *where* (after conditioning);
8//! it does not decide *how the splice lands* against real segment/keyframe
9//! boundaries — that is [`ssai_runtime::splice::condition_splice_point_wrapping`],
10//! reused here rather than re-implemented, per the crate-root docs. Two
11//! implementations of boundary conditioning could disagree about the same
12//! boundary, which is exactly the bug class this workspace keeps finding.
13
14use crate::error::Result;
15use alloc::vec::Vec;
16use scte35_splice::SpliceInfoSection;
17use scte35_splice::commands::AnyCommand;
18use scte35_splice::commands::SpliceInsert;
19use scte35_splice::time::{BreakDuration, SpliceTime};
20pub use ssai_runtime::splice::ConditionedSplicePoint;
21use ssai_runtime::splice::PTS_MODULUS_33;
22
23/// Which edge of an ad break a transition represents.
24///
25/// A [`crate::schedule::Schedule`] alone (Programme/Ad/Slate) does not
26/// disambiguate "ad -> ad" within a multi-spot break from "ad -> programme"
27/// return, so the caller supplies this explicitly rather than this crate
28/// guessing it from entry kinds.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum BreakEdge {
32    /// Entering a break — `out_of_network_indicator = true` (§9.7.3).
33    Enter,
34    /// Returning from a break — `out_of_network_indicator = false`.
35    Return,
36}
37
38impl BreakEdge {
39    /// Stable label.
40    pub fn name(&self) -> &'static str {
41        match self {
42            BreakEdge::Enter => "enter",
43            BreakEdge::Return => "return",
44        }
45    }
46}
47broadcast_common::impl_spec_display!(BreakEdge);
48
49/// Build a `splice_insert()` command for a transition, snapping its target
50/// instant onto the nearest of `candidates` (real segment or keyframe
51/// boundaries) within `max_delta_ticks` of `requested_pts` — see
52/// [`ssai_runtime::splice::condition_splice_point_wrapping`], which this delegates to
53/// rather than duplicating. Errors (via [`crate::Error::SpliceConditioning`])
54/// if no candidate is close enough, or none are supplied: this crate never
55/// emits a cue for a splice point nothing is actually close to.
56///
57/// Distances are measured modulo 2^33 (the width of the SCTE-35 `pts_time`):
58/// `delta_ticks`/`direction` are circular (a cue at `2^33 - 100` snapping to a
59/// boundary at `50` is 150 ticks `After`). The returned
60/// [`ConditionedSplicePoint`] reports `requested_pts` and `snapped_pts` in the
61/// caller's own units (an unwrapped channel clock stays unwrapped).
62///
63/// `break_duration_ticks`, if given (and below 2^33, the width of the
64/// `break_duration()` field, else [`crate::Error::BreakDurationTooLong`]), sets `break_duration().duration` with
65/// `auto_return = true` (the splicer returns to the network feed on its own
66/// once the duration elapses — §9.7.3).
67///
68/// # Units (mandatory — PLAY-W1, #1126)
69///
70/// `requested_pts`, every entry of `candidates`, `max_delta_ticks` and
71/// `break_duration_ticks` **must** already be counts of 90 kHz ticks:
72/// `splice_time()`/`break_duration()` are fixed 90 kHz fields (ANSI/SCTE 35
73/// 2023r1 §9.8.1/§9.8.2), and this function passes every one of these values
74/// straight through to [`SpliceTime::with_pts`]/[`BreakDuration`] with no
75/// unit conversion or validation. Passing a channel clock in any other unit
76/// (27 MHz, milliseconds, nanoseconds, …) produces a structurally valid cue
77/// that is silently wrong by that unit's fixed conversion factor. Convert
78/// with `scte35_splice::time::duration_to_ticks` first if the channel clock
79/// isn't already 90 kHz.
80pub fn build_splice_insert(
81    edge: BreakEdge,
82    splice_event_id: u32,
83    requested_pts: u64,
84    candidates: &[u64],
85    max_delta_ticks: u64,
86    break_duration_ticks: Option<u64>,
87) -> Result<(ConditionedSplicePoint, SpliceInsert)> {
88    if let Some(ticks) = break_duration_ticks
89        && ticks >= PTS_MODULUS_33
90    {
91        return Err(crate::Error::BreakDurationTooLong { ticks });
92    }
93    // `splice_time().pts_time` is a 33-bit field (ANSI/SCTE 35 §9.8.1;
94    // `SpliceTime::with_pts` masks to it), so the instant a cue names — and the
95    // boundary it snaps to — live on the circle of 2^33 ticks, wrapping about
96    // every 26.5 h on a 24/7 channel: a cue just before the wrap whose nearest
97    // real boundary is just after it is 150 ticks away, not ~8.6e9. Reduce
98    // every input onto that circle and measure the circular distance
99    // (ssai-runtime audit r14-SSAI-W2).
100    let reduced: Vec<u64> = candidates.iter().map(|c| c % PTS_MODULUS_33).collect();
101    let mut conditioned = ssai_runtime::splice::condition_splice_point_wrapping(
102        requested_pts % PTS_MODULUS_33,
103        &reduced,
104        max_delta_ticks,
105        PTS_MODULUS_33,
106    )?;
107    // Report in the caller's units: the instant they asked for and the
108    // candidate they supplied (an unwrapped channel clock stays unwrapped),
109    // with the circular delta/direction measured above.
110    if let Some(original) = candidates
111        .iter()
112        .zip(&reduced)
113        .find_map(|(orig, red)| (*red == conditioned.snapped_pts).then_some(*orig))
114    {
115        conditioned.snapped_pts = original;
116    }
117    conditioned.requested_pts = requested_pts;
118    let insert = SpliceInsert {
119        splice_event_id,
120        splice_event_cancel_indicator: false,
121        out_of_network_indicator: matches!(edge, BreakEdge::Enter),
122        program_splice_flag: true,
123        splice_immediate_flag: false,
124        event_id_compliance_flag: true,
125        splice_time: Some(SpliceTime::with_pts(conditioned.snapped_pts)),
126        components: Vec::new(),
127        break_duration: break_duration_ticks.map(|duration| BreakDuration {
128            auto_return: true,
129            duration,
130        }),
131        unique_program_id: 0,
132        avail_num: 0,
133        avails_expected: 0,
134    };
135    Ok((conditioned, insert))
136}
137
138/// Wrap a built [`SpliceInsert`] into a clear (unencrypted)
139/// `splice_info_section()`, ready to serialize
140/// (`broadcast_common::Serialize::to_bytes`) onto the SCTE-35 elementary
141/// stream/PID.
142#[must_use]
143pub fn to_section<'a>(insert: SpliceInsert) -> SpliceInfoSection<'a> {
144    SpliceInfoSection::new_clear(AnyCommand::SpliceInsert(insert), &[])
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use broadcast_common::{Parse, Serialize};
151    use ssai_runtime::Error as SsaiError;
152
153    #[test]
154    fn builds_an_enter_break_cue_snapped_to_the_conditioned_point() {
155        let (conditioned, insert) = build_splice_insert(
156            BreakEdge::Enter,
157            7,
158            1_000,
159            &[1_050, 2_000],
160            100,
161            Some(1_800_000),
162        )
163        .unwrap();
164        assert_eq!(conditioned.snapped_pts, 1_050);
165        assert_eq!(insert.splice_event_id, 7);
166        assert!(insert.out_of_network_indicator);
167        assert_eq!(insert.splice_time.unwrap().pts_time, Some(1_050));
168        assert_eq!(insert.break_duration.unwrap().duration, 1_800_000);
169        assert!(insert.break_duration.unwrap().auto_return);
170
171        // The cue must reflect the *conditioned* point, not the raw request
172        // — proves this isn't a passthrough of `requested_pts`.
173        assert_ne!(insert.splice_time.unwrap().pts_time, Some(1_000));
174    }
175
176    #[test]
177    fn a_break_duration_past_33_bits_is_rejected_at_construction() {
178        let max = PTS_MODULUS_33 - 1;
179        assert!(build_splice_insert(BreakEdge::Enter, 1, 1_000, &[1_000], 0, Some(max)).is_ok());
180        let err = build_splice_insert(
181            BreakEdge::Enter,
182            1,
183            1_000,
184            &[1_000],
185            0,
186            Some(PTS_MODULUS_33),
187        )
188        .unwrap_err();
189        assert!(matches!(
190            err,
191            crate::Error::BreakDurationTooLong { ticks } if ticks == PTS_MODULUS_33
192        ));
193    }
194
195    #[test]
196    fn builds_a_return_break_cue_with_no_duration() {
197        let (_, insert) =
198            build_splice_insert(BreakEdge::Return, 8, 1_000, &[1_000], 0, None).unwrap();
199        assert!(!insert.out_of_network_indicator);
200        assert!(insert.break_duration.is_none());
201    }
202
203    #[test]
204    fn rejects_a_cue_with_no_boundary_within_tolerance() {
205        let err = build_splice_insert(BreakEdge::Enter, 1, 1_000, &[5_000], 10, None).unwrap_err();
206        match err {
207            crate::Error::SpliceConditioning(SsaiError::NoAlignedBoundary {
208                requested_pts,
209                tolerance_ticks,
210                nearest_delta_ticks,
211            }) => {
212                assert_eq!(requested_pts, 1_000);
213                assert_eq!(tolerance_ticks, 10);
214                assert_eq!(nearest_delta_ticks, 4_000);
215            }
216            other => panic!("expected SpliceConditioning(NoAlignedBoundary), got {other:?}"),
217        }
218    }
219
220    #[test]
221    fn section_round_trips_through_scte35_splices_own_serialize() {
222        let (_, insert) =
223            build_splice_insert(BreakEdge::Enter, 42, 1_000, &[1_000], 0, Some(900_000)).unwrap();
224        let section = to_section(insert);
225        let bytes = section.try_to_bytes().unwrap();
226        assert_eq!(bytes[0], scte35_splice::section::TABLE_ID);
227
228        let parsed = SpliceInfoSection::parse(&bytes).unwrap();
229        match parsed.clear.unwrap().command {
230            AnyCommand::SpliceInsert(reparsed) => {
231                assert_eq!(reparsed.splice_event_id, 42);
232                assert_eq!(reparsed.splice_time.unwrap().pts_time, Some(1_000));
233                assert_eq!(reparsed.break_duration.unwrap().duration, 900_000);
234            }
235            other => panic!("expected SpliceInsert, got {other:?}"),
236        }
237    }
238
239    #[test]
240    fn label_convention() {
241        assert_eq!(BreakEdge::Enter.name(), "enter");
242        assert_eq!(alloc::format!("{}", BreakEdge::Return), "return");
243    }
244
245    /// PLAY-W1 follow-up: the cue's `pts_time` is 33-bit, so a requested
246    /// instant just before the wrap whose nearest real boundary is just after
247    /// it is a 150-tick `After` snap, not an out-of-tolerance miss.
248    #[test]
249    fn a_cue_before_the_33_bit_wrap_snaps_to_a_boundary_after_it() {
250        let requested = PTS_MODULUS_33 - 100;
251        let (conditioned, insert) =
252            build_splice_insert(BreakEdge::Enter, 9, requested, &[50], 1_000, None).unwrap();
253        assert_eq!(conditioned.snapped_pts, 50);
254        assert_eq!(conditioned.delta_ticks, 150);
255        assert_eq!(conditioned.direction, ssai_runtime::SnapDirection::After);
256        assert_eq!(insert.splice_time.unwrap().pts_time, Some(50));
257        // Too tight a tolerance is still refused (circularly measured).
258        assert!(matches!(
259            build_splice_insert(BreakEdge::Enter, 9, requested, &[50], 149, None),
260            Err(crate::Error::SpliceConditioning(
261                SsaiError::NoAlignedBoundary {
262                    nearest_delta_ticks: 150,
263                    ..
264                }
265            ))
266        ));
267    }
268
269    /// A channel clock already unrolled past 2^33 is reduced onto the
270    /// SCTE-35 circle before conditioning: boundary just before the wrap,
271    /// request just after it, in unwrapped terms.
272    #[test]
273    fn an_unwrapped_channel_clock_is_reduced_to_the_33_bit_circle() {
274        let requested = PTS_MODULUS_33 + 10;
275        let (conditioned, _) = build_splice_insert(
276            BreakEdge::Return,
277            1,
278            requested,
279            &[PTS_MODULUS_33 - 5],
280            1_000,
281            None,
282        )
283        .unwrap();
284        // Reported in the caller's (unwrapped) units.
285        assert_eq!(conditioned.requested_pts, requested);
286        assert_eq!(conditioned.snapped_pts, PTS_MODULUS_33 - 5);
287        assert_eq!(conditioned.delta_ticks, 15);
288        assert_eq!(conditioned.direction, ssai_runtime::SnapDirection::Before);
289    }
290}