Skip to main content

ff_preview/timeline/
mod.rs

1//! Real-time playback of a [`Timeline`].
2//!
3//! [`TimelinePlayer`] opens every clip on the primary video track of a
4//! [`Timeline`] and plays them back in order, mapping each clip's frame PTS
5//! to the unified timeline coordinate.
6//!
7//! | Type | Role |
8//! |------|------|
9//! | [`TimelinePlayer`] | Thin builder: call [`open`](TimelinePlayer::open) |
10//! | [`TimelineRunner`] | Owns the decode pipelines; move to a thread and call [`run`](TimelineRunner::run) |
11//! | [`PlayerHandle`] | Shared, cloneable control handle |
12//!
13//! ## Audio
14//!
15//! When any clip on the primary video track carries an audio stream,
16//! [`TimelinePlayer::open`] creates an [`AudioMixer`] with one track per
17//! audio-bearing clip.  A background [`AudioDecoder`](ff_decode::AudioDecoder) thread is started for
18//! the active clip and pushes mono samples via [`AudioTrackHandle`].  On clip
19//! transition or seek the old thread is cancelled and a new one is started.
20//! [`PlayerHandle::pop_audio_samples`] calls [`AudioMixer::mix`] and returns
21//! interleaved stereo `f32` output.
22
23mod audio_resampling;
24mod runner;
25mod runner_layout;
26mod state;
27mod timeline_inner;
28
29use std::path::PathBuf;
30use std::sync::atomic::{AtomicBool, AtomicU64};
31use std::sync::{Arc, Mutex, mpsc};
32use std::time::{Duration, Instant};
33
34use ff_pipeline::Clip;
35use ff_pipeline::timeline::Timeline;
36
37use crate::audio::{AudioMixer, AudioTrackHandle};
38use crate::error::PreviewError;
39use crate::event::PlayerEvent;
40use crate::playback::SwsRgbaConverter;
41use crate::playback::decode_buffer::DecodeBuffer;
42use crate::playback::master_clock::MasterClock;
43use crate::playback::player_handle::PlayerHandle;
44
45pub use runner::TimelineRunner;
46
47use audio_resampling::spawn_audio_track_thread;
48use state::{AudioFadeConfig, AudioOnlyTrack, ClipState, OverlayLayer};
49
50// -- Constants --
51
52const CHANNEL_CAP: usize = 64;
53
54// ── TimelinePlayer ────────────────────────────────────────────────────────────
55
56/// Thin builder for a ([`TimelineRunner`], [`PlayerHandle`]) pair backed by a
57/// [`Timeline`].
58///
59/// Playback is limited to the primary video track (`video_tracks[0]`). When
60/// any clip carries an audio stream, an [`AudioMixer`] is created and audio
61/// is mixed into the stereo output from [`PlayerHandle::pop_audio_samples`].
62///
63/// # Example
64///
65/// ```ignore
66/// use ff_pipeline::{Timeline, Clip};
67/// use ff_preview::{TimelinePlayer, RgbaSink};
68/// use std::time::Duration;
69///
70/// let timeline = Timeline::builder()
71///     .canvas(1920, 1080)
72///     .frame_rate(30.0)
73///     .video_track(vec![
74///         Clip::new("intro.mp4").trim(Duration::ZERO, Duration::from_secs(5)),
75///     ])
76///     .build()?;
77///
78/// let (mut runner, handle) = TimelinePlayer::open(&timeline)?;
79/// runner.set_sink(Box::new(RgbaSink::new()));
80/// std::thread::spawn(move || { let _ = runner.run(); });
81/// handle.play();
82/// ```
83pub struct TimelinePlayer;
84
85impl TimelinePlayer {
86    /// Open `timeline` for real-time preview playback.
87    ///
88    /// Probes every clip's source file to determine effective durations and
89    /// audio availability, opens a [`DecodeBuffer`] for each clip on the
90    /// primary video track, and seeks each buffer to its configured `in_point`.
91    ///
92    /// When any clip carries an audio stream an [`AudioMixer`] is created and
93    /// the first audio-bearing clip's decode thread is started immediately.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`PreviewError`] when:
98    /// - `timeline` has no video tracks or the primary track is empty,
99    /// - a clip source file cannot be found or opened,
100    /// - a clip cannot be probed for duration.
101    #[allow(clippy::too_many_lines)]
102    pub fn open(timeline: &Timeline) -> Result<(TimelineRunner, PlayerHandle), PreviewError> {
103        struct ProbeResult {
104            source: PathBuf,
105            in_pt: Duration,
106            clip_dur: Duration,
107            timeline_offset: Duration,
108            out_point: Option<Duration>,
109            transition_dur: Duration,
110            has_audio: bool,
111            /// Video frame dimensions — used to pre-populate `last_frame_w/h` so the
112            /// gap-fill loop can synthesise black frames before the first real frame.
113            video_w: u32,
114            video_h: u32,
115            speed: f64,
116            opacity: f32,
117            clip: Clip,
118        }
119
120        let tracks = timeline.video_tracks();
121        if tracks.is_empty() || tracks[0].is_empty() {
122            return Err(PreviewError::Ffmpeg {
123                code: 0,
124                message: "timeline has no video clips in the primary track".into(),
125            });
126        }
127
128        let fps = timeline.frame_rate().max(1.0);
129        let clip_list = &tracks[0];
130
131        // ── Phase 1: probe all clips ──────────────────────────────────────────
132
133        let mut probes: Vec<ProbeResult> = Vec::with_capacity(clip_list.len());
134        let mut has_any_audio = false;
135
136        for clip in clip_list {
137            let in_pt = clip.in_point.unwrap_or(Duration::ZERO);
138            let info = ff_probe::open(&clip.source)?;
139            let speed = clip.speed.max(0.01);
140
141            let unscaled_dur = match (clip.in_point, clip.out_point) {
142                (Some(ip), Some(op)) => op.saturating_sub(ip),
143                (None, Some(op)) => op,
144                _ => info.duration().saturating_sub(in_pt),
145            };
146            let clip_dur = if (speed - 1.0).abs() < 1e-9 {
147                unscaled_dur
148            } else {
149                unscaled_dur.div_f64(speed)
150            };
151
152            let transition_dur = if clip.transition.is_some() {
153                clip.transition_duration
154            } else {
155                Duration::ZERO
156            };
157
158            let has_audio = info.has_audio();
159            has_any_audio |= has_audio;
160
161            let (video_w, video_h) = info
162                .primary_video()
163                .map_or((0, 0), |v| (v.width(), v.height()));
164
165            probes.push(ProbeResult {
166                source: clip.source.clone(),
167                in_pt,
168                clip_dur,
169                timeline_offset: clip.timeline_offset,
170                out_point: clip.out_point,
171                transition_dur,
172                has_audio,
173                video_w,
174                video_h,
175                speed,
176                opacity: clip.opacity.clamp(0.0, 1.0),
177                clip: clip.clone(),
178            });
179        }
180
181        // ── Phase 2: build mixer and track handles (if audio present) ─────────
182
183        let (mut mixer_arc, audio_track_handles): (
184            Option<Arc<Mutex<AudioMixer>>>,
185            Vec<Option<AudioTrackHandle>>,
186        ) = if has_any_audio {
187            let mut mixer = AudioMixer::new(48_000);
188            let handles: Vec<Option<AudioTrackHandle>> = probes
189                .iter()
190                .map(|p| {
191                    if p.has_audio {
192                        Some(mixer.add_track())
193                    } else {
194                        None
195                    }
196                })
197                .collect();
198            (Some(Arc::new(Mutex::new(mixer))), handles)
199        } else {
200            (None, probes.iter().map(|_| None).collect())
201        };
202
203        // ── Phase 3: build ClipState objects ──────────────────────────────────
204
205        let mut clip_states: Vec<ClipState> = Vec::with_capacity(probes.len());
206        for (i, p) in probes.iter().enumerate() {
207            let timeline_start = p.timeline_offset;
208            let timeline_end = timeline_start + p.clip_dur;
209
210            let mut decode_buf = DecodeBuffer::open(&p.source).build()?;
211            if p.in_pt > Duration::ZERO {
212                decode_buf.seek(p.in_pt)?;
213            }
214
215            clip_states.push(ClipState {
216                source: p.source.clone(),
217                decode_buf,
218                timeline_start,
219                timeline_end,
220                in_point: p.in_pt,
221                out_point: p.out_point,
222                transition_dur: p.transition_dur,
223                audio_track: audio_track_handles[i].clone(),
224                speed: p.speed,
225                opacity: p.opacity,
226                clip: p.clip.clone(),
227            });
228        }
229
230        // ── Phase 4: build overlay layers (V2, V3, …) ────────────────────────
231        // Audio from V2+ clips is routed through AudioOnlyTrack (same mechanism as
232        // A1) so it is started/stopped as the playhead crosses each clip window.
233
234        let mut audio_only_tracks: Vec<AudioOnlyTrack> = Vec::new();
235
236        let mut overlay_layers: Vec<OverlayLayer> = Vec::new();
237        for v_track in timeline.video_tracks().iter().skip(1) {
238            if v_track.is_empty() {
239                continue;
240            }
241            let mut layer_clips: Vec<ClipState> = Vec::new();
242            for clip in v_track {
243                let in_pt = clip.in_point.unwrap_or(Duration::ZERO);
244                let info = ff_probe::open(&clip.source)?;
245                let clip_dur = match (clip.in_point, clip.out_point) {
246                    (Some(ip), Some(op)) => op.saturating_sub(ip),
247                    (None, Some(op)) => op,
248                    _ => info.duration().saturating_sub(in_pt),
249                };
250                let timeline_start = clip.timeline_offset;
251                let timeline_end = timeline_start + clip_dur;
252                let mut decode_buf = DecodeBuffer::open(&clip.source).build()?;
253                if in_pt > Duration::ZERO {
254                    decode_buf.seek(in_pt)?;
255                }
256                if info.has_audio() {
257                    let mixer_ref = mixer_arc
258                        .get_or_insert_with(|| Arc::new(Mutex::new(AudioMixer::new(48_000))));
259                    let handle = mixer_ref
260                        .lock()
261                        .unwrap_or_else(std::sync::PoisonError::into_inner)
262                        .add_track();
263                    audio_only_tracks.push(AudioOnlyTrack {
264                        source: clip.source.clone(),
265                        timeline_start,
266                        timeline_end,
267                        in_point: in_pt,
268                        fade_in: clip.fade_in,
269                        fade_out: clip.fade_out,
270                        clip_dur,
271                        handle,
272                        cancel: None,
273                        thread: None,
274                    });
275                }
276                layer_clips.push(ClipState {
277                    source: clip.source.clone(),
278                    decode_buf,
279                    timeline_start,
280                    timeline_end,
281                    in_point: in_pt,
282                    out_point: clip.out_point,
283                    transition_dur: Duration::ZERO,
284                    audio_track: None,
285                    speed: clip.speed.max(0.01),
286                    opacity: clip.opacity.clamp(0.0, 1.0),
287                    clip: clip.clone(),
288                });
289            }
290            overlay_layers.push(OverlayLayer {
291                clips: layer_clips,
292                active: 0,
293                sws: SwsRgbaConverter::new(),
294                rgba: Vec::new(),
295                cur_dims: None,
296                pending: None,
297            });
298        }
299
300        // ── Phase 5: build audio-only tracks (A1, A2, …) ─────────────────────
301
302        for a_track in timeline.audio_tracks() {
303            for clip in a_track {
304                let in_pt = clip.in_point.unwrap_or(Duration::ZERO);
305                let info = ff_probe::open(&clip.source)?;
306                if !info.has_audio() {
307                    continue;
308                }
309                let clip_dur = match (clip.in_point, clip.out_point) {
310                    (Some(ip), Some(op)) => op.saturating_sub(ip),
311                    (None, Some(op)) => op,
312                    _ => info.duration().saturating_sub(in_pt),
313                };
314                let timeline_start = clip.timeline_offset;
315                let timeline_end = timeline_start + clip_dur;
316                // Lazily create the mixer if no V1 clip had audio.
317                let mixer_ref =
318                    mixer_arc.get_or_insert_with(|| Arc::new(Mutex::new(AudioMixer::new(48_000))));
319                let handle = mixer_ref
320                    .lock()
321                    .unwrap_or_else(std::sync::PoisonError::into_inner)
322                    .add_track();
323                // Apply per-clip gain (dB → linear).
324                if clip.volume_db != 0.0 {
325                    #[allow(clippy::cast_possible_truncation)]
326                    let linear = 10.0_f64.powf(clip.volume_db / 20.0) as f32;
327                    handle.set_volume(linear);
328                }
329                audio_only_tracks.push(AudioOnlyTrack {
330                    source: clip.source.clone(),
331                    timeline_start,
332                    timeline_end,
333                    in_point: in_pt,
334                    fade_in: clip.fade_in,
335                    fade_out: clip.fade_out,
336                    clip_dur,
337                    handle,
338                    cancel: None,
339                    thread: None,
340                });
341            }
342        }
343
344        // ── Compute total duration ─────────────────────────────────────────────
345
346        let total_dur = clip_states
347            .iter()
348            .map(|c| c.timeline_end)
349            .max()
350            .unwrap_or(Duration::ZERO);
351        let duration_millis = u64::try_from(total_dur.as_millis()).unwrap_or(u64::MAX);
352
353        // ── Build runner and handle ───────────────────────────────────────────
354
355        let current_pts = Arc::new(AtomicU64::new(0));
356        let paused = Arc::new(AtomicBool::new(false));
357        let stopped = Arc::new(AtomicBool::new(false));
358        let (cmd_tx, cmd_rx) = mpsc::sync_channel(CHANNEL_CAP);
359        let (event_tx, event_rx) = mpsc::sync_channel::<PlayerEvent>(CHANNEL_CAP);
360
361        // Only start the audio thread for the first V1 clip immediately when that
362        // clip begins at timeline position 0.  When there is a pre-roll gap the
363        // gap-fill loop starts the audio at the correct timeline position instead.
364        let first_clip_at_origin = clip_states
365            .first()
366            .is_some_and(|c| c.timeline_start == Duration::ZERO);
367        let (initial_audio_cancel, initial_audio_thread) = if first_clip_at_origin {
368            if let Some(handle) = clip_states.first().and_then(|c| c.audio_track.clone()) {
369                let source = clip_states[0].source.clone();
370                let in_pt = clip_states[0].in_point;
371                let clip0_speed = clip_states[0].speed;
372                let cancel = Arc::new(AtomicBool::new(false));
373                let thread = spawn_audio_track_thread(
374                    source,
375                    in_pt,
376                    handle,
377                    Arc::clone(&cancel),
378                    AudioFadeConfig {
379                        speed: clip0_speed,
380                        ..AudioFadeConfig::NONE
381                    },
382                );
383                (Some(cancel), Some(thread))
384            } else {
385                (None, None)
386            }
387        } else {
388            (None, None)
389        };
390
391        // Pre-populate frame dimensions from the first clip's probe so the gap-fill
392        // loop can synthesise black frames even before the first real frame arrives.
393        let (initial_last_w, initial_last_h) =
394            probes.first().map_or((0, 0), |p| (p.video_w, p.video_h));
395
396        let runner = TimelineRunner {
397            clips: clip_states,
398            overlay_layers,
399            audio_only_tracks,
400            active: 0,
401            transition: None,
402            cmd_rx,
403            event_tx,
404            sink: None,
405            current_pts: Arc::clone(&current_pts),
406            paused: Arc::clone(&paused),
407            stopped: Arc::clone(&stopped),
408            fps,
409            rate: 1.0,
410            clock: MasterClock::System {
411                started_at: Instant::now(),
412                base_pts: Duration::ZERO,
413                rate: 1.0,
414            },
415            resume_pts: Duration::ZERO,
416            sws_a: SwsRgbaConverter::new(),
417            sws_b: SwsRgbaConverter::new(),
418            rgba_a: Vec::new(),
419            rgba_b: Vec::new(),
420            blend_buf: Vec::new(),
421            last_frame_w: initial_last_w,
422            last_frame_h: initial_last_h,
423            gap_buf: Vec::new(),
424            audio_mixer: mixer_arc.clone(),
425            active_audio_cancel: initial_audio_cancel,
426            active_audio_thread: initial_audio_thread,
427            composer: None,
428            composer_key: Vec::new(),
429        };
430
431        let handle = PlayerHandle::for_timeline(
432            cmd_tx,
433            Arc::new(Mutex::new(event_rx)),
434            current_pts,
435            paused,
436            stopped,
437            duration_millis,
438            mixer_arc,
439        );
440
441        Ok((runner, handle))
442    }
443}
444
445// ── Tests ─────────────────────────────────────────────────────────────────────
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use std::path::PathBuf;
451    use std::thread;
452
453    fn test_video_path() -> PathBuf {
454        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/video/gameplay.mp4")
455    }
456
457    // ── blend_rgba delegate ────────────────────────────────────────────────
458
459    #[test]
460    fn timeline_inner_blend_rgba_at_zero_alpha_should_return_a() {
461        let a = vec![255u8, 0, 0, 255];
462        let b = vec![0u8, 0, 255, 255];
463        let mut dst = Vec::new();
464        timeline_inner::blend_rgba(&a, &b, 0.0, &mut dst);
465        assert_eq!(dst, a);
466    }
467
468    // ── open ──────────────────────────────────────────────────────────────
469
470    #[test]
471    fn timeline_player_open_should_fail_when_no_video_tracks() {
472        let _ = PreviewError::SeekOutOfRange {
473            pts: Duration::from_secs(1),
474        };
475    }
476
477    // ── run ───────────────────────────────────────────────────────────────
478
479    #[test]
480    #[ignore = "requires assets/video/gameplay.mp4; run with -- --include-ignored"]
481    fn timeline_runner_run_should_deliver_frames_for_single_clip() {
482        use crate::playback::sink::FrameSink;
483
484        let path = test_video_path();
485        if !path.exists() {
486            println!("skipping: video asset not found");
487            return;
488        }
489
490        struct CountSink(usize, PlayerHandle);
491        impl FrameSink for CountSink {
492            fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
493                self.0 += 1;
494                if self.0 >= 20 {
495                    self.1.stop();
496                }
497            }
498        }
499
500        let timeline = ff_pipeline::Timeline::builder()
501            .canvas(1280, 720)
502            .frame_rate(30.0)
503            .video_track(vec![
504                ff_pipeline::Clip::new(&path).trim(Duration::ZERO, Duration::from_secs(2)),
505            ])
506            .build()
507            .expect("timeline build failed");
508
509        let (mut runner, handle) = match TimelinePlayer::open(&timeline) {
510            Ok(p) => p,
511            Err(e) => {
512                println!("skipping: open failed: {e}");
513                return;
514            }
515        };
516
517        runner.set_sink(Box::new(CountSink(0, handle.clone())));
518        let _ = runner.run();
519
520        let events: Vec<_> = std::iter::from_fn(|| handle.poll_event()).collect();
521        assert!(
522            events.iter().any(|e| matches!(e, PlayerEvent::Eof)),
523            "Eof event must be delivered after run() completes"
524        );
525        assert!(
526            events
527                .iter()
528                .any(|e| matches!(e, PlayerEvent::PositionUpdate(_))),
529            "PositionUpdate events must be emitted during playback"
530        );
531    }
532
533    /// Regression test for the MasterClock::System pause-drift bug.
534    ///
535    /// After pause → seek → sleep N seconds → play, the first PositionUpdate
536    /// must carry a PTS close to the seek target (≤ target + 2 frame periods),
537    /// not target + N.
538    #[test]
539    #[ignore = "requires assets/video/gameplay.mp4; run with -- --include-ignored"]
540    fn timeline_runner_resume_after_seek_while_paused_should_not_drift() {
541        let path = test_video_path();
542        if !path.exists() {
543            println!("skipping: video asset not found");
544            return;
545        }
546
547        let fps = 30.0_f64;
548        let seek_target = Duration::from_secs(1);
549        let two_frame_periods = Duration::from_secs_f64(2.0 / fps);
550
551        let timeline = ff_pipeline::Timeline::builder()
552            .canvas(1280, 720)
553            .frame_rate(fps)
554            .video_track(vec![
555                ff_pipeline::Clip::new(&path).trim(Duration::ZERO, Duration::from_secs(5)),
556            ])
557            .build()
558            .expect("timeline build failed");
559
560        let (runner, handle) = match TimelinePlayer::open(&timeline) {
561            Ok(p) => p,
562            Err(e) => {
563                println!("skipping: open failed: {e}");
564                return;
565            }
566        };
567
568        let handle_bg = handle.clone();
569        let bg = thread::spawn(move || {
570            let _ = runner.run();
571        });
572
573        // Let the runner start, then pause, seek, wait 500 ms, play.
574        thread::sleep(Duration::from_millis(50));
575        handle.pause();
576        thread::sleep(Duration::from_millis(20));
577        handle.seek(seek_target);
578        thread::sleep(Duration::from_millis(500));
579        handle.play();
580
581        // Collect the first PositionUpdate after play.
582        let deadline = std::time::Instant::now() + Duration::from_secs(5);
583        let first_pts = loop {
584            if let Some(PlayerEvent::PositionUpdate(pts)) = handle.poll_event() {
585                break Some(pts);
586            }
587            if std::time::Instant::now() > deadline {
588                break None;
589            }
590            thread::sleep(Duration::from_millis(5));
591        };
592
593        handle_bg.stop();
594        let _ = bg.join();
595
596        let pts = first_pts.expect("no PositionUpdate received within 5 seconds");
597        assert!(
598            pts <= seek_target + two_frame_periods,
599            "first frame after seek-while-paused should be near seek target; \
600             got {pts:?}, expected ≤ {:?}",
601            seek_target + two_frame_periods,
602        );
603    }
604
605    #[test]
606    #[ignore = "requires assets/video/gameplay.mp4; run with -- --include-ignored"]
607    fn timeline_runner_seek_should_deliver_seek_completed_event() {
608        let path = test_video_path();
609        if !path.exists() {
610            println!("skipping: video asset not found");
611            return;
612        }
613
614        let timeline = ff_pipeline::Timeline::builder()
615            .canvas(1280, 720)
616            .frame_rate(30.0)
617            .video_track(vec![
618                ff_pipeline::Clip::new(&path).trim(Duration::ZERO, Duration::from_secs(10)),
619            ])
620            .build()
621            .expect("timeline build failed");
622
623        let (runner, handle) = match TimelinePlayer::open(&timeline) {
624            Ok(p) => p,
625            Err(e) => {
626                println!("skipping: open failed: {e}");
627                return;
628            }
629        };
630
631        let handle_bg = handle.clone();
632        let bg = thread::spawn(move || {
633            let _ = runner.run();
634        });
635
636        thread::sleep(Duration::from_millis(50));
637        handle.seek(Duration::from_secs(1));
638
639        let deadline = std::time::Instant::now() + Duration::from_secs(3);
640        let found = loop {
641            if let Some(e) = handle.poll_event() {
642                if matches!(e, PlayerEvent::SeekCompleted(_)) {
643                    break true;
644                }
645            }
646            if std::time::Instant::now() > deadline {
647                break false;
648            }
649            thread::sleep(Duration::from_millis(10));
650        };
651
652        handle_bg.stop();
653        let _ = bg.join();
654
655        assert!(
656            found,
657            "SeekCompleted must be delivered within 3 seconds of seek"
658        );
659    }
660}