Skip to main content

moq_hls/export/
segments.rs

1//! One rendition's view of the broadcast timeline, as a `Producer`/[`Consumer`] pair.
2//!
3//! The broadcast has a single timeline track; the catalog watcher reads it and fans each
4//! record out to every rendition as a row: the segment's number, timing, and this
5//! rendition's group ranges (empty when the record carries no content for it, a gap). Records
6//! are self-contained (the timeline only publishes a segment once its content is final on
7//! every track), so every row is immediately listable and fetchable. Two things read the
8//! window:
9//!
10//! * the HTTP serve path, synchronously, to render a media playlist and look up a segment's
11//!   group ranges (nothing here touches media bytes on that path); and
12//! * a [`Consumer`] cursor, for a recorder that wants every segment *with its media*, in
13//!   order, exactly once. `next()` waits for the next row, FETCHes and transmuxes its groups
14//!   (via [`Rendition`]), and yields the CMAF bytes.
15//!
16//! Rows carry the broadcast's aligned segment numbers, so a segment is addressed by that
17//! number everywhere (the `seg/{segment}.m4s` URI, `EXT-X-MEDIA-SEQUENCE`, the recorder
18//! cursor), and the same number names the same span of content time on every rendition.
19
20use std::collections::VecDeque;
21use std::sync::Arc;
22use std::task::Poll;
23use std::time::{Duration, SystemTime};
24
25use bytes::Bytes;
26use hang::timeline::Range;
27
28use super::Rendition;
29use crate::Result;
30
31/// The producing side of a rendition's timeline window.
32///
33/// The catalog watcher appends rows via [`push`](Self::push) and marks the stream
34/// [`end`](Self::end)ed. Cheap to share behind an `Arc`; the window state lives in a
35/// [`kio::Producer`] so a [`Consumer`] can await changes without a separate signal.
36pub(crate) struct Producer {
37	state: kio::Producer<State>,
38}
39
40struct State {
41	/// Rows within the window, oldest first. Every row is a complete segment.
42	rows: VecDeque<Row>,
43	/// The first listed segment, or the segment that would follow an empty trimmed window.
44	sequence: u64,
45	/// The timeline track ended: the broadcast is over (`EXT-X-ENDLIST`).
46	ended: bool,
47}
48
49/// Numbers the broadcast's content timeline: the sequence bumps wherever the timeline breaks.
50///
51/// Held once by the timeline fanout rather than per rendition, so every rendition stamps a
52/// record with the same sequence even when one isn't listing rows (waiting to rebind) or
53/// cleared its own window.
54#[derive(Default)]
55pub(crate) struct Discontinuities {
56	sequence: u64,
57	/// The end of the last stamped record.
58	last_end: Option<Duration>,
59	/// Records were skipped, so the next one can't continue the timeline.
60	broken: bool,
61}
62
63impl Discontinuities {
64	/// The sequence of a record spanning `pts..end`, bumped if it doesn't continue the last one.
65	pub fn stamp(&mut self, pts: Duration, end: Duration) -> u64 {
66		// Tolerate sub-millisecond drift from timescale rounding.
67		let jumped = self
68			.last_end
69			.is_some_and(|last| pts.saturating_sub(last).max(last.saturating_sub(pts)) > Duration::from_millis(1));
70		if self.broken || jumped {
71			self.sequence += 1;
72		}
73		self.broken = false;
74		self.last_end = Some(end);
75		self.sequence
76	}
77
78	/// Records were skipped (or a new run started): the next one starts a new discontinuity.
79	pub fn interrupt(&mut self) {
80		self.broken = true;
81	}
82}
83
84/// One playlist segment: its aligned number, timing, and this rendition's group ranges.
85#[derive(Clone, Debug, PartialEq)]
86pub(crate) struct Row {
87	/// The source timeline record index, used to mirror exact window trims.
88	pub index: u64,
89	/// The aligned segment number (its URI: `seg/{segment}.m4s`), shared across renditions.
90	pub segment: u64,
91	/// This rendition's group ranges within the segment. Empty means the rendition has no
92	/// content for the span (`EXT-X-GAP`).
93	pub ranges: Vec<Range>,
94	/// Presentation duration.
95	pub duration: Duration,
96	/// The segment's starting presentation timestamp.
97	pub pts: moq_net::Timestamp,
98	/// The segment's ending presentation timestamp (`pts + duration`), for window eviction
99	/// and discontinuity detection.
100	pub end: Duration,
101	/// The broadcast's [`Discontinuities`] sequence for this record, shared by every rendition,
102	/// so renditions mark the same breaks however many segments each one skipped.
103	pub discontinuity: u64,
104}
105
106/// A consistent read of the window, for rendering one playlist (the serve path only).
107#[cfg_attr(not(feature = "server"), allow(dead_code))]
108pub(crate) struct Window {
109	/// The `EXT-X-MEDIA-SEQUENCE` of the first listed segment: its aligned segment number, so
110	/// sequence numbers line up across renditions.
111	pub sequence: u64,
112	/// Listed segments, oldest first.
113	pub segments: Vec<Row>,
114	/// Whether the timeline (and so the playlist) has ended.
115	pub ended: bool,
116}
117
118/// The next segment a [`Consumer`] should emit, resolved from the window.
119enum Next {
120	/// A segment is ready to fetch.
121	Ready(Row),
122	/// No further segment will ever appear (the timeline ended).
123	Ended,
124	/// Nothing new yet; wait for the next window change.
125	Pending,
126}
127
128impl State {
129	/// Snapshot the current window. Every row is a complete segment, so all are listed.
130	#[cfg_attr(not(feature = "server"), allow(dead_code))]
131	fn window(&self) -> Window {
132		Window {
133			sequence: self.sequence,
134			segments: self.rows.iter().cloned().collect(),
135			ended: self.ended,
136		}
137	}
138
139	/// The first segment numbered past `after`, for a cursor.
140	///
141	/// Rows are complete the moment they arrive. Segments evicted from the front of the
142	/// window before the cursor reached them are skipped: the cursor resumes at the oldest
143	/// row still in the window.
144	fn next_after(&self, after: Option<u64>) -> Next {
145		let next = self.rows.iter().find(|r| match after {
146			Some(after) => r.segment > after,
147			None => true,
148		});
149		match next {
150			Some(row) => Next::Ready(row.clone()),
151			None if self.ended => Next::Ended,
152			None => Next::Pending,
153		}
154	}
155}
156
157impl Producer {
158	/// An empty window.
159	pub fn new() -> Self {
160		Self {
161			state: kio::Producer::new(State {
162				rows: VecDeque::new(),
163				sequence: 0,
164				ended: false,
165			}),
166		}
167	}
168
169	/// Append a row, evicting the front of the window past `window`.
170	pub fn push(&self, row: Row, window: Duration) {
171		let Ok(mut state) = self.state.write() else {
172			return;
173		};
174
175		if let Some(back) = state.rows.back() {
176			// A backward jump in pts or segment number means the publisher restarted its timeline;
177			// the old window can't be stitched onto the new one, so start over.
178			if Duration::from(row.pts) < Duration::from(back.pts) || row.segment <= back.segment {
179				tracing::warn!("timeline jumped backwards; resetting the playlist window");
180				state.rows.clear();
181			}
182		}
183		if state.rows.is_empty() {
184			state.sequence = row.segment;
185		}
186
187		state.rows.push_back(row);
188
189		// Evict from the front while the remaining rows still cover the window.
190		while state.rows.len() >= 2 {
191			let span = state.rows.back().unwrap().end.saturating_sub(state.rows[1].pts.into());
192			if span < window {
193				break;
194			}
195			state.rows.pop_front();
196		}
197		state.sequence = state.rows.front().unwrap().segment;
198	}
199
200	/// Remove rows whose source timeline indices fall within `range`.
201	pub fn pop(&self, range: std::ops::Range<u64>) {
202		if let Ok(mut state) = self.state.write() {
203			let after = state
204				.rows
205				.iter()
206				.filter(|row| range.contains(&row.index))
207				.map(|row| row.segment.saturating_add(1))
208				.max();
209			state.rows.retain(|row| !range.contains(&row.index));
210			state.sequence = state
211				.rows
212				.front()
213				.map(|row| row.segment)
214				.or(after)
215				.unwrap_or(state.sequence);
216		}
217	}
218
219	/// Clear every retained row after an unrecoverable gap in the source timeline.
220	pub fn clear(&self) {
221		if let Ok(mut state) = self.state.write() {
222			state.rows.clear();
223		}
224	}
225
226	/// Mark the timeline ended (the broadcast finished cleanly): the playlist gets
227	/// `EXT-X-ENDLIST` and cursors end once drained.
228	pub fn end(&self) {
229		if let Ok(mut state) = self.state.write() {
230			state.ended = true;
231		}
232	}
233
234	/// Close the channel: no more rows will arrive. A [`Consumer`] drains the segments it
235	/// can still see and then ends; the serve path keeps reading the frozen window. Call after
236	/// [`end`](Self::end) on a clean finish, or on its own when the source is lost mid-stream.
237	pub fn close(&self) {
238		let _ = self.state.close();
239	}
240
241	/// Snapshot the current window (serve path).
242	#[cfg_attr(not(feature = "server"), allow(dead_code))]
243	pub fn window(&self) -> Window {
244		self.state.read().window()
245	}
246
247	/// The group ranges segment `segment` covers for this rendition, or `None` if it isn't in
248	/// the window. An empty vec means the segment is a gap for this rendition.
249	pub fn segment_ranges(&self, segment: u64) -> Option<Vec<Range>> {
250		let state = self.state.read();
251		let row = state.rows.iter().find(|r| r.segment == segment)?;
252		Some(row.ranges.clone())
253	}
254
255	/// The number of the segment whose `pts` is exactly `time` in the timeline's timescale
256	/// (DASH `$Time$` addressing), or `None` when no listed row starts there. Exact match is
257	/// safe because the rendered `S@t` and this lookup convert the same [`Row::pts`] the same
258	/// way.
259	#[cfg_attr(not(feature = "server"), allow(dead_code))]
260	pub fn segment_number_at(&self, time: u64, timescale: moq_net::Timescale) -> Option<u64> {
261		let state = self.state.read();
262		state
263			.rows
264			.iter()
265			.find(|row| row.pts.as_scale(timescale) == time as u128)
266			.map(|row| row.segment)
267	}
268
269	/// The newest group known to start with a keyframe, used to bootstrap an init segment for
270	/// inline-parameter-set codecs.
271	pub fn latest_keyframe_group(&self) -> Option<u64> {
272		let state = self.state.read();
273		state
274			.rows
275			.iter()
276			.rev()
277			.flat_map(|row| row.ranges.iter().rev())
278			.find(|range| range.keyframe)
279			.map(|range| range.start)
280	}
281
282	/// Whether the playlist has anything to serve yet (at least one segment, or the broadcast
283	/// already ended).
284	#[cfg_attr(not(feature = "server"), allow(dead_code))]
285	pub fn is_playable(&self) -> bool {
286		let state = self.state.read();
287		state.ended || !state.rows.is_empty()
288	}
289
290	/// Poll until [`is_playable`](Self::is_playable), for the serve path's long-poll.
291	#[cfg_attr(not(feature = "server"), allow(dead_code))]
292	pub fn poll_playable(&self, waiter: &kio::Waiter) -> Poll<()> {
293		let poll = self.state.poll_ref(waiter, |state| {
294			if state.ended || !state.rows.is_empty() {
295				Poll::Ready(())
296			} else {
297				Poll::Pending
298			}
299		});
300		match poll {
301			// Ready, or the channel closed (no more rows will arrive): stop waiting either way.
302			Poll::Ready(_) => Poll::Ready(()),
303			Poll::Pending => Poll::Pending,
304		}
305	}
306
307	/// A cursor over segments, starting from the oldest still in the window.
308	pub fn subscribe(&self, rendition: Arc<Rendition>) -> Consumer {
309		Consumer {
310			state: self.state.consume(),
311			rendition,
312			position: Position::default(),
313			feed: None,
314		}
315	}
316}
317
318/// A segment with its transmuxed media, yielded by a [`Consumer`].
319pub struct Segment {
320	/// The aligned segment number (also its `seg/{segment}.m4s` URI stem), shared across the
321	/// broadcast's renditions.
322	pub segment: u64,
323	/// The transmuxed CMAF fragment (`moof`+`mdat`), fetched on demand by [`Consumer::next`].
324	pub media: Bytes,
325	/// Presentation duration.
326	pub duration: Duration,
327	/// Wall-clock start time, when the timeline advertises an anchor.
328	pub program_date_time: Option<SystemTime>,
329	/// The content timeline breaks before this segment (the source skipped or restarted), so a
330	/// recorder marks an `EXT-X-DISCONTINUITY` here. Every rendition marks the same breaks, as
331	/// HLS requires. Segments this cursor skipped (evicted, uncached, or gaps with no content
332	/// for this rendition) leave a hole on a continuous timeline, not a discontinuity.
333	pub discontinuity: bool,
334}
335
336/// A cursor over one rendition's segments, in timeline order.
337///
338/// Obtained from [`Rendition::segments`](super::Rendition::segments). Each
339/// [`next`](Self::next) awaits the next segment, then FETCHes and transmuxes it through the
340/// path the HTTP serve path uses. The cursor also holds a live subscription to the media
341/// track, so each group reaches the cache as it is published and that FETCH is a hit. A
342/// cursor reads every group anyway, so this costs no extra traffic, and a source without
343/// FETCH (moq-lite before 05) can serve a group no other way.
344pub struct Consumer {
345	state: kio::Consumer<State>,
346	rendition: Arc<Rendition>,
347	position: Position,
348	feed: Option<Feed>,
349}
350
351/// The live subscription a [`Consumer`] holds on its rendition's media track.
352struct Feed {
353	/// What it subscribed through: a rebound sibling publisher gets a fresh feed.
354	binding: Arc<moq_mux::Binding>,
355	state: FeedState,
356}
357
358enum FeedState {
359	/// Waiting for the binding to resolve the broadcast.
360	Binding,
361	Subscribing(moq_net::track::Subscribing),
362	Live(Box<moq_net::track::Subscriber>),
363	/// The track can't be subscribed; fetches fail on their own.
364	Failed,
365}
366
367/// Where a [`Consumer`] is in the timeline. Only advanced once a segment is fetched or
368/// skipped, so a transient fetch error re-tries the same segment instead of losing it.
369#[derive(Default)]
370struct Position {
371	/// The number of the last segment passed; the cursor resumes strictly after it.
372	after: Option<u64>,
373	/// The discontinuity sequence of the last segment passed.
374	discontinuity: Option<u64>,
375}
376
377impl Position {
378	/// Pass `row` without returning it. A skipped first row still sets the baseline, so a break
379	/// before this cursor's first fetch is reported, as a sibling that fetched it would.
380	fn skip(&mut self, row: &Row) {
381		self.after = Some(row.segment);
382		self.discontinuity.get_or_insert(row.discontinuity);
383	}
384
385	/// Pass `row` as returned, reporting whether it starts a new discontinuity.
386	fn emit(&mut self, row: &Row) -> bool {
387		self.after = Some(row.segment);
388		let previous = self.discontinuity.replace(row.discontinuity);
389		previous.is_some_and(|previous| previous != row.discontinuity)
390	}
391}
392
393impl Consumer {
394	/// The rendition's CMAF init segment, built once and cached; `None` until it can be built
395	/// (an inline-parameter-set codec needs the first segment first).
396	pub async fn init(&self) -> Result<Option<Bytes>> {
397		self.rendition.init().await
398	}
399
400	/// The next segment, with its media; `None` once the rendition ends.
401	///
402	/// Waits for the next segment, then FETCHes and transmuxes its groups. A segment whose
403	/// groups already left the relay cache (or that is a gap for this rendition) is skipped,
404	/// resuming at the next one, rather than surfaced as an error; a real fetch/transmux
405	/// failure is returned, leaving the cursor to retry it on the next call.
406	pub async fn next(&mut self) -> Result<Option<Segment>> {
407		loop {
408			let Some(row) = kio::wait(|waiter| self.poll_next(waiter)).await else {
409				return Ok(None);
410			};
411			match self.rendition.segment(row.segment).await? {
412				Some(media) => {
413					return Ok(Some(Segment {
414						segment: row.segment,
415						media,
416						duration: row.duration,
417						program_date_time: self.rendition.wall_clock(row.pts),
418						discontinuity: self.position.emit(&row),
419					}));
420				}
421				None => self.position.skip(&row),
422			}
423		}
424	}
425
426	fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll<Option<Row>> {
427		self.poll_feed(waiter);
428		let poll = self
429			.state
430			.poll(waiter, |state| match state.next_after(self.position.after) {
431				Next::Ready(row) => Poll::Ready(Some(row)),
432				Next::Ended => Poll::Ready(None),
433				Next::Pending => Poll::Pending,
434			});
435		match poll {
436			Poll::Ready(Ok(found)) => Poll::Ready(found),
437			// The producer closed without a clean end (broadcast dropped): no more segments.
438			Poll::Ready(Err(_)) => Poll::Ready(None),
439			Poll::Pending => Poll::Pending,
440		}
441	}
442
443	/// Hold a live subscription on the media track the rendition is bound to now.
444	fn poll_feed(&mut self, waiter: &kio::Waiter) {
445		let binding = self.rendition.binding();
446		let feed = match &mut self.feed {
447			Some(feed) if Arc::ptr_eq(&feed.binding, &binding) => feed,
448			feed => feed.insert(Feed {
449				binding,
450				state: FeedState::Binding,
451			}),
452		};
453		loop {
454			feed.state = match &mut feed.state {
455				FeedState::Binding => match self.rendition.poll_subscribe(&feed.binding, waiter) {
456					Poll::Ready(Some(subscribing)) => FeedState::Subscribing(subscribing),
457					Poll::Ready(None) => FeedState::Failed,
458					Poll::Pending => return,
459				},
460				FeedState::Subscribing(subscribing) => match subscribing.poll_ok(waiter) {
461					Poll::Ready(Ok(subscriber)) => FeedState::Live(Box::new(subscriber)),
462					Poll::Ready(Err(_)) => FeedState::Failed,
463					Poll::Pending => return,
464				},
465				FeedState::Live(subscriber) => {
466					// Drop each group as it arrives: the cache keeps it for the fetch, and a
467					// subscription that resumed across routes only pulls while it is read.
468					while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(waiter) {}
469					return;
470				}
471				FeedState::Failed => return,
472			};
473		}
474	}
475}
476
477#[cfg(test)]
478mod tests {
479	use super::*;
480
481	fn row(segment: u64, group: u64, pts_ms: u64, duration_ms: u64) -> Row {
482		let pts = moq_net::Timestamp::from_millis(pts_ms).unwrap();
483		Row {
484			index: segment,
485			segment,
486			ranges: vec![Range::new(group, group)],
487			duration: Duration::from_millis(duration_ms),
488			pts,
489			end: Duration::from(pts) + Duration::from_millis(duration_ms),
490			discontinuity: 0,
491		}
492	}
493
494	#[test]
495	fn every_row_is_listed() {
496		let live = Producer::new();
497		live.push(row(0, 0, 0, 2_000), Duration::from_secs(30));
498		live.push(row(1, 1, 2_000, 2_000), Duration::from_secs(30));
499
500		let window = live.window();
501		assert_eq!(window.sequence, 0);
502		assert!(!window.ended);
503		assert_eq!(window.segments.len(), 2, "rows are complete segments; all are listed");
504		assert_eq!(window.segments[0].segment, 0);
505		assert_eq!(window.segments[0].duration, Duration::from_secs(2));
506
507		live.end();
508		assert!(live.window().ended);
509	}
510
511	#[test]
512	fn window_evicts_and_advances_sequence() {
513		let live = Producer::new();
514		let window = Duration::from_secs(4);
515		for i in 0..6u64 {
516			live.push(row(i, i, i * 2_000, 2_000), window);
517		}
518
519		let snapshot = live.window();
520		// Segments still cover >= 4s after eviction, and the sequence is the first listed
521		// segment's aligned number.
522		assert!(snapshot.sequence > 0);
523		let span: Duration = snapshot.segments.iter().map(|s| s.duration).sum();
524		assert!(span >= Duration::from_secs(4));
525		assert_eq!(snapshot.segments.first().unwrap().segment, snapshot.sequence);
526	}
527
528	#[test]
529	fn source_window_pop_removes_playlist_rows() {
530		let live = Producer::new();
531		let window = Duration::from_secs(30);
532		for i in 0..4u64 {
533			let mut row = row(i, i, i * 2_000, 2_000);
534			row.index = i + 10;
535			live.push(row, window);
536		}
537
538		live.pop(10..12);
539		let snapshot = live.window();
540		assert_eq!(snapshot.sequence, 2);
541		assert_eq!(
542			snapshot.segments.iter().map(|row| row.segment).collect::<Vec<_>>(),
543			vec![2, 3]
544		);
545
546		live.pop(12..14);
547		let snapshot = live.window();
548		assert_eq!(snapshot.sequence, 4);
549		assert!(snapshot.segments.is_empty());
550	}
551
552	#[test]
553	fn a_skipped_source_range_clears_rows_before_the_next_segment() {
554		let live = Producer::new();
555		live.push(row(4, 4, 8_000, 2_000), Duration::from_secs(10));
556		live.clear();
557		live.push(row(10, 10, 20_000, 2_000), Duration::from_secs(10));
558
559		let snapshot = live.window();
560		assert_eq!(snapshot.sequence, 10);
561		assert_eq!(
562			snapshot.segments.iter().map(|row| row.segment).collect::<Vec<_>>(),
563			vec![10]
564		);
565	}
566
567	fn secs(s: u64) -> Duration {
568		Duration::from_secs(s)
569	}
570
571	fn stamped(segment: u64, discontinuity: u64) -> Row {
572		Row {
573			discontinuity,
574			..row(segment, segment, segment * 2_000, 2_000)
575		}
576	}
577
578	#[test]
579	fn skipped_rows_do_not_hide_or_invent_breaks() {
580		// Skipping a row on a continuous timeline is a hole, not a discontinuity.
581		let mut position = Position::default();
582		assert!(!position.emit(&stamped(0, 0)), "a clean start");
583		position.skip(&stamped(1, 0));
584		assert!(!position.emit(&stamped(2, 0)));
585
586		// A break before the first fetch still counts: a sibling that fetched row 0 marks row 1.
587		let mut position = Position::default();
588		position.skip(&stamped(0, 0));
589		assert!(position.emit(&stamped(1, 1)));
590	}
591
592	#[test]
593	fn a_continuous_timeline_keeps_one_discontinuity() {
594		// A cursor that skips a record (uncached, or a gap for its rendition) must not mark the
595		// next one: a sibling that fetched it wouldn't, and players require renditions to agree.
596		let mut timeline = Discontinuities::default();
597		assert_eq!(timeline.stamp(secs(0), secs(2)), 0);
598		assert_eq!(timeline.stamp(secs(2), secs(4)), 0);
599		assert_eq!(timeline.stamp(secs(4), secs(6)), 0);
600	}
601
602	#[test]
603	fn a_content_time_jump_starts_a_new_discontinuity() {
604		let mut timeline = Discontinuities::default();
605		assert_eq!(timeline.stamp(secs(0), secs(2)), 0);
606		assert_eq!(timeline.stamp(secs(10), secs(12)), 1);
607		assert_eq!(timeline.stamp(secs(12), secs(14)), 1);
608	}
609
610	#[test]
611	fn skipped_records_start_a_new_discontinuity() {
612		let mut timeline = Discontinuities::default();
613		assert_eq!(timeline.stamp(secs(0), secs(2)), 0);
614		timeline.interrupt();
615		assert_eq!(
616			timeline.stamp(secs(2), secs(4)),
617			1,
618			"skipped records break the timeline even when the next one lines up"
619		);
620	}
621
622	#[test]
623	fn segment_ranges_and_gaps() {
624		let live = Producer::new();
625		let window = Duration::from_secs(30);
626		live.push(row(0, 0, 0, 1_000), window);
627		// Segment 1 is a gap for this rendition: no ranges.
628		live.push(
629			Row {
630				index: 1,
631				segment: 1,
632				ranges: Vec::new(),
633				duration: Duration::from_secs(1),
634				pts: moq_net::Timestamp::from_millis(1_000).unwrap(),
635				end: Duration::from_millis(2_000),
636				discontinuity: 0,
637			},
638			window,
639		);
640		live.push(row(2, 100, 2_000, 1_000), window);
641
642		assert_eq!(live.segment_ranges(0), Some(vec![Range::new(0, 0)]));
643		assert_eq!(live.segment_ranges(1), Some(vec![]), "a gap is present but empty");
644		assert_eq!(live.segment_ranges(7), None, "unknown segments miss");
645
646		// The gap row carries no keyframe group; the bootstrap group comes from segment 2.
647		assert_eq!(live.latest_keyframe_group(), Some(100));
648	}
649
650	#[test]
651	fn backwards_jump_resets_the_window() {
652		let live = Producer::new();
653		let window = Duration::from_secs(30);
654		live.push(row(0, 0, 10_000, 2_000), window);
655		live.push(row(1, 1, 12_000, 2_000), window);
656		live.push(row(2, 2, 1_000, 2_000), window); // restart: pts rewound
657
658		let snapshot = live.window();
659		assert_eq!(snapshot.segments.len(), 1, "the window restarted at the new row");
660		assert_eq!(snapshot.segments[0].segment, 2);
661
662		// A segment number that rewinds (a restarted publisher) resets the same way.
663		live.push(row(0, 0, 2_000, 2_000), window);
664		assert_eq!(live.window().segments.len(), 1);
665	}
666
667	#[test]
668	fn next_after_walks_segments() {
669		let live = Producer::new();
670		let window = Duration::from_secs(30);
671		live.push(row(0, 0, 0, 2_000), window);
672		live.push(row(1, 1, 2_000, 2_000), window);
673
674		let Next::Ready(first) = live.state.read().next_after(None) else {
675			panic!("expected a segment");
676		};
677		assert_eq!(first.segment, 0);
678		let Next::Ready(second) = live.state.read().next_after(Some(0)) else {
679			panic!("expected a segment");
680		};
681		assert_eq!(second.segment, 1);
682		assert!(
683			matches!(live.state.read().next_after(Some(1)), Next::Pending),
684			"nothing further while live"
685		);
686
687		live.end();
688		assert!(matches!(live.state.read().next_after(Some(1)), Next::Ended));
689	}
690}