1mod 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
49const CHANNEL_CAP: usize = 64;
52
53pub struct TimelinePlayer;
83
84impl TimelinePlayer {
85 #[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_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 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 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 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 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 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 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 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 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 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 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 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(¤t_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#[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 #[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 #[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 #[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 #[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 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 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}