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::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
50const CHANNEL_CAP: usize = 64;
53
54pub struct TimelinePlayer;
84
85impl TimelinePlayer {
86 #[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_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 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 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 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 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 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 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 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 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 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 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 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(¤t_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#[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 #[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 #[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 #[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 #[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 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 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}