1mod audio_resampling;
25mod compositor;
26mod inner;
27mod runner;
28mod runner_layout;
29mod state;
30mod types;
31
32use std::path::PathBuf;
33use std::sync::atomic::{AtomicBool, AtomicU64};
34use std::sync::{Arc, Mutex, mpsc};
35use std::time::{Duration, Instant};
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 compositor::PreviewCompositor;
46pub use inner::apply_xfade;
47pub use runner::{Pacing, SceneRunner};
48pub use types::{
49 Scene, SceneAudioPlacement, SceneAudioTrack, ScenePlacement, SceneSource, SceneVideoTrack,
50};
51
52use audio_resampling::spawn_audio_track_thread;
53use ff_filter::{AnimatedValue, SolidSource, TextSource, XfadeTransition};
54use ff_format::VideoFrame;
55use state::{
56 AudioFadeConfig, AudioOnlyTrack, ClipState, ClipVideoSource, LavfiOverlayState, OverlayLayer,
57 db_to_linear,
58};
59
60fn resolve_canvas_dims(scene: &Scene) -> (u32, u32) {
64 if let Some(dims) = scene.canvas {
65 return dims;
66 }
67 for track in &scene.video_tracks {
68 for p in &track.placements {
69 if let Some(path) = p.source.as_file()
70 && let Ok(info) = ff_probe::open(path)
71 && let Some(v) = info.primary_video()
72 {
73 return (v.width(), v.height());
74 }
75 }
76 }
77 (0, 0)
78}
79
80fn generated_held_frame(source: &SceneSource, cw: u32, ch: u32, fps: f64) -> Option<VideoFrame> {
85 if cw == 0 || ch == 0 {
86 log::warn!("generated source has no canvas size to render into, cw={cw} ch={ch}");
87 return None;
88 }
89 let pulled = match source {
90 SceneSource::Solid(color) => {
91 SolidSource::new(*color, cw, ch, fps).map(|mut s| pull_first(&mut s))
92 }
93 SceneSource::Text(spec) => {
94 TextSource::new(spec, cw, ch, fps).map(|mut s| pull_first(&mut s))
95 }
96 SceneSource::File(_) => return None,
97 };
98 match pulled {
99 Ok(Some(frame)) => Some(frame),
100 Ok(None) => {
101 log::warn!("generated source produced no frame; rendering nothing");
102 None
103 }
104 Err(e) => {
105 log::warn!("generated source unavailable, rendering nothing, error={e}");
106 None
107 }
108 }
109}
110
111fn generated_span(out_point: Option<Duration>, in_pt: Duration) -> Duration {
115 if let Some(op) = out_point {
116 op.saturating_sub(in_pt)
117 } else {
118 log::warn!(
119 "generated clip has no out_point; preview shows zero duration (bound it with a trim)"
120 );
121 Duration::ZERO
122 }
123}
124
125fn open_clip_video_source(
129 source: &SceneSource,
130 in_pt: Duration,
131 cw: u32,
132 ch: u32,
133 fps: f64,
134) -> Result<ClipVideoSource, PreviewError> {
135 match source.as_file() {
136 Some(path) => {
137 let mut buf = DecodeBuffer::open(path).build()?;
138 if in_pt > Duration::ZERO {
139 buf.seek(in_pt)?;
140 }
141 Ok(ClipVideoSource::File(buf))
142 }
143 None => Ok(ClipVideoSource::held(
144 generated_held_frame(source, cw, ch, fps),
145 in_pt,
146 fps,
147 )),
148 }
149}
150
151fn source_path(source: &SceneSource) -> PathBuf {
154 source
155 .as_file()
156 .map(std::path::Path::to_path_buf)
157 .unwrap_or_default()
158}
159
160fn pull_first<S: GeneratedPull>(source: &mut S) -> Option<VideoFrame> {
164 for _ in 0..16 {
165 match source.pull() {
166 Ok(Some(frame)) => return Some(frame),
167 Ok(None) => {}
168 Err(_) => return None,
169 }
170 }
171 None
172}
173
174trait GeneratedPull {
177 fn pull(&mut self) -> Result<Option<VideoFrame>, ff_filter::FilterError>;
178}
179impl GeneratedPull for SolidSource {
180 fn pull(&mut self) -> Result<Option<VideoFrame>, ff_filter::FilterError> {
181 SolidSource::pull(self)
182 }
183}
184impl GeneratedPull for TextSource {
185 fn pull(&mut self) -> Result<Option<VideoFrame>, ff_filter::FilterError> {
186 TextSource::pull(self)
187 }
188}
189
190const CHANNEL_CAP: usize = 64;
193
194pub struct ScenePlayer;
206
207impl ScenePlayer {
208 #[allow(clippy::too_many_lines)]
222 pub fn open(scene: &Scene) -> Result<(SceneRunner, PlayerHandle), PreviewError> {
223 struct ProbeResult {
224 source: SceneSource,
225 in_pt: Duration,
226 clip_dur: Duration,
227 offset: Duration,
228 out_point: Option<Duration>,
229 xfade_dur: Duration,
230 xfade_kind: Option<XfadeTransition>,
231 video_handle: Duration,
232 has_audio: bool,
233 video_w: u32,
236 video_h: u32,
237 speed: f64,
238 opacity: f32,
239 }
240
241 let v_tracks = &scene.video_tracks;
242 if v_tracks.is_empty() || v_tracks[0].placements.is_empty() {
243 return Err(PreviewError::Ffmpeg {
244 code: 0,
245 message: "timeline has no video clips in the primary track".into(),
246 });
247 }
248
249 let fps = scene.fps.max(1.0);
250 let canvas = resolve_canvas_dims(scene);
252 let clip_list = &v_tracks[0].placements;
253
254 let mut probes: Vec<ProbeResult> = Vec::with_capacity(clip_list.len());
257 let mut has_any_audio = false;
258
259 for p in clip_list {
260 let in_pt = p.in_point;
261 let speed = p.speed;
262
263 let (video_w, video_h, unscaled_dur, has_audio) = if let Some(path) = p.source.as_file()
266 {
267 let info = ff_probe::open(path)?;
268 let dur = p.out_point.map_or_else(
269 || info.duration().saturating_sub(in_pt),
270 |op| op.saturating_sub(in_pt),
271 );
272 let (w, h) = info
273 .primary_video()
274 .map_or((0, 0), |v| (v.width(), v.height()));
275 (w, h, dur, info.has_audio())
276 } else {
277 (
278 canvas.0,
279 canvas.1,
280 generated_span(p.out_point, in_pt),
281 false,
282 )
283 };
284 let clip_dur = if (speed - 1.0).abs() < 1e-9 {
285 unscaled_dur
286 } else {
287 unscaled_dur.div_f64(speed)
288 };
289
290 has_any_audio |= has_audio;
291
292 probes.push(ProbeResult {
293 source: p.source.clone(),
294 in_pt,
295 clip_dur,
296 offset: p.offset,
297 out_point: p.out_point,
298 xfade_dur: p.xfade_dur,
299 xfade_kind: p.xfade_kind,
300 video_handle: p.video_handle,
301 has_audio,
302 video_w,
303 video_h,
304 speed,
305 opacity: p.opacity,
306 });
307 }
308
309 let (mut mixer_arc, audio_track_handles): (
312 Option<Arc<Mutex<AudioMixer>>>,
313 Vec<Option<AudioTrackHandle>>,
314 ) = if has_any_audio {
315 let mut mixer = AudioMixer::new(48_000);
316 let handles: Vec<Option<AudioTrackHandle>> = probes
317 .iter()
318 .map(|p| {
319 if p.has_audio {
320 Some(mixer.add_track())
321 } else {
322 None
323 }
324 })
325 .collect();
326 (Some(Arc::new(Mutex::new(mixer))), handles)
327 } else {
328 (None, probes.iter().map(|_| None).collect())
329 };
330
331 let mut clip_states: Vec<ClipState> = Vec::with_capacity(probes.len());
334 for (i, p) in probes.iter().enumerate() {
335 let timeline_start = p.offset;
336 let timeline_end = timeline_start + p.clip_dur;
337
338 let decode_buf = open_clip_video_source(&p.source, p.in_pt, p.video_w, p.video_h, fps)?;
339
340 if let (Some(handle), AnimatedValue::Static(db)) =
343 (&audio_track_handles[i], &clip_list[i].volume)
344 && *db != 0.0
345 {
346 handle.set_volume(db_to_linear(*db));
347 }
348 if let Some(handle) = &audio_track_handles[i] {
351 let pan0 = clip_list[i].pan.value_at(Duration::ZERO);
352 if pan0 != 0.0 {
353 #[allow(clippy::cast_possible_truncation)]
355 handle.set_pan(pan0 as f32);
356 }
357 }
358 clip_states.push(ClipState {
359 source: p.source.clone(),
360 decode_buf,
361 timeline_start,
362 timeline_end,
363 in_point: p.in_pt,
364 out_point: p.out_point,
365 xfade_dur: p.xfade_dur,
366 xfade_kind: p.xfade_kind,
367 video_handle: p.video_handle,
368 audio_track: audio_track_handles[i].clone(),
369 speed: p.speed,
370 opacity: p.opacity,
371 layer_desc: clip_list[i].layer.clone(),
372 volume: clip_list[i].volume.clone(),
373 fade_in: clip_list[i].fade_in,
374 fade_out: clip_list[i].fade_out,
375 pitch: clip_list[i].pitch,
376 });
377 }
378
379 let mut audio_only_tracks: Vec<AudioOnlyTrack> = Vec::new();
384
385 let mut overlay_layers: Vec<OverlayLayer> = Vec::new();
386 for layer in v_tracks.iter().skip(1) {
387 if layer.placements.is_empty() {
388 continue;
389 }
390 let mut layer_clips: Vec<ClipState> = Vec::new();
391 for p in &layer.placements {
392 let in_pt = p.in_point;
393 let (clip_dur, has_audio) = match p.source.as_file() {
396 Some(path) => {
397 let info = ff_probe::open(path)?;
398 let dur = p.out_point.map_or_else(
399 || info.duration().saturating_sub(in_pt),
400 |op| op.saturating_sub(in_pt),
401 );
402 (dur, info.has_audio())
403 }
404 None => (generated_span(p.out_point, in_pt), false),
405 };
406 let timeline_start = p.offset;
407 let timeline_end = timeline_start + clip_dur;
408 let decode_buf = open_clip_video_source(&p.source, in_pt, canvas.0, canvas.1, fps)?;
409 if has_audio {
410 let mixer_ref = mixer_arc
411 .get_or_insert_with(|| Arc::new(Mutex::new(AudioMixer::new(48_000))));
412 let handle = mixer_ref
413 .lock()
414 .unwrap_or_else(std::sync::PoisonError::into_inner)
415 .add_track();
416 if let AnimatedValue::Static(db) = &p.volume
417 && *db != 0.0
418 {
419 handle.set_volume(db_to_linear(*db));
420 }
421 let pan0 = p.pan.value_at(Duration::ZERO);
424 if pan0 != 0.0 {
425 #[allow(clippy::cast_possible_truncation)]
427 handle.set_pan(pan0 as f32);
428 }
429 audio_only_tracks.push(AudioOnlyTrack {
430 source: source_path(&p.source),
431 timeline_start,
432 timeline_end,
433 in_point: in_pt,
434 fade_in: p.fade_in,
435 fade_out: p.fade_out,
436 clip_dur,
437 speed: p.speed,
438 pitch: p.pitch,
439 handle,
440 volume: p.volume.clone(),
441 cancel: None,
442 thread: None,
443 });
444 }
445 layer_clips.push(ClipState {
446 source: p.source.clone(),
447 decode_buf,
448 timeline_start,
449 timeline_end,
450 in_point: in_pt,
451 out_point: p.out_point,
452 xfade_dur: Duration::ZERO,
453 xfade_kind: None,
454 video_handle: Duration::ZERO,
456 audio_track: None,
457 speed: p.speed,
458 opacity: p.opacity,
459 layer_desc: p.layer.clone(),
460 volume: p.volume.clone(),
461 fade_in: p.fade_in,
462 fade_out: p.fade_out,
463 pitch: p.pitch,
464 });
465 }
466 overlay_layers.push(OverlayLayer {
467 clips: layer_clips,
468 active: 0,
469 sws: SwsRgbaConverter::new(),
470 rgba: Vec::new(),
471 cur_dims: None,
472 pending: None,
473 });
474 }
475
476 for track in &scene.audio_tracks {
479 for p in &track.placements {
480 let in_pt = p.in_point;
481 let info = ff_probe::open(&p.source)?;
482 if !info.has_audio() {
483 continue;
484 }
485 let clip_dur = p.out_point.map_or_else(
486 || info.duration().saturating_sub(in_pt),
487 |op| op.saturating_sub(in_pt),
488 );
489 let timeline_start = p.offset;
490 let timeline_end = timeline_start + clip_dur;
491 let mixer_ref =
493 mixer_arc.get_or_insert_with(|| Arc::new(Mutex::new(AudioMixer::new(48_000))));
494 let handle = mixer_ref
495 .lock()
496 .unwrap_or_else(std::sync::PoisonError::into_inner)
497 .add_track();
498 if let AnimatedValue::Static(db) = &p.volume
501 && *db != 0.0
502 {
503 handle.set_volume(db_to_linear(*db));
504 }
505 let pan0 = p.pan.value_at(Duration::ZERO);
508 if pan0 != 0.0 {
509 #[allow(clippy::cast_possible_truncation)]
511 handle.set_pan(pan0 as f32);
512 }
513 audio_only_tracks.push(AudioOnlyTrack {
514 source: p.source.clone(),
515 timeline_start,
516 timeline_end,
517 in_point: in_pt,
518 fade_in: p.fade_in,
519 fade_out: p.fade_out,
520 clip_dur,
521 speed: p.speed,
522 pitch: p.pitch,
523 handle,
524 volume: p.volume.clone(),
525 cancel: None,
526 thread: None,
527 });
528 }
529 }
530
531 let total_dur = clip_states
534 .iter()
535 .map(|c| c.timeline_end)
536 .max()
537 .unwrap_or(Duration::ZERO);
538 let duration_millis = u64::try_from(total_dur.as_millis()).unwrap_or(u64::MAX);
539
540 let current_pts = Arc::new(AtomicU64::new(0));
543 let paused = Arc::new(AtomicBool::new(false));
544 let stopped = Arc::new(AtomicBool::new(false));
545 let (cmd_tx, cmd_rx) = mpsc::sync_channel(CHANNEL_CAP);
546 let (event_tx, event_rx) = mpsc::sync_channel::<PlayerEvent>(CHANNEL_CAP);
547
548 let first_clip_at_origin = clip_states
552 .first()
553 .is_some_and(|c| c.timeline_start == Duration::ZERO);
554 let (initial_audio_cancel, initial_audio_thread) = if first_clip_at_origin {
555 if let Some(handle) = clip_states.first().and_then(|c| c.audio_track.clone()) {
556 let source = source_path(&clip_states[0].source);
558 let in_pt = clip_states[0].in_point;
559 let clip0_speed = clip_states[0].speed;
560 let clip0_pitch = clip_states[0].pitch;
561 let cancel = Arc::new(AtomicBool::new(false));
562 let thread = spawn_audio_track_thread(
563 source,
564 in_pt,
565 handle,
566 Arc::clone(&cancel),
567 AudioFadeConfig {
568 speed: clip0_speed,
569 pitch: clip0_pitch,
570 ..AudioFadeConfig::NONE
571 },
572 );
573 (Some(cancel), Some(thread))
574 } else {
575 (None, None)
576 }
577 } else {
578 (None, None)
579 };
580
581 let (initial_last_w, initial_last_h) =
584 probes.first().map_or((0, 0), |p| (p.video_w, p.video_h));
585
586 let runner = SceneRunner {
587 clips: clip_states,
588 overlay_layers,
589 audio_only_tracks,
590 active: 0,
591 transition: None,
592 cmd_rx,
593 event_tx,
594 sink: None,
595 gpu_compositor: None,
596 current_pts: Arc::clone(¤t_pts),
597 paused: Arc::clone(&paused),
598 stopped: Arc::clone(&stopped),
599 fps,
600 rate: 1.0,
601 clock: MasterClock::System {
602 started_at: Instant::now(),
603 base_pts: Duration::ZERO,
604 rate: 1.0,
605 },
606 resume_pts: Duration::ZERO,
607 sws_a: SwsRgbaConverter::new(),
608 sws_b: SwsRgbaConverter::new(),
609 rgba_a: Vec::new(),
610 rgba_b: Vec::new(),
611 blend_buf: Vec::new(),
612 dissolve_field: Vec::new(),
613 dissolve_field_dims: (0, 0),
614 last_frame_w: initial_last_w,
615 last_frame_h: initial_last_h,
616 gap_buf: Vec::new(),
617 audio_mixer: mixer_arc.clone(),
618 active_audio_cancel: initial_audio_cancel,
619 active_audio_thread: initial_audio_thread,
620 composer: None,
621 composer_key: Vec::new(),
622 canvas: scene.canvas,
623 lavfi: scene
624 .lavfi_overlay
625 .as_deref()
626 .and_then(LavfiOverlayState::new),
627 };
628
629 let handle = PlayerHandle::for_timeline(
630 cmd_tx,
631 Arc::new(Mutex::new(event_rx)),
632 current_pts,
633 paused,
634 stopped,
635 duration_millis,
636 mixer_arc,
637 );
638
639 Ok((runner, handle))
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 #[test]
648 fn resolve_canvas_dims_should_prefer_explicit_canvas_then_fall_back() {
649 let scene = |canvas| Scene {
650 fps: 30.0,
651 canvas,
652 lavfi_overlay: None,
653 video_tracks: vec![],
654 audio_tracks: vec![],
655 };
656 assert_eq!(
658 resolve_canvas_dims(&scene(Some((1920, 1080)))),
659 (1920, 1080)
660 );
661 assert_eq!(resolve_canvas_dims(&scene(None)), (0, 0));
664 }
665
666 #[test]
667 #[ignore = "requires the color/drawtext filters; run with -- --include-ignored"]
668 fn preview_should_render_text_and_solid_sources() {
669 use ff_format::{Color, TextSpec};
670
671 let red = Color::rgb(200, 30, 40);
676 let Some(frame) = generated_held_frame(&SceneSource::Solid(red), 16, 16, 30.0) else {
677 println!("Skipping: color filter unavailable");
678 return;
679 };
680 assert_eq!((frame.width(), frame.height()), (16, 16));
681 let Some(plane) = frame.plane(0) else {
682 println!("Skipping: no rgba plane");
683 return;
684 };
685 let stride = frame.stride(0).unwrap_or(16 * 4);
686 let off = 8 * stride + 8 * 4;
689 let (r, g, b) = (plane[off], plane[off + 1], plane[off + 2]);
690 assert!(
691 r.abs_diff(200) <= 6 && g.abs_diff(30) <= 6 && b.abs_diff(40) <= 6,
692 "solid centre pixel must be ~red, got ({r}, {g}, {b})"
693 );
694
695 if let Some(tf) =
698 generated_held_frame(&SceneSource::Text(TextSpec::new("Hi")), 64, 32, 30.0)
699 {
700 assert_eq!((tf.width(), tf.height()), (64, 32));
701 } else {
702 println!("Skipping text: drawtext filter unavailable");
703 }
704 }
705
706 #[test]
709 fn inner_blend_rgba_at_zero_alpha_should_return_a() {
710 let a = vec![255u8, 0, 0, 255];
711 let b = vec![0u8, 0, 255, 255];
712 let mut dst = Vec::new();
713 inner::blend_rgba(&a, &b, 0.0, &mut dst);
714 assert_eq!(dst, a);
715 }
716
717 #[test]
720 fn timeline_player_open_should_fail_when_no_video_tracks() {
721 let _ = PreviewError::SeekOutOfRange {
722 pts: Duration::from_secs(1),
723 };
724 }
725}