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`],
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;
21
22/// Which edge of an ad break a transition represents.
23///
24/// A [`crate::schedule::Schedule`] alone (Programme/Ad/Slate) does not
25/// disambiguate "ad -> ad" within a multi-spot break from "ad -> programme"
26/// return, so the caller supplies this explicitly rather than this crate
27/// guessing it from entry kinds.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum BreakEdge {
31    /// Entering a break — `out_of_network_indicator = true` (§9.7.3).
32    Enter,
33    /// Returning from a break — `out_of_network_indicator = false`.
34    Return,
35}
36
37impl BreakEdge {
38    /// Stable label.
39    pub fn name(&self) -> &'static str {
40        match self {
41            BreakEdge::Enter => "enter",
42            BreakEdge::Return => "return",
43        }
44    }
45}
46broadcast_common::impl_spec_display!(BreakEdge);
47
48/// Build a `splice_insert()` command for a transition, snapping its target
49/// instant onto the nearest of `candidates` (real segment or keyframe
50/// boundaries) within `max_delta_ticks` of `requested_pts` — see
51/// [`ssai_runtime::splice::condition_splice_point`], which this delegates to
52/// rather than duplicating. Errors (via [`crate::Error::SpliceConditioning`])
53/// if no candidate is close enough, or none are supplied: this crate never
54/// emits a cue for a splice point nothing is actually close to.
55///
56/// `break_duration_ticks`, if given, sets `break_duration().duration` with
57/// `auto_return = true` (the splicer returns to the network feed on its own
58/// once the duration elapses — §9.7.3).
59pub fn build_splice_insert(
60    edge: BreakEdge,
61    splice_event_id: u32,
62    requested_pts: u64,
63    candidates: &[u64],
64    max_delta_ticks: u64,
65    break_duration_ticks: Option<u64>,
66) -> Result<(ConditionedSplicePoint, SpliceInsert)> {
67    let conditioned =
68        ssai_runtime::splice::condition_splice_point(requested_pts, candidates, max_delta_ticks)?;
69    let insert = SpliceInsert {
70        splice_event_id,
71        splice_event_cancel_indicator: false,
72        out_of_network_indicator: matches!(edge, BreakEdge::Enter),
73        program_splice_flag: true,
74        splice_immediate_flag: false,
75        event_id_compliance_flag: true,
76        splice_time: Some(SpliceTime::with_pts(conditioned.snapped_pts)),
77        components: Vec::new(),
78        break_duration: break_duration_ticks.map(|duration| BreakDuration {
79            auto_return: true,
80            duration,
81        }),
82        unique_program_id: 0,
83        avail_num: 0,
84        avails_expected: 0,
85    };
86    Ok((conditioned, insert))
87}
88
89/// Wrap a built [`SpliceInsert`] into a clear (unencrypted)
90/// `splice_info_section()`, ready to serialize
91/// (`broadcast_common::Serialize::to_bytes`) onto the SCTE-35 elementary
92/// stream/PID.
93#[must_use]
94pub fn to_section<'a>(insert: SpliceInsert) -> SpliceInfoSection<'a> {
95    SpliceInfoSection::new_clear(AnyCommand::SpliceInsert(insert), &[])
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use broadcast_common::{Parse, Serialize};
102    use ssai_runtime::Error as SsaiError;
103
104    #[test]
105    fn builds_an_enter_break_cue_snapped_to_the_conditioned_point() {
106        let (conditioned, insert) = build_splice_insert(
107            BreakEdge::Enter,
108            7,
109            1_000,
110            &[1_050, 2_000],
111            100,
112            Some(1_800_000),
113        )
114        .unwrap();
115        assert_eq!(conditioned.snapped_pts, 1_050);
116        assert_eq!(insert.splice_event_id, 7);
117        assert!(insert.out_of_network_indicator);
118        assert_eq!(insert.splice_time.unwrap().pts_time, Some(1_050));
119        assert_eq!(insert.break_duration.unwrap().duration, 1_800_000);
120        assert!(insert.break_duration.unwrap().auto_return);
121
122        // The cue must reflect the *conditioned* point, not the raw request
123        // — proves this isn't a passthrough of `requested_pts`.
124        assert_ne!(insert.splice_time.unwrap().pts_time, Some(1_000));
125    }
126
127    #[test]
128    fn builds_a_return_break_cue_with_no_duration() {
129        let (_, insert) =
130            build_splice_insert(BreakEdge::Return, 8, 1_000, &[1_000], 0, None).unwrap();
131        assert!(!insert.out_of_network_indicator);
132        assert!(insert.break_duration.is_none());
133    }
134
135    #[test]
136    fn rejects_a_cue_with_no_boundary_within_tolerance() {
137        let err = build_splice_insert(BreakEdge::Enter, 1, 1_000, &[5_000], 10, None).unwrap_err();
138        match err {
139            crate::Error::SpliceConditioning(SsaiError::NoAlignedBoundary {
140                requested_pts,
141                tolerance_ticks,
142                nearest_delta_ticks,
143            }) => {
144                assert_eq!(requested_pts, 1_000);
145                assert_eq!(tolerance_ticks, 10);
146                assert_eq!(nearest_delta_ticks, 4_000);
147            }
148            other => panic!("expected SpliceConditioning(NoAlignedBoundary), got {other:?}"),
149        }
150    }
151
152    #[test]
153    fn section_round_trips_through_scte35_splices_own_serialize() {
154        let (_, insert) =
155            build_splice_insert(BreakEdge::Enter, 42, 1_000, &[1_000], 0, Some(900_000)).unwrap();
156        let section = to_section(insert);
157        let bytes = section.to_bytes();
158        assert_eq!(bytes[0], scte35_splice::section::TABLE_ID);
159
160        let parsed = SpliceInfoSection::parse(&bytes).unwrap();
161        match parsed.clear.unwrap().command {
162            AnyCommand::SpliceInsert(reparsed) => {
163                assert_eq!(reparsed.splice_event_id, 42);
164                assert_eq!(reparsed.splice_time.unwrap().pts_time, Some(1_000));
165                assert_eq!(reparsed.break_duration.unwrap().duration, 900_000);
166            }
167            other => panic!("expected SpliceInsert, got {other:?}"),
168        }
169    }
170
171    #[test]
172    fn label_convention() {
173        assert_eq!(BreakEdge::Enter.name(), "enter");
174        assert_eq!(alloc::format!("{}", BreakEdge::Return), "return");
175    }
176}