Skip to main content

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 — or
79    /// use [`Self::rebase_wrapped`], which does the unroll for a raw 33-bit PTS.
80    #[must_use]
81    pub fn rebase(&self, source_pts: u64) -> Option<u64> {
82        let rebased = i128::from(source_pts) + self.pts_rebase_offset;
83        u64::try_from(rebased).ok()
84    }
85
86    /// [`Self::rebase`] for a **raw, still-wrapped** 33-bit source PTS
87    /// (PLAY-W2 hardening): `raw_pts` is unrolled against
88    /// `last_unwrapped_pts` (the previous *unwrapped* source PTS of this
89    /// entry, e.g. `to.source_start_pts` for the first sample) with
90    /// [`broadcast_common::clock33::unwrap_delta`], then rebased. Handles one
91    /// wrap in either direction, so a source straddling 2^33 lands on the
92    /// right timeline instant instead of off by the modulus.
93    ///
94    /// Returns `None` if `raw_pts` is not a 33-bit value (`>= 2^33`, i.e.
95    /// it is not a raw wire PTS) or the result is out of `u64` range.
96    #[must_use]
97    pub fn rebase_wrapped(&self, raw_pts: u64, last_unwrapped_pts: u64) -> Option<u64> {
98        use broadcast_common::clock33::{WRAP_33BIT, unwrap_delta};
99        if raw_pts >= WRAP_33BIT {
100            return None;
101        }
102        let prev_raw = last_unwrapped_pts % WRAP_33BIT;
103        let unwrapped = unwrap_delta(i128::from(last_unwrapped_pts), prev_raw, raw_pts);
104        u64::try_from(unwrapped + self.pts_rebase_offset).ok()
105    }
106}
107
108/// A planned transition together with the two entries either side of it.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct PlannedTransition<'a> {
111    /// The entry currently playing.
112    pub from: &'a ScheduleEntry,
113    /// The entry the channel joins into.
114    pub to: &'a ScheduleEntry,
115    /// The computed plan for that join.
116    pub plan: TransitionPlan,
117}
118
119/// Find and plan the next transition due at or after `now_pts`.
120///
121/// Returns `None` if `now_pts` precedes every entry, or if the entry active
122/// at `now_pts` is the schedule's last entry (nothing scheduled to follow it
123/// yet).
124#[must_use]
125pub fn next_transition(schedule: &Schedule, now_pts: u64) -> Option<PlannedTransition<'_>> {
126    let idx = schedule.active_index_at(now_pts)?;
127    let entries = schedule.entries();
128    let to = entries.get(idx + 1)?;
129    let from = &entries[idx];
130    Some(PlannedTransition {
131        from,
132        to,
133        plan: TransitionPlan::plan(from, to),
134    })
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::schedule::{CodecConfigId, EntryKind, ScheduleEntry};
141
142    fn entry(
143        id: &str,
144        kind: EntryKind,
145        planned_start: u64,
146        source_start_pts: u64,
147        codec_config: u64,
148    ) -> ScheduleEntry {
149        ScheduleEntry {
150            id: id.into(),
151            kind,
152            planned_start,
153            source_start_pts,
154            codec_config: CodecConfigId(codec_config),
155        }
156    }
157
158    /// The primary property under test: continuity across a join. Once
159    /// rebased, the incoming source's own PTS sequence must land exactly on
160    /// the channel timeline with no gap and no overlap — `rebase(start) ==
161    /// at_pts`, and every later source PTS advances the channel timeline by
162    /// exactly the same amount it advanced on the source's own clock.
163    #[test]
164    fn rebase_lands_the_incoming_source_continuously_on_the_channel_timeline() {
165        let programme = entry("prog-1", EntryKind::Programme, 0, 0, 1);
166        // The ad's own asset timeline starts at PTS 5_000 (e.g. it is itself
167        // a clip cut from a longer source), and is scheduled to join the
168        // channel at channel-timeline instant 1_000_000.
169        let ad = entry("ad-1", EntryKind::Ad, 1_000_000, 5_000, 1);
170
171        let plan = TransitionPlan::plan(&programme, &ad);
172        assert_eq!(plan.at_pts, 1_000_000);
173
174        // The ad's very first sample lands exactly at the join instant.
175        assert_eq!(plan.rebase(5_000), Some(1_000_000));
176        // One second later (90 kHz) on the ad's own clock is one second
177        // later on the channel timeline too — no drift introduced by the
178        // rebase itself.
179        assert_eq!(plan.rebase(5_000 + 90_000), Some(1_090_000));
180        // Ten seconds later.
181        assert_eq!(plan.rebase(5_000 + 900_000), Some(1_900_000));
182    }
183
184    #[test]
185    fn rebase_offset_can_be_negative_when_source_starts_after_the_join_instant() {
186        // A degenerate but legal schedule: the source's own clock numbering
187        // starts past the channel instant it joins at.
188        let from = entry("prog-1", EntryKind::Programme, 0, 0, 1);
189        let to = entry("ad-1", EntryKind::Ad, 100, 500, 1);
190        let plan = TransitionPlan::plan(&from, &to);
191        assert_eq!(plan.pts_rebase_offset, -400);
192        assert_eq!(plan.rebase(500), Some(100));
193        // A source PTS below its own declared start rebases to a negative
194        // channel instant — reported as `None`, never wrapped.
195        assert_eq!(plan.rebase(300), None);
196    }
197
198    #[test]
199    fn rebase_returns_none_rather_than_wrapping_out_of_range() {
200        let from = entry("prog-1", EntryKind::Programme, 0, 0, 1);
201        let to = entry("ad-1", EntryKind::Ad, 0, u64::MAX, 1);
202        let plan = TransitionPlan::plan(&from, &to);
203
204        // The source's own declared start rebases exactly onto the join
205        // instant, even though the raw offset is a huge negative i128.
206        assert_eq!(plan.rebase(u64::MAX), Some(0));
207        // Anything before the source's own start would rebase to a negative
208        // channel instant — reported honestly as `None`, not wrapped into a
209        // huge bogus `u64`.
210        assert_eq!(plan.rebase(0), None);
211    }
212
213    #[test]
214    fn discontinuity_flags_a_codec_config_change_across_the_join() {
215        let from = entry("prog-1", EntryKind::Programme, 0, 0, 1);
216        let same = entry("prog-2", EntryKind::Programme, 1_000, 0, 1);
217        let different = entry("ad-1", EntryKind::Ad, 1_000, 0, 2);
218
219        assert!(!TransitionPlan::plan(&from, &same).discontinuity);
220        assert!(TransitionPlan::plan(&from, &different).discontinuity);
221    }
222
223    #[test]
224    fn next_transition_walks_the_schedule() {
225        let mut sched = Schedule::default();
226        sched
227            .push(entry("prog-1", EntryKind::Programme, 0, 0, 1))
228            .unwrap();
229        sched
230            .push(entry("ad-1", EntryKind::Ad, 1_000, 0, 2))
231            .unwrap();
232        sched
233            .push(entry("prog-2", EntryKind::Programme, 2_000, 0, 1))
234            .unwrap();
235
236        let t = next_transition(&sched, 500).unwrap();
237        assert_eq!(t.from.id, "prog-1");
238        assert_eq!(t.to.id, "ad-1");
239        assert!(t.plan.discontinuity);
240
241        let t = next_transition(&sched, 1_500).unwrap();
242        assert_eq!(t.from.id, "ad-1");
243        assert_eq!(t.to.id, "prog-2");
244
245        // No entry follows the last one yet.
246        assert!(next_transition(&sched, 2_500).is_none());
247        // pts 0 IS prog-1's start, so the next transition is still found.
248        assert!(next_transition(&sched, 0).is_some());
249    }
250
251    /// PLAY-W2: a raw 33-bit source PTS that wrapped mid-entry must land on
252    /// the right channel instant (values straddling 2^33), not off by 2^33.
253    #[test]
254    fn rebase_wrapped_handles_a_source_pts_wrap() {
255        use broadcast_common::clock33::WRAP_33BIT;
256        let start = WRAP_33BIT - 90_000;
257        let from = entry("prog-1", EntryKind::Programme, 0, 0, 1);
258        let to = entry("prog-2", EntryKind::Programme, 1_000_000, start, 1);
259        let plan = TransitionPlan::plan(&from, &to);
260        // Before the wrap: same as plain rebase.
261        assert_eq!(plan.rebase_wrapped(start, start), Some(1_000_000));
262        // Two seconds later the raw PTS has wrapped to 90_000.
263        assert_eq!(
264            plan.rebase_wrapped(90_000, start),
265            Some(1_000_000 + 180_000)
266        );
267        // The naive (documented-unsafe) call lands ~2^33 away.
268        assert_ne!(plan.rebase(90_000), Some(1_180_000));
269        // A later sample after the unwrapped accumulator crossed the wrap.
270        assert_eq!(
271            plan.rebase_wrapped(180_000, WRAP_33BIT + 90_000),
272            Some(1_000_000 + 270_000)
273        );
274        // Not a 33-bit wire value.
275        assert_eq!(plan.rebase_wrapped(WRAP_33BIT, start), None);
276    }
277}