playout_runtime/transition.rs
1//! Transition planning: given a [`Schedule`] and
2//! the current channel-timeline position, what transition happens next, at
3//! what instant, and what timeline-continuity actions it implies.
4//!
5//! Issue [#748](https://github.com/fishloa/rust-broadcast/issues/748)'s
6//! framing: **the hard part is the transition, not the schedule.** Joining
7//! two sources means timeline continuity across the join — a PTS-rebase
8//! offset so the incoming source's own clock lands continuously on the
9//! shared channel timeline (the parameter `transmux`'s IR transform would
10//! consume to actually rewrite sample timestamps — this crate computes the
11//! number, it does not touch sample bytes), plus a discontinuity flag when
12//! the codec configuration changes across the join (the signal
13//! `broadcast_hls::mark_init_discontinuities` would act on downstream). "A
14//! schedule that plays the right thing at the wrong timestamp is worse than
15//! no scheduler" — so [`TransitionPlan`] is the primary thing this crate's
16//! tests hold to timeline-correctness, not the schedule bookkeeping.
17
18use crate::schedule::{Schedule, ScheduleEntry};
19
20/// The computed plan for joining `from` into `to` at [`Self::at_pts`].
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct TransitionPlan {
23 /// The channel-timeline instant the join happens at — `to`'s
24 /// `planned_start`.
25 pub at_pts: u64,
26 /// Add this to one of `to`'s own PTS values to place it on the shared
27 /// channel timeline (see [`Self::rebase`]). `i128` so it can hold the
28 /// full possible difference of two `u64` instants without clamping —
29 /// this crate never silently narrows a rebase computation. May be
30 /// negative when `to`'s own clock numbering starts after the channel
31 /// instant it is joining at (an unusual but not-impossible schedule).
32 pub pts_rebase_offset: i128,
33 /// Whether `from` and `to` carry different
34 /// [`CodecConfigId`](crate::schedule::CodecConfigId)s — a discontinuity
35 /// that must be signalled downstream (init-segment change / new codec
36 /// config), never something this crate transcodes or conforms away.
37 pub discontinuity: bool,
38}
39
40impl TransitionPlan {
41 /// Plan the transition from `from` into `to`. `to.planned_start` is the
42 /// channel-timeline instant the join happens at; the rebase offset is
43 /// derived so that `to.source_start_pts`, once rebased, lands exactly on
44 /// that instant.
45 #[must_use]
46 pub fn plan(from: &ScheduleEntry, to: &ScheduleEntry) -> Self {
47 let at_pts = to.planned_start;
48 let pts_rebase_offset = i128::from(at_pts) - i128::from(to.source_start_pts);
49 TransitionPlan {
50 at_pts,
51 pts_rebase_offset,
52 discontinuity: from.codec_config != to.codec_config,
53 }
54 }
55
56 /// Map one of `to`'s own PTS values onto the shared channel timeline
57 /// (`source_pts + pts_rebase_offset`).
58 ///
59 /// Returns `None` rather than wrapping if the rebased instant would fall
60 /// outside `u64`'s range (e.g. a source PTS before the source's own
61 /// declared start, rebasing to before the join instant) — this crate
62 /// never silently produces a timeline instant that doesn't correspond to
63 /// the input.
64 ///
65 /// # `source_pts` must already be unwrapped (PLAY-W2, #1126)
66 ///
67 /// `pts_rebase_offset` is fixed once, at [`Self::plan`], from
68 /// `to.source_start_pts`. If `to` is a live or long-running source whose
69 /// own PTS numbering wraps mid-entry (e.g. a 33-bit MPEG PTS every
70 /// ~26.5 h) and `source_pts` here is the *raw, wrapped* value, the
71 /// result is off by the wrap modulus — silently landing far from the
72 /// true timeline position ("the right thing at the wrong timestamp",
73 /// exactly what this module's own doc warns against). This crate has no
74 /// way to detect that from a single `source_pts` value: `rebase` is
75 /// pure, with no memory of previously-seen values to compare against.
76 /// The caller must unroll any source-clock wrap *before* calling this
77 /// (`timed_metadata::Timeline` already does exactly this for 33-bit MPEG PTS)
78 /// so every `source_pts` passed here is monotonic within the entry.
79 #[must_use]
80 pub fn rebase(&self, source_pts: u64) -> Option<u64> {
81 let rebased = i128::from(source_pts) + self.pts_rebase_offset;
82 u64::try_from(rebased).ok()
83 }
84}
85
86/// A planned transition together with the two entries either side of it.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct PlannedTransition<'a> {
89 /// The entry currently playing.
90 pub from: &'a ScheduleEntry,
91 /// The entry the channel joins into.
92 pub to: &'a ScheduleEntry,
93 /// The computed plan for that join.
94 pub plan: TransitionPlan,
95}
96
97/// Find and plan the next transition due at or after `now_pts`.
98///
99/// Returns `None` if `now_pts` precedes every entry, or if the entry active
100/// at `now_pts` is the schedule's last entry (nothing scheduled to follow it
101/// yet).
102#[must_use]
103pub fn next_transition(schedule: &Schedule, now_pts: u64) -> Option<PlannedTransition<'_>> {
104 let idx = schedule.active_index_at(now_pts)?;
105 let entries = schedule.entries();
106 let to = entries.get(idx + 1)?;
107 let from = &entries[idx];
108 Some(PlannedTransition {
109 from,
110 to,
111 plan: TransitionPlan::plan(from, to),
112 })
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::schedule::{CodecConfigId, EntryKind, ScheduleEntry};
119
120 fn entry(
121 id: &str,
122 kind: EntryKind,
123 planned_start: u64,
124 source_start_pts: u64,
125 codec_config: u64,
126 ) -> ScheduleEntry {
127 ScheduleEntry {
128 id: id.into(),
129 kind,
130 planned_start,
131 source_start_pts,
132 codec_config: CodecConfigId(codec_config),
133 }
134 }
135
136 /// The primary property under test: continuity across a join. Once
137 /// rebased, the incoming source's own PTS sequence must land exactly on
138 /// the channel timeline with no gap and no overlap — `rebase(start) ==
139 /// at_pts`, and every later source PTS advances the channel timeline by
140 /// exactly the same amount it advanced on the source's own clock.
141 #[test]
142 fn rebase_lands_the_incoming_source_continuously_on_the_channel_timeline() {
143 let programme = entry("prog-1", EntryKind::Programme, 0, 0, 1);
144 // The ad's own asset timeline starts at PTS 5_000 (e.g. it is itself
145 // a clip cut from a longer source), and is scheduled to join the
146 // channel at channel-timeline instant 1_000_000.
147 let ad = entry("ad-1", EntryKind::Ad, 1_000_000, 5_000, 1);
148
149 let plan = TransitionPlan::plan(&programme, &ad);
150 assert_eq!(plan.at_pts, 1_000_000);
151
152 // The ad's very first sample lands exactly at the join instant.
153 assert_eq!(plan.rebase(5_000), Some(1_000_000));
154 // One second later (90 kHz) on the ad's own clock is one second
155 // later on the channel timeline too — no drift introduced by the
156 // rebase itself.
157 assert_eq!(plan.rebase(5_000 + 90_000), Some(1_090_000));
158 // Ten seconds later.
159 assert_eq!(plan.rebase(5_000 + 900_000), Some(1_900_000));
160 }
161
162 #[test]
163 fn rebase_offset_can_be_negative_when_source_starts_after_the_join_instant() {
164 // A degenerate but legal schedule: the source's own clock numbering
165 // starts past the channel instant it joins at.
166 let from = entry("prog-1", EntryKind::Programme, 0, 0, 1);
167 let to = entry("ad-1", EntryKind::Ad, 100, 500, 1);
168 let plan = TransitionPlan::plan(&from, &to);
169 assert_eq!(plan.pts_rebase_offset, -400);
170 assert_eq!(plan.rebase(500), Some(100));
171 // A source PTS below its own declared start rebases to a negative
172 // channel instant — reported as `None`, never wrapped.
173 assert_eq!(plan.rebase(300), None);
174 }
175
176 #[test]
177 fn rebase_returns_none_rather_than_wrapping_out_of_range() {
178 let from = entry("prog-1", EntryKind::Programme, 0, 0, 1);
179 let to = entry("ad-1", EntryKind::Ad, 0, u64::MAX, 1);
180 let plan = TransitionPlan::plan(&from, &to);
181
182 // The source's own declared start rebases exactly onto the join
183 // instant, even though the raw offset is a huge negative i128.
184 assert_eq!(plan.rebase(u64::MAX), Some(0));
185 // Anything before the source's own start would rebase to a negative
186 // channel instant — reported honestly as `None`, not wrapped into a
187 // huge bogus `u64`.
188 assert_eq!(plan.rebase(0), None);
189 }
190
191 #[test]
192 fn discontinuity_flags_a_codec_config_change_across_the_join() {
193 let from = entry("prog-1", EntryKind::Programme, 0, 0, 1);
194 let same = entry("prog-2", EntryKind::Programme, 1_000, 0, 1);
195 let different = entry("ad-1", EntryKind::Ad, 1_000, 0, 2);
196
197 assert!(!TransitionPlan::plan(&from, &same).discontinuity);
198 assert!(TransitionPlan::plan(&from, &different).discontinuity);
199 }
200
201 #[test]
202 fn next_transition_walks_the_schedule() {
203 let mut sched = Schedule::default();
204 sched
205 .push(entry("prog-1", EntryKind::Programme, 0, 0, 1))
206 .unwrap();
207 sched
208 .push(entry("ad-1", EntryKind::Ad, 1_000, 0, 2))
209 .unwrap();
210 sched
211 .push(entry("prog-2", EntryKind::Programme, 2_000, 0, 1))
212 .unwrap();
213
214 let t = next_transition(&sched, 500).unwrap();
215 assert_eq!(t.from.id, "prog-1");
216 assert_eq!(t.to.id, "ad-1");
217 assert!(t.plan.discontinuity);
218
219 let t = next_transition(&sched, 1_500).unwrap();
220 assert_eq!(t.from.id, "ad-1");
221 assert_eq!(t.to.id, "prog-2");
222
223 // No entry follows the last one yet.
224 assert!(next_transition(&sched, 2_500).is_none());
225 // pts 0 IS prog-1's start, so the next transition is still found.
226 assert!(next_transition(&sched, 0).is_some());
227 }
228}