Skip to main content

sheathe_package/
scte35.rs

1//! Minimal SCTE-35 splice_info_section builder for ad markers.
2//!
3//! Produces a well-formed binary `splice_info_section` carrying a
4//! `splice_insert` command (cue-out / cue-in). Enough for packaging pipelines
5//! to signal ad breaks via DASH `EventStream` and HLS `EXT-X-DATERANGE` without
6//! a full SCTE-35 stack. The CRC-32 is computed per MPEG-2 Systems (ISO 13818-1
7//! annex A).
8
9/// A SCTE-35 cue placement relative to the presentation timeline.
10#[derive(Debug, Clone)]
11pub struct Scte35Marker {
12    /// Presentation time in seconds from the period / asset start.
13    pub time_seconds: f64,
14    /// Cue-out (ad start) when true; cue-in (ad end / return-to-network) when false.
15    pub out_of_network: bool,
16    /// Optional event id (defaults to a hash of the time).
17    pub event_id: Option<u32>,
18    /// Optional break duration in seconds (cue-out only).
19    pub break_duration_seconds: Option<f64>,
20}
21
22impl Scte35Marker {
23    /// Cue-out (splice into ad) at `time_seconds`.
24    pub fn cue_out(time_seconds: f64) -> Self {
25        Self { time_seconds, out_of_network: true, event_id: None, break_duration_seconds: None }
26    }
27
28    /// Cue-in (return to network) at `time_seconds`.
29    pub fn cue_in(time_seconds: f64) -> Self {
30        Self { time_seconds, out_of_network: false, event_id: None, break_duration_seconds: None }
31    }
32}
33
34/// Build a binary SCTE-35 `splice_info_section` for `marker`.
35///
36/// Uses `pts_adjustment = 0` and signals the splice time as PTS at 90 kHz
37/// (`time_seconds * 90000`). Suitable for embedding as DASH Event message data
38/// (base64) or HLS `SCTE35-OUT`/`SCTE35-IN` (hex with `0x` prefix).
39pub fn build_splice_insert(marker: &Scte35Marker) -> Vec<u8> {
40    let event_id = marker.event_id.unwrap_or_else(|| {
41        // Stable-ish default from time (milliseconds).
42        (marker.time_seconds * 1000.0).round() as u32
43    });
44    let pts = (marker.time_seconds * 90_000.0).round() as u64 & 0x1_ffff_ffff; // 33-bit PTS
45
46    // splice_insert body (without section header / CRC).
47    let mut body = Vec::new();
48    // splice_event_id
49    body.extend_from_slice(&event_id.to_be_bytes());
50    // splice_event_cancel_indicator(1)=0 | reserved(7)=0x7f
51    body.push(0x7f);
52    // out_of_network_indicator(1) | program_splice_flag(1)=1 | duration_flag(1)
53    // | splice_immediate_flag(1)=0 | reserved(4)=0xf
54    let duration_flag = marker.break_duration_seconds.is_some() && marker.out_of_network;
55    let mut flags: u8 = 0b0100_1111; // program_splice=1, immediate=0, reserved
56    if marker.out_of_network {
57        flags |= 0b1000_0000;
58    }
59    if duration_flag {
60        flags |= 0b0010_0000;
61    }
62    body.push(flags);
63
64    // program splice time: time_specified_flag(1)=1 | reserved(6)=0x3f | pts_time(33)
65    // Encoded as 5 bytes: 1 bit flag + 6 reserved + 33 pts = 40 bits.
66    let time_word: u64 = (1u64 << 39) | (0x3f << 33) | pts;
67    body.push(((time_word >> 32) & 0xff) as u8);
68    body.push(((time_word >> 24) & 0xff) as u8);
69    body.push(((time_word >> 16) & 0xff) as u8);
70    body.push(((time_word >> 8) & 0xff) as u8);
71    body.push((time_word & 0xff) as u8);
72
73    if duration_flag {
74        // break_duration: auto_return(1)=1 | reserved(6)=0x3f | duration(33) @ 90kHz
75        let dur_pts = (marker.break_duration_seconds.unwrap_or(0.0) * 90_000.0).round() as u64
76            & 0x1_ffff_ffff;
77        let dur_word: u64 = (1u64 << 39) | (0x3f << 33) | dur_pts;
78        body.push(((dur_word >> 32) & 0xff) as u8);
79        body.push(((dur_word >> 24) & 0xff) as u8);
80        body.push(((dur_word >> 16) & 0xff) as u8);
81        body.push(((dur_word >> 8) & 0xff) as u8);
82        body.push((dur_word & 0xff) as u8);
83    }
84
85    // unique_program_id (16), avail_num (8), avails_expected (8)
86    body.extend_from_slice(&0u16.to_be_bytes());
87    body.push(0);
88    body.push(0);
89
90    // Section: table_id(8)=0xFC | section_syntax_indicator(1)=0 | private(1)=0
91    // | reserved(2)=3 | section_length(12) | protocol_version(8)=0
92    // | encrypted_packet(1)=0 | encryption_algorithm(6)=0 | pts_adjustment(33)=0
93    // | cw_index(8)=0 | tier(12)=0xfff | splice_command_length(12)
94    // | splice_command_type(8)=5 (splice_insert) | command | descriptor_loop_length(16)=0
95    // | CRC_32(32)
96
97    let command_type: u8 = 5; // splice_insert
98    let command_len = body.len() as u16;
99    // Bytes after section_length field, before CRC:
100    // protocol(1) + encrypt/pts_adj(5) + cw(1) + tier/cmd_len(3) + cmd_type(1)
101    // + body + descriptor_loop(2)
102    let section_payload_len = 1 + 5 + 1 + 3 + 1 + body.len() + 2;
103    let section_length = (section_payload_len + 4) as u16; // + CRC
104
105    let mut section = Vec::with_capacity(3 + section_payload_len + 4);
106    section.push(0xFC); // table_id
107    // section_syntax=0, private=0, reserved=3, section_length
108    let sl = (0b0011_0000_0000_0000) | (section_length & 0x0fff);
109    section.extend_from_slice(&sl.to_be_bytes());
110    section.push(0); // protocol_version
111    // encrypted_packet=0 | encryption_algorithm=0 | pts_adjustment (33 bits of 0)
112    // 40 bits: 1+6+33 packed into 5 bytes starting with reserved high bits.
113    section.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00]);
114    section.push(0); // cw_index
115    // tier (12 bits of 1) | splice_command_length (12 bits)
116    let tier_cmd = (0x0fffu32 << 12) | (u32::from(command_len) & 0x0fff);
117    section.push(((tier_cmd >> 16) & 0xff) as u8);
118    section.push(((tier_cmd >> 8) & 0xff) as u8);
119    section.push((tier_cmd & 0xff) as u8);
120    section.push(command_type);
121    section.extend_from_slice(&body);
122    section.extend_from_slice(&0u16.to_be_bytes()); // descriptor_loop_length
123
124    let crc = mpeg_crc32(&section);
125    section.extend_from_slice(&crc.to_be_bytes());
126    section
127}
128
129/// Hex encoding with a `0x` prefix (HLS `SCTE35-OUT` style).
130pub fn to_hex_0x(bytes: &[u8]) -> String {
131    let mut s = String::from("0x");
132    for b in bytes {
133        s.push_str(&format!("{b:02X}"));
134    }
135    s
136}
137
138/// Standard base64 (no padding strip) for DASH Event message data.
139pub fn to_base64(bytes: &[u8]) -> String {
140    // Minimal base64 encoder — avoids a dependency for a single encode site.
141    const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
142    let mut out = String::new();
143    let mut i = 0;
144    while i + 3 <= bytes.len() {
145        let n =
146            (u32::from(bytes[i]) << 16) | (u32::from(bytes[i + 1]) << 8) | u32::from(bytes[i + 2]);
147        out.push(T[((n >> 18) & 63) as usize] as char);
148        out.push(T[((n >> 12) & 63) as usize] as char);
149        out.push(T[((n >> 6) & 63) as usize] as char);
150        out.push(T[(n & 63) as usize] as char);
151        i += 3;
152    }
153    let rest = bytes.len() - i;
154    if rest == 1 {
155        let n = u32::from(bytes[i]) << 16;
156        out.push(T[((n >> 18) & 63) as usize] as char);
157        out.push(T[((n >> 12) & 63) as usize] as char);
158        out.push('=');
159        out.push('=');
160    } else if rest == 2 {
161        let n = (u32::from(bytes[i]) << 16) | (u32::from(bytes[i + 1]) << 8);
162        out.push(T[((n >> 18) & 63) as usize] as char);
163        out.push(T[((n >> 12) & 63) as usize] as char);
164        out.push(T[((n >> 6) & 63) as usize] as char);
165        out.push('=');
166    }
167    out
168}
169
170/// MPEG-2 CRC-32 (ISO/IEC 13818-1 annex A), poly 0x04C11DB7, init 0xFFFFFFFF.
171fn mpeg_crc32(data: &[u8]) -> u32 {
172    let mut crc: u32 = 0xffff_ffff;
173    for &byte in data {
174        crc ^= u32::from(byte) << 24;
175        for _ in 0..8 {
176            if crc & 0x8000_0000 != 0 {
177                crc = (crc << 1) ^ 0x04C1_1DB7;
178            } else {
179                crc <<= 1;
180            }
181        }
182    }
183    crc
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn splice_insert_has_table_id_and_crc_length() {
192        let bytes = build_splice_insert(&Scte35Marker::cue_out(12.0));
193        assert_eq!(bytes[0], 0xFC);
194        // section_length covers remaining bytes after the 3-byte header.
195        let section_length = u16::from_be_bytes([bytes[1] & 0x0f, bytes[2]]) as usize;
196        assert_eq!(bytes.len(), 3 + section_length);
197        // Command type splice_insert = 5 appears after the fixed header fields.
198        assert!(bytes.contains(&5));
199    }
200
201    #[test]
202    fn hex_and_base64_round_shapes() {
203        let bytes = build_splice_insert(&Scte35Marker::cue_in(30.0));
204        let hex = to_hex_0x(&bytes);
205        assert!(hex.starts_with("0xFC"));
206        let b64 = to_base64(&bytes);
207        assert!(!b64.is_empty());
208        assert!(b64.is_ascii());
209    }
210}