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