Skip to main content

ff_preview/scene/
mod.rs

1//! Real-time playback of a [`Scene`].
2//!
3//! [`ScenePlayer`] opens every placement on the base video track of a [`Scene`]
4//! and plays them back in order, mapping each clip's frame PTS to the unified
5//! timeline coordinate. The `Scene` is a model-agnostic description an engine
6//! derives from its editing model.
7//!
8//! | Type | Role |
9//! |------|------|
10//! | [`ScenePlayer`] | Thin builder: call [`open`](ScenePlayer::open) |
11//! | [`SceneRunner`] | Owns the decode pipelines; move to a thread and call [`run`](SceneRunner::run) |
12//! | [`PlayerHandle`] | Shared, cloneable control handle |
13//!
14//! ## Audio
15//!
16//! When any placement on the base video track carries an audio stream,
17//! [`ScenePlayer::open`] creates an [`AudioMixer`] with one track per
18//! audio-bearing clip.  A background [`AudioDecoder`](ff_decode::AudioDecoder) thread is started for
19//! the active clip and pushes mono samples via [`AudioTrackHandle`].  On clip
20//! transition or seek the old thread is cancelled and a new one is started.
21//! [`PlayerHandle::pop_audio_samples`] calls [`AudioMixer::mix`] and returns
22//! interleaved stereo `f32` output.
23
24mod audio_resampling;
25mod inner;
26mod runner;
27mod runner_layout;
28mod state;
29mod types;
30
31use std::path::PathBuf;
32use std::sync::atomic::{AtomicBool, AtomicU64};
33use std::sync::{Arc, Mutex, mpsc};
34use std::time::{Duration, Instant};
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::SceneRunner;
45pub use types::{Scene, SceneAudioPlacement, SceneAudioTrack, ScenePlacement, SceneVideoTrack};
46
47use audio_resampling::spawn_audio_track_thread;
48use ff_filter::{AnimatedValue, XfadeTransition};
49use state::{
50    AudioFadeConfig, AudioOnlyTrack, ClipState, LavfiOverlayState, OverlayLayer, db_to_linear,
51};
52
53// -- Constants --
54
55const CHANNEL_CAP: usize = 64;
56
57// ── ScenePlayer ─────────────────────────────────────────────────────────────
58
59/// Thin builder for a ([`SceneRunner`], [`PlayerHandle`]) pair backed by a
60/// [`Scene`].
61///
62/// Playback is limited to the base video track (`video_tracks[0]`). When any
63/// placement carries an audio stream, an [`AudioMixer`] is created and audio is
64/// mixed into the stereo output from [`PlayerHandle::pop_audio_samples`].
65///
66/// This player is model-agnostic: an engine derives the [`Scene`] from its
67/// editing model and hands it here.
68pub struct ScenePlayer;
69
70impl ScenePlayer {
71    /// Open a [`Scene`] for real-time preview playback.
72    ///
73    /// Resolves the scene against the media (probing each placement's source for
74    /// duration, audio availability, and frame size), opens a [`DecodeBuffer`]
75    /// per base-track clip and seeks it to `in_point`, and builds the audio mixer
76    /// and tracks.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`PreviewError`] when:
81    /// - the scene has no video tracks or the base track is empty,
82    /// - a placement source file cannot be found or opened,
83    /// - a placement cannot be probed for duration.
84    #[allow(clippy::too_many_lines)]
85    pub fn open(scene: &Scene) -> Result<(SceneRunner, PlayerHandle), PreviewError> {
86        struct ProbeResult {
87            source: PathBuf,
88            in_pt: Duration,
89            clip_dur: Duration,
90            offset: Duration,
91            out_point: Option<Duration>,
92            xfade_dur: Duration,
93            xfade_kind: Option<XfadeTransition>,
94            has_audio: bool,
95            /// Video frame dimensions — used to pre-populate `last_frame_w/h` so the
96            /// gap-fill loop can synthesise black frames before the first real frame.
97            video_w: u32,
98            video_h: u32,
99            speed: f64,
100            opacity: f32,
101        }
102
103        let v_tracks = &scene.video_tracks;
104        if v_tracks.is_empty() || v_tracks[0].placements.is_empty() {
105            return Err(PreviewError::Ffmpeg {
106                code: 0,
107                message: "timeline has no video clips in the primary track".into(),
108            });
109        }
110
111        let fps = scene.fps.max(1.0);
112        let clip_list = &v_tracks[0].placements;
113
114        // ── Phase 1: probe all clips ──────────────────────────────────────────
115
116        let mut probes: Vec<ProbeResult> = Vec::with_capacity(clip_list.len());
117        let mut has_any_audio = false;
118
119        for p in clip_list {
120            let in_pt = p.in_point;
121            let info = ff_probe::open(&p.source)?;
122            let speed = p.speed;
123
124            // `in_point` is pre-resolved (defaulted to zero) in the Scene, so this
125            // equals the old `match (in_point, out_point)` for all four cases.
126            let unscaled_dur = p.out_point.map_or_else(
127                || info.duration().saturating_sub(in_pt),
128                |op| op.saturating_sub(in_pt),
129            );
130            let clip_dur = if (speed - 1.0).abs() < 1e-9 {
131                unscaled_dur
132            } else {
133                unscaled_dur.div_f64(speed)
134            };
135
136            let has_audio = info.has_audio();
137            has_any_audio |= has_audio;
138
139            let (video_w, video_h) = info
140                .primary_video()
141                .map_or((0, 0), |v| (v.width(), v.height()));
142
143            probes.push(ProbeResult {
144                source: p.source.clone(),
145                in_pt,
146                clip_dur,
147                offset: p.offset,
148                out_point: p.out_point,
149                xfade_dur: p.xfade_dur,
150                xfade_kind: p.xfade_kind,
151                has_audio,
152                video_w,
153                video_h,
154                speed,
155                opacity: p.opacity,
156            });
157        }
158
159        // ── Phase 2: build mixer and track handles (if audio present) ─────────
160
161        let (mut mixer_arc, audio_track_handles): (
162            Option<Arc<Mutex<AudioMixer>>>,
163            Vec<Option<AudioTrackHandle>>,
164        ) = if has_any_audio {
165            let mut mixer = AudioMixer::new(48_000);
166            let handles: Vec<Option<AudioTrackHandle>> = probes
167                .iter()
168                .map(|p| {
169                    if p.has_audio {
170                        Some(mixer.add_track())
171                    } else {
172                        None
173                    }
174                })
175                .collect();
176            (Some(Arc::new(Mutex::new(mixer))), handles)
177        } else {
178            (None, probes.iter().map(|_| None).collect())
179        };
180
181        // ── Phase 3: build ClipState objects ──────────────────────────────────
182
183        let mut clip_states: Vec<ClipState> = Vec::with_capacity(probes.len());
184        for (i, p) in probes.iter().enumerate() {
185            let timeline_start = p.offset;
186            let timeline_end = timeline_start + p.clip_dur;
187
188            let mut decode_buf = DecodeBuffer::open(&p.source).build()?;
189            if p.in_pt > Duration::ZERO {
190                decode_buf.seek(p.in_pt)?;
191            }
192
193            // Apply a static V1 audio gain once at open; an animated gain is driven
194            // per-tick by the runner.
195            if let (Some(handle), AnimatedValue::Static(db)) =
196                (&audio_track_handles[i], &clip_list[i].volume)
197                && *db != 0.0
198            {
199                handle.set_volume(db_to_linear(*db));
200            }
201            clip_states.push(ClipState {
202                source: p.source.clone(),
203                decode_buf,
204                timeline_start,
205                timeline_end,
206                in_point: p.in_pt,
207                out_point: p.out_point,
208                xfade_dur: p.xfade_dur,
209                xfade_kind: p.xfade_kind,
210                audio_track: audio_track_handles[i].clone(),
211                speed: p.speed,
212                opacity: p.opacity,
213                layer_desc: clip_list[i].layer.clone(),
214                volume: clip_list[i].volume.clone(),
215                fade_in: clip_list[i].fade_in,
216                fade_out: clip_list[i].fade_out,
217            });
218        }
219
220        // ── Phase 4: build overlay layers (V2, V3, …) ────────────────────────
221        // Audio from V2+ clips is routed through AudioOnlyTrack (same mechanism as
222        // A1) so it is started/stopped as the playhead crosses each clip window.
223
224        let mut audio_only_tracks: Vec<AudioOnlyTrack> = Vec::new();
225
226        let mut overlay_layers: Vec<OverlayLayer> = Vec::new();
227        for layer in v_tracks.iter().skip(1) {
228            if layer.placements.is_empty() {
229                continue;
230            }
231            let mut layer_clips: Vec<ClipState> = Vec::new();
232            for p in &layer.placements {
233                let in_pt = p.in_point;
234                let info = ff_probe::open(&p.source)?;
235                let clip_dur = p.out_point.map_or_else(
236                    || info.duration().saturating_sub(in_pt),
237                    |op| op.saturating_sub(in_pt),
238                );
239                let timeline_start = p.offset;
240                let timeline_end = timeline_start + clip_dur;
241                let mut decode_buf = DecodeBuffer::open(&p.source).build()?;
242                if in_pt > Duration::ZERO {
243                    decode_buf.seek(in_pt)?;
244                }
245                if info.has_audio() {
246                    let mixer_ref = mixer_arc
247                        .get_or_insert_with(|| Arc::new(Mutex::new(AudioMixer::new(48_000))));
248                    let handle = mixer_ref
249                        .lock()
250                        .unwrap_or_else(std::sync::PoisonError::into_inner)
251                        .add_track();
252                    if let AnimatedValue::Static(db) = &p.volume
253                        && *db != 0.0
254                    {
255                        handle.set_volume(db_to_linear(*db));
256                    }
257                    audio_only_tracks.push(AudioOnlyTrack {
258                        source: p.source.clone(),
259                        timeline_start,
260                        timeline_end,
261                        in_point: in_pt,
262                        fade_in: p.fade_in,
263                        fade_out: p.fade_out,
264                        clip_dur,
265                        speed: p.speed,
266                        handle,
267                        volume: p.volume.clone(),
268                        cancel: None,
269                        thread: None,
270                    });
271                }
272                layer_clips.push(ClipState {
273                    source: p.source.clone(),
274                    decode_buf,
275                    timeline_start,
276                    timeline_end,
277                    in_point: in_pt,
278                    out_point: p.out_point,
279                    xfade_dur: Duration::ZERO,
280                    xfade_kind: None,
281                    audio_track: None,
282                    speed: p.speed,
283                    opacity: p.opacity,
284                    layer_desc: p.layer.clone(),
285                    volume: p.volume.clone(),
286                    fade_in: p.fade_in,
287                    fade_out: p.fade_out,
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 track in &scene.audio_tracks {
303            for p in &track.placements {
304                let in_pt = p.in_point;
305                let info = ff_probe::open(&p.source)?;
306                if !info.has_audio() {
307                    continue;
308                }
309                let clip_dur = p.out_point.map_or_else(
310                    || info.duration().saturating_sub(in_pt),
311                    |op| op.saturating_sub(in_pt),
312                );
313                let timeline_start = p.offset;
314                let timeline_end = timeline_start + clip_dur;
315                // Lazily create the mixer if no V1 clip had audio.
316                let mixer_ref =
317                    mixer_arc.get_or_insert_with(|| Arc::new(Mutex::new(AudioMixer::new(48_000))));
318                let handle = mixer_ref
319                    .lock()
320                    .unwrap_or_else(std::sync::PoisonError::into_inner)
321                    .add_track();
322                // Apply a static gain once at open; an animated gain (a track) is driven
323                // per-tick by the runner.
324                if let AnimatedValue::Static(db) = &p.volume
325                    && *db != 0.0
326                {
327                    handle.set_volume(db_to_linear(*db));
328                }
329                audio_only_tracks.push(AudioOnlyTrack {
330                    source: p.source.clone(),
331                    timeline_start,
332                    timeline_end,
333                    in_point: in_pt,
334                    fade_in: p.fade_in,
335                    fade_out: p.fade_out,
336                    clip_dur,
337                    speed: p.speed,
338                    handle,
339                    volume: p.volume.clone(),
340                    cancel: None,
341                    thread: None,
342                });
343            }
344        }
345
346        // ── Compute total duration ─────────────────────────────────────────────
347
348        let total_dur = clip_states
349            .iter()
350            .map(|c| c.timeline_end)
351            .max()
352            .unwrap_or(Duration::ZERO);
353        let duration_millis = u64::try_from(total_dur.as_millis()).unwrap_or(u64::MAX);
354
355        // ── Build runner and handle ───────────────────────────────────────────
356
357        let current_pts = Arc::new(AtomicU64::new(0));
358        let paused = Arc::new(AtomicBool::new(false));
359        let stopped = Arc::new(AtomicBool::new(false));
360        let (cmd_tx, cmd_rx) = mpsc::sync_channel(CHANNEL_CAP);
361        let (event_tx, event_rx) = mpsc::sync_channel::<PlayerEvent>(CHANNEL_CAP);
362
363        // Only start the audio thread for the first V1 clip immediately when that
364        // clip begins at timeline position 0.  When there is a pre-roll gap the
365        // gap-fill loop starts the audio at the correct timeline position instead.
366        let first_clip_at_origin = clip_states
367            .first()
368            .is_some_and(|c| c.timeline_start == Duration::ZERO);
369        let (initial_audio_cancel, initial_audio_thread) = if first_clip_at_origin {
370            if let Some(handle) = clip_states.first().and_then(|c| c.audio_track.clone()) {
371                let source = clip_states[0].source.clone();
372                let in_pt = clip_states[0].in_point;
373                let clip0_speed = clip_states[0].speed;
374                let cancel = Arc::new(AtomicBool::new(false));
375                let thread = spawn_audio_track_thread(
376                    source,
377                    in_pt,
378                    handle,
379                    Arc::clone(&cancel),
380                    AudioFadeConfig {
381                        speed: clip0_speed,
382                        ..AudioFadeConfig::NONE
383                    },
384                );
385                (Some(cancel), Some(thread))
386            } else {
387                (None, None)
388            }
389        } else {
390            (None, None)
391        };
392
393        // Pre-populate frame dimensions from the first clip's probe so the gap-fill
394        // loop can synthesise black frames even before the first real frame arrives.
395        let (initial_last_w, initial_last_h) =
396            probes.first().map_or((0, 0), |p| (p.video_w, p.video_h));
397
398        let runner = SceneRunner {
399            clips: clip_states,
400            overlay_layers,
401            audio_only_tracks,
402            active: 0,
403            transition: None,
404            cmd_rx,
405            event_tx,
406            sink: None,
407            current_pts: Arc::clone(&current_pts),
408            paused: Arc::clone(&paused),
409            stopped: Arc::clone(&stopped),
410            fps,
411            rate: 1.0,
412            clock: MasterClock::System {
413                started_at: Instant::now(),
414                base_pts: Duration::ZERO,
415                rate: 1.0,
416            },
417            resume_pts: Duration::ZERO,
418            sws_a: SwsRgbaConverter::new(),
419            sws_b: SwsRgbaConverter::new(),
420            rgba_a: Vec::new(),
421            rgba_b: Vec::new(),
422            blend_buf: Vec::new(),
423            last_frame_w: initial_last_w,
424            last_frame_h: initial_last_h,
425            gap_buf: Vec::new(),
426            audio_mixer: mixer_arc.clone(),
427            active_audio_cancel: initial_audio_cancel,
428            active_audio_thread: initial_audio_thread,
429            composer: None,
430            composer_key: Vec::new(),
431            canvas: scene.canvas,
432            lavfi: scene
433                .lavfi_overlay
434                .as_deref()
435                .and_then(LavfiOverlayState::new),
436        };
437
438        let handle = PlayerHandle::for_timeline(
439            cmd_tx,
440            Arc::new(Mutex::new(event_rx)),
441            current_pts,
442            paused,
443            stopped,
444            duration_millis,
445            mixer_arc,
446        );
447
448        Ok((runner, handle))
449    }
450}
451
452// ── Tests ─────────────────────────────────────────────────────────────────────
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    // ── blend_rgba delegate ────────────────────────────────────────────────
459
460    #[test]
461    fn inner_blend_rgba_at_zero_alpha_should_return_a() {
462        let a = vec![255u8, 0, 0, 255];
463        let b = vec![0u8, 0, 255, 255];
464        let mut dst = Vec::new();
465        inner::blend_rgba(&a, &b, 0.0, &mut dst);
466        assert_eq!(dst, a);
467    }
468
469    // ── open ──────────────────────────────────────────────────────────────
470
471    #[test]
472    fn timeline_player_open_should_fail_when_no_video_tracks() {
473        let _ = PreviewError::SeekOutOfRange {
474            pts: Duration::from_secs(1),
475        };
476    }
477}