ff_preview/timeline/runner.rs
1//! The timeline decode/present state machine.
2//!
3//! [`TimelineRunner`] owns the per-track decode buffers and the audio mixer,
4//! and drives frame presentation. Construct it via
5//! [`TimelinePlayer::open`](super::TimelinePlayer::open).
6
7use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
8use std::sync::{Arc, Mutex, mpsc};
9use std::thread::{self, JoinHandle};
10use std::time::Duration;
11
12use ff_filter::{BlendMode, RealtimeComposer, RealtimeLayer};
13use ff_format::{PixelFormat, VideoFrame};
14
15use crate::audio::AudioMixer;
16use crate::error::PreviewError;
17use crate::event::PlayerEvent;
18use crate::playback::SwsRgbaConverter;
19use crate::playback::decode_buffer::FrameResult;
20use crate::playback::master_clock::MasterClock;
21use crate::playback::player::PlayerCommand;
22use crate::playback::sink::FrameSink;
23
24use super::audio_resampling::spawn_audio_track_thread;
25use super::state::{AudioFadeConfig, AudioOnlyTrack, ClipState, OverlayLayer, TransitionState};
26use super::timeline_inner;
27
28// ── TimelineRunner ────────────────────────────────────────────────────────────
29
30/// Exclusive owner of the timeline decode pipeline.
31///
32/// Move to a background thread and call [`run`](Self::run). Register a
33/// [`FrameSink`] with [`set_sink`](Self::set_sink) before calling `run`.
34pub struct TimelineRunner {
35 pub(super) clips: Vec<ClipState>,
36 /// Secondary video overlay layers (V2, V3, …). Each is composited over V1
37 /// in order before the frame is delivered to the sink.
38 pub(super) overlay_layers: Vec<OverlayLayer>,
39 /// Dedicated audio-only clips (from A1, A2, … tracks). Each is started and
40 /// stopped as the playhead crosses its timeline window.
41 pub(super) audio_only_tracks: Vec<AudioOnlyTrack>,
42 /// Index of the clip currently being decoded and presented.
43 pub(super) active: usize,
44 /// Non-`None` while a crossfade transition is in progress.
45 pub(super) transition: Option<TransitionState>,
46 pub(super) cmd_rx: mpsc::Receiver<PlayerCommand>,
47 pub(super) event_tx: mpsc::SyncSender<PlayerEvent>,
48 pub(super) sink: Option<Box<dyn FrameSink>>,
49 pub(super) current_pts: Arc<AtomicU64>,
50 pub(super) paused: Arc<AtomicBool>,
51 pub(super) stopped: Arc<AtomicBool>,
52 pub(super) fps: f64,
53 pub(super) rate: f64,
54 pub(super) clock: MasterClock,
55 /// Media PTS to re-anchor the System clock to when `PlayerCommand::Play`
56 /// is received from a paused state. Updated on every seek and after every
57 /// presented frame so that accumulated wall-clock time during pause does
58 /// not advance `current_pts()` past the last known media position.
59 pub(super) resume_pts: Duration,
60 /// Pixel-format converter for the active (outgoing) frame.
61 pub(super) sws_a: SwsRgbaConverter,
62 /// Pixel-format converter for the incoming frame during transitions.
63 pub(super) sws_b: SwsRgbaConverter,
64 pub(super) rgba_a: Vec<u8>,
65 pub(super) rgba_b: Vec<u8>,
66 pub(super) blend_buf: Vec<u8>,
67 /// Width of the most recently presented primary-track frame; used to
68 /// synthesise fill frames during primary-track gaps.
69 pub(super) last_frame_w: u32,
70 /// Height of the most recently presented primary-track frame.
71 pub(super) last_frame_h: u32,
72 /// Scratch buffer for synthesising black fill frames during primary-track gaps.
73 pub(super) gap_buf: Vec<u8>,
74 /// Multi-track audio mixer — `None` when no clip has audio.
75 pub(super) audio_mixer: Option<Arc<Mutex<AudioMixer>>>,
76 /// Cancel flag for the currently running audio decode thread.
77 pub(super) active_audio_cancel: Option<Arc<AtomicBool>>,
78 /// Handle to the currently running audio decode thread.
79 pub(super) active_audio_thread: Option<JoinHandle<()>>,
80 /// Cached real-time compositor that applies per-clip effects + blend modes
81 /// (the same chain as export). Rebuilt only when the active clip set or frame
82 /// geometry changes; `None` until the first composite.
83 pub(super) composer: Option<RealtimeComposer>,
84 /// Identifies the composer's current configuration as
85 /// `(layer_id, active_clip_idx, width, height)` per layer. Rebuild on change.
86 pub(super) composer_key: Vec<(usize, usize, u32, u32)>,
87}
88
89impl TimelineRunner {
90 /// Register the frame sink. Call before [`run`](Self::run).
91 pub fn set_sink(&mut self, sink: Box<dyn FrameSink>) {
92 self.sink = Some(sink);
93 }
94
95 /// Advances every overlay layer to the frame whose presentation time has
96 /// arrived at `target_pts`, holding the current frame otherwise (so a layer
97 /// whose fps differs from the timeline plays at the right speed rather than
98 /// advancing once per present). Returns `(layer_index, width, height)` for
99 /// each layer that currently has a frame to show.
100 fn sync_overlays(&mut self, target_pts: Duration) -> Vec<(usize, u32, u32)> {
101 let mut active = Vec::new();
102 for (li, layer) in self.overlay_layers.iter_mut().enumerate() {
103 let maybe_cidx = layer
104 .clips
105 .iter()
106 .position(|c| target_pts >= c.timeline_start && target_pts < c.timeline_end);
107 let Some(cidx) = maybe_cidx else {
108 layer.rgba.clear();
109 layer.cur_dims = None;
110 layer.pending = None;
111 continue;
112 };
113 if cidx != layer.active {
114 let local = layer.clips[cidx].in_point
115 + target_pts.saturating_sub(layer.clips[cidx].timeline_start);
116 let _ = layer.clips[cidx].decode_buf.seek(local);
117 layer.active = cidx;
118 layer.cur_dims = None;
119 layer.pending = None;
120 }
121 let clip_in = layer.clips[cidx].in_point;
122 let tl_start = layer.clips[cidx].timeline_start;
123 loop {
124 let f = match layer.pending.take() {
125 Some(pf) => pf,
126 None => match layer.clips[cidx].decode_buf.pop_frame() {
127 FrameResult::Frame(f) => f,
128 _ => break,
129 },
130 };
131 let v2_pts = tl_start + f.timestamp().as_duration().saturating_sub(clip_in);
132 if v2_pts > target_pts {
133 // Not due yet — hold it for a later present.
134 layer.pending = Some(f);
135 break;
136 }
137 if layer.sws.convert(&f, &mut layer.rgba) {
138 layer.cur_dims = Some((f.width(), f.height()));
139 }
140 }
141 match layer.cur_dims {
142 Some((ow, oh)) => active.push((li, ow, oh)),
143 None => layer.rgba.clear(),
144 }
145 }
146 active
147 }
148
149 /// Composites `base_frame` (the bottom layer) with the given overlay layers
150 /// through the cached [`RealtimeComposer`], applying each layer's effects and
151 /// blend mode. `base_id` identifies the base for cache invalidation — the V1
152 /// clip index, or `usize::MAX` for the gap-fill black base. Returns the
153 /// composited RGBA frame together with its actual `(width, height)`, or `None`
154 /// on failure.
155 ///
156 /// The composited size can differ from `base_w`/`base_h` when the base layer's
157 /// effect chain resizes the frame (`Crop`, `Scale`, `Pad`, `FitToAspect`), so
158 /// callers must push the returned dimensions to the sink rather than the
159 /// decoded ones — otherwise the buffer length no longer matches the reported
160 /// size and the frame is dropped.
161 fn composite_frame(
162 &mut self,
163 base_layer: RealtimeLayer,
164 base_id: usize,
165 base_frame: &VideoFrame,
166 base_w: u32,
167 base_h: u32,
168 overlays: &[(usize, u32, u32)],
169 ) -> Option<(Vec<u8>, u32, u32)> {
170 let mut specs = vec![base_layer];
171 let mut key: Vec<(usize, usize, u32, u32)> = vec![(0, base_id, base_w, base_h)];
172 for &(li, ow, oh) in overlays {
173 let oc = &self.overlay_layers[li];
174 specs.push(
175 oc.clips[oc.active]
176 .clip
177 .realtime_layer(ow, oh, PixelFormat::Rgba),
178 );
179 key.push((li + 1, oc.active, ow, oh));
180 }
181 if self.composer.is_none() || self.composer_key != key {
182 self.composer = RealtimeComposer::new(&specs).ok();
183 self.composer_key = if self.composer.is_some() {
184 key
185 } else {
186 Vec::new()
187 };
188 }
189 let composer = self.composer.as_mut()?;
190 if composer.push_layer(0, base_frame).is_err() {
191 return None;
192 }
193 for (slot, &(li, ow, oh)) in overlays.iter().enumerate() {
194 let vf = VideoFrame::from_rgba(ow, oh, self.overlay_layers[li].rgba.clone()).ok()?;
195 if composer.push_layer(slot + 1, &vf).is_err() {
196 return None;
197 }
198 }
199 let f = composer.pull().ok().flatten()?;
200 let (w, h) = (f.width(), f.height());
201 f.to_rgba().map(|rgba| (rgba, w, h))
202 }
203
204 /// A/V sync presentation loop.
205 ///
206 /// Plays all clips in the primary video track from start to finish (or until
207 /// a [`PlayerCommand::Stop`] is received).
208 ///
209 /// Emits [`PlayerEvent::SeekCompleted`] after each successful seek,
210 /// [`PlayerEvent::PositionUpdate`] after each presented video frame,
211 /// [`PlayerEvent::Error`] on non-fatal decode errors, and
212 /// [`PlayerEvent::Eof`] before returning.
213 ///
214 /// # Errors
215 ///
216 /// Returns [`PreviewError::SeekOutOfRange`] if a seek command targets a
217 /// timestamp that falls outside all clips on the timeline.
218 #[allow(clippy::too_many_lines)]
219 pub fn run(mut self) -> Result<(), PreviewError> {
220 if self.clips.is_empty() {
221 let _ = self.event_tx.try_send(PlayerEvent::Eof);
222 return Ok(());
223 }
224
225 let fps = self.fps.max(1.0);
226 let frame_period = Duration::from_secs_f64(1.0 / fps);
227 self.clock.reset(Duration::ZERO);
228
229 loop {
230 // ── Drain commands ────────────────────────────────────────────────
231 let mut pending_seek: Option<Duration> = None;
232 while let Ok(cmd) = self.cmd_rx.try_recv() {
233 match cmd {
234 PlayerCommand::Seek(pts) => pending_seek = Some(pts),
235 PlayerCommand::Play => {
236 // Always re-anchor the System clock on Play.
237 //
238 // PlayerHandle::play() sets the shared `paused` atomic
239 // to `false` BEFORE enqueueing PlayerCommand::Play, so
240 // paused.load() here always returns false — a guard on
241 // `if paused` would never fire. Re-anchoring
242 // unconditionally is safe: when the player was not
243 // actually paused, resume_pts equals the last presented
244 // frame PTS (or the seek target), which is already the
245 // clock's current base, so clock.reset() is a no-op
246 // in effect.
247 self.clock.reset(self.resume_pts);
248 self.stopped.store(false, Ordering::Release);
249 self.paused.store(false, Ordering::Release);
250 }
251 PlayerCommand::Pause => {
252 self.paused.store(true, Ordering::Release);
253 }
254 PlayerCommand::Stop => {
255 self.stopped.store(true, Ordering::Release);
256 }
257 PlayerCommand::SetRate(r) => {
258 if r != 0.0 {
259 let was_negative = self.rate < 0.0;
260 self.rate = r;
261 if r > 0.0 {
262 self.clock.set_rate(r);
263 if was_negative {
264 // Returning from reverse: rebase clock and
265 // restart audio from the current video position.
266 let pts = Duration::from_micros(
267 self.current_pts.load(Ordering::Relaxed),
268 );
269 self.clock.reset(pts);
270 self.resume_pts = pts;
271 if let Err(e) = self.seek_timeline_coarse(pts) {
272 log::warn!(
273 "timeline reverse→forward seek failed \
274 pts={pts:?} error={e}"
275 );
276 } else {
277 let ci = self.active;
278 let clip_local = self.clips[ci].in_point
279 + pts.saturating_sub(self.clips[ci].timeline_start);
280 if let Some(m) = &self.audio_mixer {
281 m.lock()
282 .unwrap_or_else(std::sync::PoisonError::into_inner)
283 .invalidate_all();
284 }
285 self.restart_audio_at(ci, clip_local);
286 }
287 }
288 } else {
289 // Entering reverse: silence audio.
290 if let Some(cancel) = &self.active_audio_cancel {
291 cancel.store(true, Ordering::Release);
292 }
293 if let Some(m) = &self.audio_mixer {
294 m.lock()
295 .unwrap_or_else(std::sync::PoisonError::into_inner)
296 .invalidate_all();
297 }
298 }
299 }
300 }
301 PlayerCommand::SetAvOffset(_) => {} // audio timing is system-clock driven
302 PlayerCommand::UpdateLayout(timeline) => {
303 if let Err(e) = self.update_layout_in_place(&timeline, self.resume_pts) {
304 log::warn!("timeline layout update ignored: {e}");
305 }
306 }
307 }
308 }
309
310 // ── Apply pending seek ────────────────────────────────────────────
311 let had_seek = pending_seek.is_some();
312 if let Some(target) = pending_seek {
313 self.seek_timeline(target)?;
314 self.clock.reset(target);
315 self.resume_pts = target;
316 let _ = self.event_tx.try_send(PlayerEvent::SeekCompleted(target));
317 }
318
319 // When a seek arrives while paused, present one preview frame so
320 // the sink reflects the new position without resuming playback.
321 if had_seek && self.paused.load(Ordering::Acquire) {
322 let active = self.active;
323 let deadline = std::time::Instant::now() + Duration::from_millis(300);
324 loop {
325 match self.clips[active].decode_buf.pop_frame() {
326 FrameResult::Frame(f) => {
327 let f_pts = f.timestamp().as_duration();
328 let elapsed = f_pts.saturating_sub(self.clips[active].in_point);
329 let tl_pts = self.clips[active].timeline_start
330 + if (self.clips[active].speed - 1.0).abs() < 1e-9 {
331 elapsed
332 } else {
333 elapsed.div_f64(self.clips[active].speed)
334 };
335 let w = f.width();
336 let h = f.height();
337 if self.sws_a.convert(&f, &mut self.rgba_a)
338 && let Some(sink) = self.sink.as_mut()
339 {
340 sink.push_frame(&self.rgba_a, w, h, tl_pts);
341 }
342 self.current_pts.store(
343 u64::try_from(tl_pts.as_micros()).unwrap_or(u64::MAX),
344 Ordering::Relaxed,
345 );
346 let _ = self.event_tx.try_send(PlayerEvent::PositionUpdate(tl_pts));
347 break;
348 }
349 FrameResult::Seeking(_) => {
350 if std::time::Instant::now() > deadline {
351 break;
352 }
353 thread::sleep(Duration::from_millis(2));
354 }
355 FrameResult::Eof => break,
356 }
357 }
358 }
359
360 // ── Error events from active clip ─────────────────────────────────
361 {
362 let active = self.active;
363 while let Ok(msg) = self.clips[active].decode_buf.error_events().try_recv() {
364 let _ = self.event_tx.try_send(PlayerEvent::Error(msg));
365 }
366 }
367 let trans_next = self.transition.as_ref().map(|tp| tp.next_idx);
368 if let Some(next_idx) = trans_next {
369 while let Ok(msg) = self.clips[next_idx].decode_buf.error_events().try_recv() {
370 let _ = self.event_tx.try_send(PlayerEvent::Error(msg));
371 }
372 }
373
374 // ── Stopped / paused ──────────────────────────────────────────────
375 if self.stopped.load(Ordering::Acquire) {
376 break;
377 }
378 if self.paused.load(Ordering::Acquire) {
379 thread::sleep(Duration::from_millis(5));
380 continue;
381 }
382
383 // ── Reverse playback path ─────────────────────────────────────────
384 if self.rate < 0.0 {
385 let current = Duration::from_micros(self.current_pts.load(Ordering::Relaxed));
386 let step = Duration::from_secs_f64(self.rate.abs() / fps.max(f64::MIN_POSITIVE));
387 let target = current.saturating_sub(step);
388
389 let clip_idx = self
390 .clips
391 .iter()
392 .position(|c| target >= c.timeline_start && target < c.timeline_end);
393
394 if let Some(ci) = clip_idx {
395 let elapsed_tl = target.saturating_sub(self.clips[ci].timeline_start);
396 let clip_local = self.clips[ci].in_point
397 + if (self.clips[ci].speed - 1.0).abs() < 1e-9 {
398 elapsed_tl
399 } else {
400 elapsed_tl.mul_f64(self.clips[ci].speed)
401 };
402 if self.clips[ci].decode_buf.seek_coarse(clip_local).is_ok() {
403 if ci != self.active {
404 self.active = ci;
405 self.transition = None;
406 }
407 let deadline = std::time::Instant::now() + Duration::from_millis(300);
408 let frame = loop {
409 match self.clips[ci].decode_buf.pop_frame() {
410 FrameResult::Frame(f) => break Some(f),
411 FrameResult::Seeking(_) => {
412 if std::time::Instant::now() > deadline {
413 break None;
414 }
415 thread::sleep(Duration::from_millis(2));
416 }
417 FrameResult::Eof => break None,
418 }
419 };
420 if let Some(f) = frame {
421 let f_pts = f.timestamp().as_duration();
422 let elapsed = f_pts.saturating_sub(self.clips[ci].in_point);
423 let tl_pts = self.clips[ci].timeline_start
424 + if (self.clips[ci].speed - 1.0).abs() < 1e-9 {
425 elapsed
426 } else {
427 elapsed.div_f64(self.clips[ci].speed)
428 };
429 let w = f.width();
430 let h = f.height();
431 if self.sws_a.convert(&f, &mut self.rgba_a)
432 && let Some(sink) = self.sink.as_mut()
433 {
434 sink.push_frame(&self.rgba_a, w, h, tl_pts);
435 }
436 self.current_pts.store(
437 u64::try_from(tl_pts.as_micros()).unwrap_or(u64::MAX),
438 Ordering::Relaxed,
439 );
440 self.resume_pts = tl_pts;
441 let _ = self.event_tx.try_send(PlayerEvent::PositionUpdate(tl_pts));
442 }
443 }
444 }
445
446 if self
447 .clips
448 .first()
449 .is_some_and(|c| target < c.timeline_start)
450 {
451 self.paused.store(true, Ordering::Release);
452 }
453 thread::sleep(frame_period);
454 continue;
455 }
456
457 // ── Pop frame from active clip ─────────────────────────────────────
458 let active = self.active;
459 let pop_result = self.clips[active].decode_buf.pop_frame();
460
461 match pop_result {
462 FrameResult::Eof => {
463 let old_active = active;
464 if let Some(tp) = self.transition.take() {
465 self.active = tp.next_idx;
466 } else if active + 1 < self.clips.len() {
467 self.active += 1;
468 } else {
469 break;
470 }
471 if self.active != old_active {
472 // Clear the outgoing clip's pre-decoded audio so its stale
473 // samples do not continue to mix in after the transition.
474 if let Some(h) = self.clips[old_active].audio_track.clone() {
475 h.clear();
476 }
477 let in_pt = self.clips[self.active].in_point;
478 self.restart_audio_at(self.active, in_pt);
479 }
480 }
481
482 FrameResult::Seeking(last) => {
483 if let Some(ref f) = last {
484 let f_pts = f.timestamp().as_duration();
485 let in_pt = self.clips[active].in_point;
486 // Suppress pre-seek artefact frames: when a DecodeBuffer
487 // is opened and immediately seeked to in_point, the
488 // background thread may have decoded one frame from
489 // position 0 before processing the seek command. That
490 // frame ends up as `last` and must not be displayed —
491 // its content is from before the clip's in_point.
492 if f_pts >= in_pt {
493 let tl_start = self.clips[active].timeline_start;
494 let elapsed = f_pts.saturating_sub(in_pt);
495 let spd = self.clips[active].speed;
496 let tl_pts = tl_start
497 + if (spd - 1.0).abs() < 1e-9 {
498 elapsed
499 } else {
500 elapsed.div_f64(spd)
501 };
502 let w = f.width();
503 let h = f.height();
504 if self.sws_a.convert(f, &mut self.rgba_a)
505 && let Some(sink) = self.sink.as_mut()
506 {
507 sink.push_frame(&self.rgba_a, w, h, tl_pts);
508 }
509 }
510 }
511 }
512
513 FrameResult::Frame(frame) => {
514 let f_pts = frame.timestamp().as_duration();
515 let clip_in = self.clips[active].in_point;
516 let clip_out = self.clips[active].out_point;
517 let clip_tl_start = self.clips[active].timeline_start;
518 let clip_tl_end = self.clips[active].timeline_end;
519 let clip_speed = self.clips[active].speed;
520
521 // Skip frames before in_point (e.g. right after a seek).
522 if f_pts < clip_in {
523 continue;
524 }
525
526 // Treat frames past out_point as EOF for this clip.
527 let past_out = clip_out.is_some_and(|op| f_pts >= op);
528 let elapsed = f_pts.saturating_sub(clip_in);
529 // Remap source PTS → timeline PTS via speed factor.
530 // For speed=2.0 the clip occupies half the timeline duration;
531 // for speed=0.5 it occupies double.
532 let tl_elapsed = if (clip_speed - 1.0).abs() < 1e-9 {
533 elapsed
534 } else {
535 elapsed.div_f64(clip_speed)
536 };
537 let past_end = clip_tl_start + tl_elapsed >= clip_tl_end;
538
539 if past_out || past_end {
540 let old_active = active;
541 if let Some(tp) = self.transition.take() {
542 self.active = tp.next_idx;
543 } else if active + 1 < self.clips.len() {
544 self.active += 1;
545 } else {
546 break;
547 }
548 if self.active != old_active {
549 // Clear the outgoing clip's pre-decoded audio so its
550 // stale samples do not continue to mix in after the
551 // transition.
552 if let Some(h) = self.clips[old_active].audio_track.clone() {
553 h.clear();
554 }
555 let in_pt = self.clips[self.active].in_point;
556 self.restart_audio_at(self.active, in_pt);
557 }
558 continue;
559 }
560
561 let timeline_pts = clip_tl_start + tl_elapsed;
562
563 // ── Manage audio-only decode threads ──────────────────────
564 for at in &mut self.audio_only_tracks {
565 let should_run =
566 timeline_pts >= at.timeline_start && timeline_pts < at.timeline_end;
567 let is_running = at.cancel.is_some();
568 if should_run && !is_running {
569 let local =
570 at.in_point + timeline_pts.saturating_sub(at.timeline_start);
571 at.start_at(local);
572 } else if !should_run && is_running {
573 at.stop();
574 // Clear stale pre-decoded samples so the mixer does
575 // not play this track's buffered audio past clip end.
576 at.handle.clear();
577 }
578 }
579
580 // Update shared current_pts and resume anchor.
581 self.current_pts.store(
582 u64::try_from(timeline_pts.as_micros()).unwrap_or(u64::MAX),
583 Ordering::Relaxed,
584 );
585 self.resume_pts = timeline_pts;
586
587 // ── Transition zone entry check ────────────────────────────
588 if self.transition.is_none() && active + 1 < self.clips.len() {
589 let next = &self.clips[active + 1];
590 if next.transition_dur > Duration::ZERO
591 && timeline_pts >= next.timeline_start
592 {
593 if timeline_pts < next.timeline_start + next.transition_dur {
594 self.transition = Some(TransitionState {
595 next_idx: active + 1,
596 start: next.timeline_start,
597 duration: next.transition_dur,
598 });
599 } else {
600 // Jumped past the entire transition zone.
601 let old_active = active;
602 self.active = active + 1;
603 if self.active != old_active {
604 let in_pt = self.clips[self.active].in_point;
605 self.restart_audio_at(self.active, in_pt);
606 }
607 continue;
608 }
609 }
610 }
611
612 // ── A/V sync (system clock) ───────────────────────────────
613 {
614 let clock_pts = self.clock.current_pts();
615 let diff = timeline_pts.as_secs_f64() - clock_pts.as_secs_f64();
616 let fp = frame_period.as_secs_f64();
617
618 // Only enter gap fill for an actual gap between clips.
619 // For slow-motion clips (speed < 1.0) the large diff is expected
620 // and should be handled by the `diff > fp` sleep below instead.
621 if diff > fp * 2.0
622 && (clip_speed - 1.0) > -1e-9
623 && self.transition.is_none()
624 && self.last_frame_w > 0
625 {
626 // Gap in the primary track: the next V1 clip starts more than
627 // 2 frame-periods ahead of the clock. Synthesise black frames
628 // composited with overlay-layer content for every missing
629 // frame period so that V2 overlays and audio-only tracks
630 // remain live during the gap.
631 let gw = self.last_frame_w;
632 let gh = self.last_frame_h;
633 let n = (gw * gh * 4) as usize;
634 'gap: loop {
635 // Drain incoming commands.
636 while let Ok(cmd) = self.cmd_rx.try_recv() {
637 match cmd {
638 PlayerCommand::Play => {
639 self.clock.reset(self.resume_pts);
640 self.stopped.store(false, Ordering::Release);
641 self.paused.store(false, Ordering::Release);
642 }
643 PlayerCommand::Pause => {
644 self.paused.store(true, Ordering::Release);
645 }
646 PlayerCommand::Stop => {
647 self.stopped.store(true, Ordering::Release);
648 }
649 PlayerCommand::SetRate(r) => {
650 if r > 0.0 {
651 self.rate = r;
652 self.clock.set_rate(r);
653 }
654 }
655 _ => {}
656 }
657 }
658 if self.stopped.load(Ordering::Acquire) {
659 break 'gap;
660 }
661 if self.paused.load(Ordering::Acquire) {
662 thread::sleep(Duration::from_millis(5));
663 continue 'gap;
664 }
665 let gap_pts = self.clock.current_pts();
666 if gap_pts + frame_period >= timeline_pts {
667 break 'gap;
668 }
669 // Build a black base and composite the overlays onto it
670 // through the shared compositor — same held-frame timing,
671 // effects, and blend modes as the main present path.
672 self.gap_buf.resize(n, 0);
673 self.gap_buf.fill(0);
674 let gap_overlays = self.sync_overlays(gap_pts);
675 let gap_composited = if gap_overlays.is_empty() {
676 None
677 } else {
678 let base_layer = RealtimeLayer {
679 width: gw,
680 height: gh,
681 pixel_format: PixelFormat::Rgba,
682 effects: Vec::new(),
683 opacity: 1.0,
684 blend_mode: BlendMode::Normal,
685 };
686 match VideoFrame::from_rgba(gw, gh, self.gap_buf.clone()) {
687 Ok(bf) => self.composite_frame(
688 base_layer,
689 usize::MAX,
690 &bf,
691 gw,
692 gh,
693 &gap_overlays,
694 ),
695 Err(_) => None,
696 }
697 };
698 // Manage audio-only decode threads (A1/A2…).
699 for at in &mut self.audio_only_tracks {
700 let should_run =
701 gap_pts >= at.timeline_start && gap_pts < at.timeline_end;
702 let is_running = at.cancel.is_some();
703 if should_run && !is_running {
704 let local =
705 at.in_point + gap_pts.saturating_sub(at.timeline_start);
706 at.start_at(local);
707 } else if !should_run && is_running {
708 at.stop();
709 at.handle.clear();
710 }
711 }
712 // Manage V1 inline audio: start it the moment the
713 // gap clock reaches the active clip's timeline_start.
714 if self.active_audio_cancel.is_none()
715 && self.clips[self.active].audio_track.is_some()
716 && gap_pts >= self.clips[self.active].timeline_start
717 {
718 let tl_start = self.clips[self.active].timeline_start;
719 let in_pt = self.clips[self.active].in_point;
720 let gap_elapsed = gap_pts.saturating_sub(tl_start);
721 let spd = self.clips[self.active].speed;
722 let local = in_pt
723 + if (spd - 1.0).abs() < 1e-9 {
724 gap_elapsed
725 } else {
726 gap_elapsed.mul_f64(spd)
727 };
728 self.restart_audio_at(self.active, local);
729 }
730 self.current_pts.store(
731 u64::try_from(gap_pts.as_micros()).unwrap_or(u64::MAX),
732 Ordering::Relaxed,
733 );
734 self.resume_pts = gap_pts;
735 let _ =
736 self.event_tx.try_send(PlayerEvent::PositionUpdate(gap_pts));
737 if let Some(sink) = self.sink.as_mut() {
738 match &gap_composited {
739 Some((rgba, cw, ch)) => {
740 sink.push_frame(rgba, *cw, *ch, gap_pts);
741 }
742 None => sink.push_frame(&self.gap_buf, gw, gh, gap_pts),
743 }
744 }
745 thread::sleep(frame_period);
746 }
747 } else if diff > fp {
748 let sleep_secs =
749 (diff - fp / 2.0).max(0.0) / self.rate.max(f64::MIN_POSITIVE);
750 thread::sleep(Duration::from_secs_f64(sleep_secs));
751 } else if diff < -fp {
752 log::debug!(
753 "timeline dropped late frame timeline_pts={timeline_pts:?} \
754 clock_pts={clock_pts:?}"
755 );
756 continue;
757 }
758 }
759
760 // Start V1 inline audio on the first presented frame when a
761 // pre-roll gap prevented the thread from starting at open() time.
762 // The gap-fill loop attempts this but exits one frame-period before
763 // timeline_start, so we catch the remaining case here.
764 if self.active_audio_cancel.is_none()
765 && self.clips[active].audio_track.is_some()
766 {
767 let in_pt = self.clips[active].in_point;
768 let elapsed_tl =
769 timeline_pts.saturating_sub(self.clips[active].timeline_start);
770 let local = in_pt
771 + if (clip_speed - 1.0).abs() < 1e-9 {
772 elapsed_tl
773 } else {
774 elapsed_tl.mul_f64(clip_speed)
775 };
776 self.restart_audio_at(active, local);
777 }
778
779 // ── Present frame ─────────────────────────────────────────
780 let w = frame.width();
781 let h = frame.height();
782 self.last_frame_w = w;
783 self.last_frame_h = h;
784
785 // Copy transition fields to avoid holding a borrow while
786 // calling `pop_frame` on the next clip.
787 let (in_trans, next_idx, trans_start, trans_dur) = match &self.transition {
788 Some(tp) => (true, tp.next_idx, tp.start, tp.duration),
789 None => (false, 0, Duration::ZERO, Duration::ZERO),
790 };
791
792 let a_ok = self.sws_a.convert(&frame, &mut self.rgba_a);
793
794 if a_ok {
795 // V1 per-clip opacity: pre-multiply toward black (producer-side;
796 // the composer ignores base-layer opacity).
797 let v1_op = self.clips[active].opacity;
798 if (v1_op - 1.0).abs() > 1e-6 {
799 for chunk in self.rgba_a.chunks_exact_mut(4) {
800 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
801 {
802 chunk[0] = (f32::from(chunk[0]) * v1_op).round() as u8;
803 chunk[1] = (f32::from(chunk[1]) * v1_op).round() as u8;
804 chunk[2] = (f32::from(chunk[2]) * v1_op).round() as u8;
805 }
806 }
807 }
808
809 // Transition crossfade (producer-side): blend the incoming clip
810 // into rgba_a so the composer grades the crossfaded V1 frame.
811 if in_trans
812 && let FrameResult::Frame(next_frame) =
813 self.clips[next_idx].decode_buf.pop_frame()
814 && self.sws_b.convert(&next_frame, &mut self.rgba_b)
815 {
816 let alpha = (timeline_pts.saturating_sub(trans_start).as_secs_f32()
817 / trans_dur.as_secs_f32())
818 .clamp(0.0, 1.0);
819 timeline_inner::blend_rgba(
820 &self.rgba_a,
821 &self.rgba_b,
822 alpha,
823 &mut self.blend_buf,
824 );
825 std::mem::swap(&mut self.rgba_a, &mut self.blend_buf);
826 }
827
828 // Update overlays (held-frame, advanced by PTS) and composite
829 // the V1 base with them through the shared compositor.
830 let active_overlays = self.sync_overlays(timeline_pts);
831 let base_layer =
832 self.clips[active]
833 .clip
834 .realtime_layer(w, h, PixelFormat::Rgba);
835 let composited = match VideoFrame::from_rgba(w, h, self.rgba_a.clone()) {
836 Ok(bf) => self.composite_frame(
837 base_layer,
838 active,
839 &bf,
840 w,
841 h,
842 &active_overlays,
843 ),
844 Err(_) => None,
845 };
846
847 // Deliver: the composited frame, or the raw V1 as a fallback.
848 if let Some(sink) = self.sink.as_mut() {
849 match &composited {
850 Some((rgba, cw, ch)) => {
851 sink.push_frame(rgba, *cw, *ch, timeline_pts);
852 }
853 None => sink.push_frame(&self.rgba_a, w, h, timeline_pts),
854 }
855 }
856
857 // Advance past a completed transition.
858 if in_trans && timeline_pts >= trans_start + trans_dur {
859 let old_active = self.active;
860 self.transition = None;
861 self.active = next_idx;
862 if self.active != old_active {
863 let in_pt = self.clips[self.active].in_point;
864 self.restart_audio_at(self.active, in_pt);
865 }
866 }
867 }
868
869 let _ = self
870 .event_tx
871 .try_send(PlayerEvent::PositionUpdate(timeline_pts));
872 }
873 }
874 }
875
876 let _ = self.event_tx.try_send(PlayerEvent::Eof);
877 if let Some(sink) = self.sink.as_mut() {
878 sink.flush();
879 }
880 Ok(())
881 }
882
883 /// Seek all decode buffers so that `active` is the clip containing `target`
884 /// and that clip's buffer is positioned at the correct source-file PTS.
885 ///
886 /// When `target` falls in a pre-roll or inter-clip gap the method finds the
887 /// next clip after `target`, seeks it to its `in_point`, and returns without
888 /// starting audio — the gap-fill loop in `run()` will start audio at the
889 /// right time.
890 pub(super) fn seek_timeline(&mut self, target: Duration) -> Result<(), PreviewError> {
891 // Try to find a clip that contains `target`.
892 let clip_in_range = self
893 .clips
894 .iter()
895 .position(|c| target >= c.timeline_start && target < c.timeline_end);
896
897 // If target is in a gap, find the next clip after `target`.
898 let (clip_idx, clip_local_pts, is_gap_seek) = if let Some(ci) = clip_in_range {
899 let elapsed_tl = target.saturating_sub(self.clips[ci].timeline_start);
900 let local = self.clips[ci].in_point
901 + if (self.clips[ci].speed - 1.0).abs() < 1e-9 {
902 elapsed_tl
903 } else {
904 elapsed_tl.mul_f64(self.clips[ci].speed)
905 };
906 (ci, local, false)
907 } else if let Some(ci) = self.clips.iter().position(|c| c.timeline_start > target) {
908 // Seek the clip to its in_point; gap-fill loop will tick until it starts.
909 (ci, self.clips[ci].in_point, true)
910 } else {
911 return Err(PreviewError::SeekOutOfRange { pts: target });
912 };
913
914 self.clips[clip_idx].decode_buf.seek(clip_local_pts)?;
915 self.active = clip_idx;
916 self.transition = None;
917
918 // Discard stale audio and restart from the seek position.
919 if let Some(mixer_arc) = &self.audio_mixer {
920 mixer_arc
921 .lock()
922 .unwrap_or_else(std::sync::PoisonError::into_inner)
923 .invalidate_all();
924 }
925 if is_gap_seek {
926 // Cancel any running V1 audio thread; the gap loop will restart it
927 // once the clock reaches the clip's timeline_start.
928 if let Some(cancel) = self.active_audio_cancel.take() {
929 cancel.store(true, Ordering::Release);
930 }
931 drop(self.active_audio_thread.take());
932 } else {
933 self.restart_audio_at(clip_idx, clip_local_pts);
934 }
935
936 // Seek overlay layers to the new target position.
937 for layer in &mut self.overlay_layers {
938 let cidx = layer
939 .clips
940 .iter()
941 .position(|c| target >= c.timeline_start && target < c.timeline_end);
942 if let Some(cidx) = cidx {
943 let local = layer.clips[cidx].in_point
944 + target.saturating_sub(layer.clips[cidx].timeline_start);
945 let _ = layer.clips[cidx].decode_buf.seek(local);
946 layer.active = cidx;
947 }
948 }
949
950 // Stop all audio-only threads; they restart on the next frame tick.
951 for at in &mut self.audio_only_tracks {
952 at.stop();
953 }
954
955 Ok(())
956 }
957
958 /// Coarse (I-frame only) seek variant of [`seek_timeline`].
959 ///
960 /// Does not restart audio or invalidate the mixer — caller is responsible.
961 /// Used for the reverse→forward recovery path where latency matters more
962 /// than frame-accurate positioning.
963 fn seek_timeline_coarse(&mut self, target: Duration) -> Result<(), PreviewError> {
964 let clip_idx = self
965 .clips
966 .iter()
967 .position(|c| target >= c.timeline_start && target < c.timeline_end)
968 .ok_or(PreviewError::SeekOutOfRange { pts: target })?;
969 let elapsed_tl = target.saturating_sub(self.clips[clip_idx].timeline_start);
970 let clip_local_pts = self.clips[clip_idx].in_point
971 + if (self.clips[clip_idx].speed - 1.0).abs() < 1e-9 {
972 elapsed_tl
973 } else {
974 elapsed_tl.mul_f64(self.clips[clip_idx].speed)
975 };
976 self.clips[clip_idx]
977 .decode_buf
978 .seek_coarse(clip_local_pts)?;
979 self.active = clip_idx;
980 self.transition = None;
981 Ok(())
982 }
983
984 /// Cancel the current audio decode thread (if any) and start a new one
985 /// for `clip_idx` beginning at `start_pts`.
986 fn restart_audio_at(&mut self, clip_idx: usize, start_pts: Duration) {
987 // Cancel and drop the previous thread.
988 if let Some(cancel) = &self.active_audio_cancel {
989 cancel.store(true, Ordering::Release);
990 }
991 drop(self.active_audio_thread.take());
992 self.active_audio_cancel = None;
993
994 let Some(handle) = self.clips.get(clip_idx).and_then(|c| c.audio_track.clone()) else {
995 return;
996 };
997 handle.clear(); // discard stale samples
998
999 let source = self.clips[clip_idx].source.clone();
1000 let clip_speed = self.clips[clip_idx].speed;
1001 let cancel = Arc::new(AtomicBool::new(false));
1002 let thread = spawn_audio_track_thread(
1003 source,
1004 start_pts,
1005 handle,
1006 Arc::clone(&cancel),
1007 AudioFadeConfig {
1008 speed: clip_speed,
1009 ..AudioFadeConfig::NONE
1010 },
1011 );
1012 self.active_audio_cancel = Some(cancel);
1013 self.active_audio_thread = Some(thread);
1014 }
1015}
1016
1017impl Drop for TimelineRunner {
1018 fn drop(&mut self) {
1019 if let Some(cancel) = &self.active_audio_cancel {
1020 cancel.store(true, Ordering::Release);
1021 }
1022 if let Some(h) = self.active_audio_thread.take() {
1023 let _ = h.join();
1024 }
1025 }
1026}