playout_runtime/schedule.rs
1//! The channel schedule: an ordered list of entries (programme / ad / slate).
2//!
3//! The wire/data format here is deliberately **ours to define** — issue
4//! [#748](https://github.com/fishloa/rust-broadcast/issues/748)'s
5//! design-decision comment: SCTE 224 (ESNI) covers policy/blackout
6//! signalling, not channel assembly, and adopting it would be cargo-culting a
7//! standard that does not answer this question. There is no applicable open
8//! standard for "ordered list of sources with planned starts" to transcribe,
9//! so [`Schedule`] is a plain in-memory model, not a wire type: it has no
10//! `Parse`/`Serialize` pair, and it is deliberately minimal — enough to plan
11//! [transitions](crate::transition) and the SCTE-35 cues they imply, nothing
12//! about rights, geography, or blackout rules.
13
14use crate::error::{Error, Result};
15use alloc::string::String;
16use alloc::vec::Vec;
17
18/// What kind of content a [`ScheduleEntry`] carries.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21#[non_exhaustive]
22pub enum EntryKind {
23 /// Regular programme content.
24 Programme,
25 /// An advertisement — a scheduled ad-break asset.
26 Ad,
27 /// Filler/slate content played when nothing else is scheduled (e.g. a
28 /// hold slide, or gap-filler between programme and ad).
29 Slate,
30}
31
32impl EntryKind {
33 /// Stable label.
34 pub fn name(&self) -> &'static str {
35 match self {
36 EntryKind::Programme => "programme",
37 EntryKind::Ad => "ad",
38 EntryKind::Slate => "slate",
39 }
40 }
41}
42broadcast_common::impl_spec_display!(EntryKind);
43
44/// Caller-supplied opaque identity for a source's codec configuration (its
45/// `avcC`/`hvcC`/`esds`, etc.), used only for equality comparison.
46///
47/// This crate never parses codec-configuration bitstreams — the "no
48/// transcoding or conforming" non-goal in issue #748's design decision — so
49/// it cannot compute this itself. The caller (whatever demuxed the source,
50/// e.g. `transmux`'s init-segment parsing) computes a stable fingerprint of
51/// its own config and passes it here; a change in fingerprint between
52/// consecutive entries is exactly what
53/// [`TransitionPlan::discontinuity`](crate::transition::TransitionPlan::discontinuity)
54/// treats as a discontinuity to signal downstream (e.g. via
55/// `broadcast_hls::mark_init_discontinuities`) — never something this crate
56/// re-encodes or conforms.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
58#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
59pub struct CodecConfigId(pub u64);
60
61/// One entry in the schedule: a source, its kind, and when it is planned to
62/// start on the shared channel timeline.
63#[derive(Debug, Clone, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65pub struct ScheduleEntry {
66 /// Caller-chosen identity for the source this entry plays (e.g. an asset
67 /// id or URI) — opaque to this crate beyond equality/display.
68 pub id: String,
69 /// What kind of content this entry is.
70 pub kind: EntryKind,
71 /// The channel-timeline instant this entry is scheduled to start at, in
72 /// the caller's clock unit.
73 ///
74 /// [`Schedule`]/[`crate::transition::TransitionPlan`] themselves never
75 /// interpret this unit — the rebase arithmetic is unit-agnostic. But if
76 /// this value (via [`crate::transition::TransitionPlan::rebase`]) ends up
77 /// passed to [`crate::scte35::build_splice_insert`] to emit a SCTE-35 cue,
78 /// it **must** already be 90 kHz ticks: `splice_time()`/`break_duration()`
79 /// are fixed 90 kHz fields (ANSI/SCTE 35 2023r1 §9.8.1/§9.8.2), and
80 /// `build_splice_insert` passes its inputs straight through with no unit
81 /// conversion or validation — a 27 MHz, millisecond, or nanosecond
82 /// channel clock produces a technically well-formed but silently
83 /// wrong-by-a-fixed-factor cue (PLAY-W1, #1126). Convert with
84 /// `scte35_splice::time::duration_to_ticks`/`ticks_to_duration` first if
85 /// the channel clock isn't already 90 kHz.
86 pub planned_start: u64,
87 /// The PTS value the source's own first presented sample carries, in the
88 /// same clock unit as [`Self::planned_start`] (and subject to the same
89 /// 90 kHz requirement before it reaches [`crate::scte35`]). Needed to
90 /// compute the PTS-rebase offset at the transition into this entry — see
91 /// [`crate::transition`].
92 pub source_start_pts: u64,
93 /// Codec-config identity, for discontinuity detection at the transition
94 /// into this entry.
95 pub codec_config: CodecConfigId,
96}
97
98/// An ordered schedule: entries strictly increasing by `planned_start`.
99///
100/// This crate does not sort entries for you — [`Schedule::push`] rejects an
101/// out-of-order entry immediately, so a caller building a schedule sees
102/// ordering mistakes at construction time rather than as a silently
103/// misordered playout later.
104#[derive(Debug, Clone, Default, PartialEq, Eq)]
105#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
106pub struct Schedule {
107 entries: Vec<ScheduleEntry>,
108}
109
110impl Schedule {
111 /// An empty schedule.
112 #[must_use]
113 pub fn new() -> Self {
114 Schedule {
115 entries: Vec::new(),
116 }
117 }
118
119 /// Append `entry`. Errors with [`Error::OutOfOrder`] if `entry` does not
120 /// start strictly after the schedule's current last entry.
121 pub fn push(&mut self, entry: ScheduleEntry) -> Result<()> {
122 if let Some(last) = self.entries.last()
123 && entry.planned_start <= last.planned_start
124 {
125 return Err(Error::OutOfOrder {
126 prev: last.planned_start,
127 next: entry.planned_start,
128 });
129 }
130 self.entries.push(entry);
131 Ok(())
132 }
133
134 /// The entries, in schedule order.
135 #[must_use]
136 pub fn entries(&self) -> &[ScheduleEntry] {
137 &self.entries
138 }
139
140 /// Number of entries.
141 #[must_use]
142 pub fn len(&self) -> usize {
143 self.entries.len()
144 }
145
146 /// Whether the schedule has no entries.
147 #[must_use]
148 pub fn is_empty(&self) -> bool {
149 self.entries.is_empty()
150 }
151
152 /// The entry active at `pts` — the last entry whose `planned_start <=
153 /// pts` — or `None` if `pts` precedes every entry (or the schedule is
154 /// empty).
155 #[must_use]
156 pub fn active_at(&self, pts: u64) -> Option<&ScheduleEntry> {
157 self.active_index_at(pts).map(|i| &self.entries[i])
158 }
159
160 /// Index form of [`Self::active_at`], for [`crate::transition`] to also
161 /// reach the *next* entry without a second lookup.
162 pub(crate) fn active_index_at(&self, pts: u64) -> Option<usize> {
163 if self.entries.is_empty() {
164 return None;
165 }
166 let idx = self.entries.partition_point(|e| e.planned_start <= pts);
167 if idx == 0 { None } else { Some(idx - 1) }
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 fn entry(id: &str, kind: EntryKind, planned_start: u64) -> ScheduleEntry {
176 ScheduleEntry {
177 id: id.into(),
178 kind,
179 planned_start,
180 source_start_pts: 0,
181 codec_config: CodecConfigId(1),
182 }
183 }
184
185 #[test]
186 fn push_accepts_strictly_increasing_starts() {
187 let mut sched = Schedule::new();
188 sched
189 .push(entry("prog-1", EntryKind::Programme, 0))
190 .unwrap();
191 sched.push(entry("ad-1", EntryKind::Ad, 1_000)).unwrap();
192 assert_eq!(sched.len(), 2);
193 }
194
195 #[test]
196 fn push_rejects_non_increasing_start() {
197 let mut sched = Schedule::new();
198 sched
199 .push(entry("prog-1", EntryKind::Programme, 1_000))
200 .unwrap();
201 let err = sched.push(entry("ad-1", EntryKind::Ad, 1_000)).unwrap_err();
202 match err {
203 Error::OutOfOrder { prev, next } => {
204 assert_eq!(prev, 1_000);
205 assert_eq!(next, 1_000);
206 }
207 other => panic!("expected OutOfOrder, got {other:?}"),
208 }
209
210 let err = sched.push(entry("ad-2", EntryKind::Ad, 500)).unwrap_err();
211 assert!(matches!(
212 err,
213 Error::OutOfOrder {
214 prev: 1_000,
215 next: 500
216 }
217 ));
218 }
219
220 #[test]
221 fn active_at_finds_the_last_entry_starting_at_or_before_pts() {
222 let mut sched = Schedule::new();
223 sched
224 .push(entry("prog-1", EntryKind::Programme, 0))
225 .unwrap();
226 sched.push(entry("ad-1", EntryKind::Ad, 1_000)).unwrap();
227 sched
228 .push(entry("prog-2", EntryKind::Programme, 2_000))
229 .unwrap();
230
231 assert_eq!(sched.active_at(0).unwrap().id, "prog-1");
232 assert_eq!(sched.active_at(999).unwrap().id, "prog-1");
233 assert_eq!(sched.active_at(1_000).unwrap().id, "ad-1");
234 assert_eq!(sched.active_at(1_999).unwrap().id, "ad-1");
235 assert_eq!(sched.active_at(2_000).unwrap().id, "prog-2");
236 assert_eq!(sched.active_at(1_000_000).unwrap().id, "prog-2");
237 }
238
239 #[test]
240 fn active_at_before_the_first_entry_is_none() {
241 let mut sched = Schedule::new();
242 sched
243 .push(entry("prog-1", EntryKind::Programme, 1_000))
244 .unwrap();
245 assert!(sched.active_at(999).is_none());
246 }
247
248 #[test]
249 fn empty_schedule_has_no_active_entry() {
250 let sched = Schedule::new();
251 assert!(sched.is_empty());
252 assert!(sched.active_at(0).is_none());
253 }
254
255 #[test]
256 fn label_convention() {
257 assert_eq!(EntryKind::Programme.name(), "programme");
258 assert_eq!(alloc::format!("{}", EntryKind::Ad), "ad");
259 assert_eq!(EntryKind::Slate.name(), "slate");
260 }
261}