Skip to main content

ff_preview/scene/
runner.rs

1//! The timeline decode/present state machine.
2//!
3//! [`SceneRunner`] owns the per-track decode buffers and the audio mixer,
4//! and drives frame presentation. Construct it via
5//! [`ScenePlayer::open`](super::ScenePlayer::open).
6
7use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
8use std::sync::{Arc, Mutex, mpsc};
9use std::thread::{self, JoinHandle};
10use std::time::Duration;
11
12use ff_filter::{
13    AnimatedValue, BlendMode, CompositeOp, RealtimeComposer, RealtimeLayer, XfadeTransition,
14};
15use ff_format::{PixelFormat, Rational, Timestamp, VideoFrame};
16
17use crate::audio::AudioMixer;
18use crate::error::PreviewError;
19use crate::event::PlayerEvent;
20use crate::playback::SwsRgbaConverter;
21use crate::playback::decode_buffer::FrameResult;
22use crate::playback::master_clock::MasterClock;
23use crate::playback::player::PlayerCommand;
24use crate::playback::sink::FrameSink;
25
26use super::audio_resampling::spawn_audio_track_thread;
27use super::inner;
28use super::state::{
29    AudioFadeConfig, AudioOnlyTrack, ClipState, LavfiOverlayState, OverlayLayer, TransitionState,
30    db_to_linear,
31};
32
33// ── SceneRunner ────────────────────────────────────────────────────────────
34
35/// Exclusive owner of the timeline decode pipeline.
36///
37/// Move to a background thread and call [`run`](Self::run). Register a
38/// [`FrameSink`] with [`set_sink`](Self::set_sink) before calling `run`.
39pub struct SceneRunner {
40    pub(super) clips: Vec<ClipState>,
41    /// Secondary video overlay layers (V2, V3, …). Each is composited over V1
42    /// in order before the frame is delivered to the sink.
43    pub(super) overlay_layers: Vec<OverlayLayer>,
44    /// Dedicated audio-only clips (from A1, A2, … tracks). Each is started and
45    /// stopped as the playhead crosses its timeline window.
46    pub(super) audio_only_tracks: Vec<AudioOnlyTrack>,
47    /// Index of the clip currently being decoded and presented.
48    pub(super) active: usize,
49    /// Non-`None` while a crossfade transition is in progress.
50    pub(super) transition: Option<TransitionState>,
51    pub(super) cmd_rx: mpsc::Receiver<PlayerCommand>,
52    pub(super) event_tx: mpsc::SyncSender<PlayerEvent>,
53    pub(super) sink: Option<Box<dyn FrameSink>>,
54    pub(super) current_pts: Arc<AtomicU64>,
55    pub(super) paused: Arc<AtomicBool>,
56    pub(super) stopped: Arc<AtomicBool>,
57    pub(super) fps: f64,
58    pub(super) rate: f64,
59    pub(super) clock: MasterClock,
60    /// Media PTS to re-anchor the System clock to when `PlayerCommand::Play`
61    /// is received from a paused state. Updated on every seek and after every
62    /// presented frame so that accumulated wall-clock time during pause does
63    /// not advance `current_pts()` past the last known media position.
64    pub(super) resume_pts: Duration,
65    /// Pixel-format converter for the active (outgoing) frame.
66    pub(super) sws_a: SwsRgbaConverter,
67    /// Pixel-format converter for the incoming frame during transitions.
68    pub(super) sws_b: SwsRgbaConverter,
69    pub(super) rgba_a: Vec<u8>,
70    pub(super) rgba_b: Vec<u8>,
71    pub(super) blend_buf: Vec<u8>,
72    /// Width of the most recently presented primary-track frame; used to
73    /// synthesise fill frames during primary-track gaps.
74    pub(super) last_frame_w: u32,
75    /// Height of the most recently presented primary-track frame.
76    pub(super) last_frame_h: u32,
77    /// Scratch buffer for synthesising black fill frames during primary-track gaps.
78    pub(super) gap_buf: Vec<u8>,
79    /// Multi-track audio mixer — `None` when no clip has audio.
80    pub(super) audio_mixer: Option<Arc<Mutex<AudioMixer>>>,
81    /// Cancel flag for the currently running audio decode thread.
82    pub(super) active_audio_cancel: Option<Arc<AtomicBool>>,
83    /// Handle to the currently running audio decode thread.
84    pub(super) active_audio_thread: Option<JoinHandle<()>>,
85    /// Cached real-time compositor that applies per-clip effects + blend modes
86    /// (the same chain as export). Rebuilt only when the active clip set or frame
87    /// geometry changes; `None` until the first composite.
88    pub(super) composer: Option<RealtimeComposer>,
89    /// Identifies the composer's current configuration as
90    /// `(layer_id, active_clip_idx, width, height)` per layer. Rebuild on change.
91    pub(super) composer_key: Vec<(usize, usize, u32, u32)>,
92    /// Project output canvas, when the timeline set one explicitly. When `Some`,
93    /// every composited frame is letterboxed to these dimensions so the preview
94    /// matches the project's output aspect. `None` composites at the base clip's
95    /// own size (legacy behaviour).
96    pub(super) canvas: Option<(u32, u32)>,
97    /// Timeline-global generated `lavfi` overlay, composited as the topmost layer
98    /// (above every file overlay). `None` when the timeline set no `lavfi_overlay`.
99    pub(super) lavfi: Option<LavfiOverlayState>,
100}
101
102impl SceneRunner {
103    /// Register the frame sink. Call before [`run`](Self::run).
104    pub fn set_sink(&mut self, sink: Box<dyn FrameSink>) {
105        self.sink = Some(sink);
106    }
107
108    /// Advances every overlay layer to the frame whose presentation time has
109    /// arrived at `target_pts`, holding the current frame otherwise (so a layer
110    /// whose fps differs from the timeline plays at the right speed rather than
111    /// advancing once per present). Returns `(layer_index, width, height)` for
112    /// each layer that currently has a frame to show.
113    fn sync_overlays(&mut self, target_pts: Duration) -> Vec<(usize, u32, u32)> {
114        let mut active = Vec::new();
115        for (li, layer) in self.overlay_layers.iter_mut().enumerate() {
116            let maybe_cidx = layer
117                .clips
118                .iter()
119                .position(|c| target_pts >= c.timeline_start && target_pts < c.timeline_end);
120            let Some(cidx) = maybe_cidx else {
121                layer.rgba.clear();
122                layer.cur_dims = None;
123                layer.pending = None;
124                continue;
125            };
126            if cidx != layer.active {
127                let local = layer.clips[cidx].in_point
128                    + target_pts.saturating_sub(layer.clips[cidx].timeline_start);
129                let _ = layer.clips[cidx].decode_buf.seek(local);
130                layer.active = cidx;
131                layer.cur_dims = None;
132                layer.pending = None;
133            }
134            let clip_in = layer.clips[cidx].in_point;
135            let tl_start = layer.clips[cidx].timeline_start;
136            loop {
137                let f = match layer.pending.take() {
138                    Some(pf) => pf,
139                    None => match layer.clips[cidx].decode_buf.pop_frame() {
140                        FrameResult::Frame(f) => f,
141                        _ => break,
142                    },
143                };
144                let v2_pts = tl_start + f.timestamp().as_duration().saturating_sub(clip_in);
145                if v2_pts > target_pts {
146                    // Not due yet — hold it for a later present.
147                    layer.pending = Some(f);
148                    break;
149                }
150                if layer.sws.convert(&f, &mut layer.rgba) {
151                    layer.cur_dims = Some((f.width(), f.height()));
152                }
153            }
154            match layer.cur_dims {
155                Some((ow, oh)) => active.push((li, ow, oh)),
156                None => layer.rgba.clear(),
157            }
158        }
159        active
160    }
161
162    /// Advances the generated `lavfi` overlay (if any) to the frame due at
163    /// `target_pts`, holding otherwise — see [`LavfiOverlayState::advance_to`].
164    /// Returns the current frame's `(width, height)`, or `None` when there is no
165    /// lavfi overlay / no frame yet.
166    fn sync_lavfi(&mut self, target_pts: Duration) -> Option<(u32, u32)> {
167        self.lavfi.as_mut()?.advance_to(target_pts)
168    }
169
170    /// Composites `base_frame` (the bottom layer) with the given overlay layers
171    /// through the cached [`RealtimeComposer`], applying each layer's effects and
172    /// blend mode. `base_id` identifies the base for cache invalidation — the V1
173    /// clip index, or `usize::MAX` for the gap-fill black base. Returns the
174    /// composited RGBA frame together with its actual `(width, height)`, or `None`
175    /// on failure.
176    ///
177    /// The composited size can differ from `base_w`/`base_h` when the base layer's
178    /// effect chain resizes the frame (`Crop`, `Scale`, `Pad`, `FitToAspect`), so
179    /// callers must push the returned dimensions to the sink rather than the
180    /// decoded ones — otherwise the buffer length no longer matches the reported
181    /// size and the frame is dropped.
182    #[allow(clippy::too_many_arguments)]
183    fn composite_frame(
184        &mut self,
185        base_layer: RealtimeLayer,
186        base_id: usize,
187        mut base_frame: VideoFrame,
188        base_w: u32,
189        base_h: u32,
190        overlays: &[(usize, u32, u32)],
191        t: Duration,
192    ) -> Option<(Vec<u8>, u32, u32)> {
193        let mut specs = vec![base_layer];
194        let mut key: Vec<(usize, usize, u32, u32)> = vec![(0, base_id, base_w, base_h)];
195        for &(li, ow, oh) in overlays {
196            let oc = &self.overlay_layers[li];
197            specs.push(RealtimeLayer::with_dimensions(
198                oc.clips[oc.active].layer_desc.clone(),
199                ow,
200                oh,
201                PixelFormat::Rgba,
202            ));
203            key.push((li + 1, oc.active, ow, oh));
204        }
205        // Topmost timeline-global lavfi overlay (generated), when a frame is held.
206        // Fixed full-frame Normal/Over layer, matching export; its transparency comes
207        // from the lavfi content's own alpha. A sentinel layer id keys the cache.
208        let lavfi_dims = self.lavfi.as_ref().and_then(|s| s.dims);
209        if let Some((lw, lh)) = lavfi_dims {
210            specs.push(RealtimeLayer {
211                width: lw,
212                height: lh,
213                pixel_format: PixelFormat::Rgba,
214                effects: Vec::new(),
215                opacity: AnimatedValue::Static(1.0),
216                x: AnimatedValue::Static(0.0),
217                y: AnimatedValue::Static(0.0),
218                scale_x: AnimatedValue::Static(1.0),
219                scale_y: AnimatedValue::Static(1.0),
220                rotation: AnimatedValue::Static(0.0),
221                blend_mode: BlendMode::Normal,
222                composite_op: CompositeOp::Over,
223            });
224            key.push((usize::MAX - 1, 0, lw, lh));
225        }
226        if self.composer.is_none() || self.composer_key != key {
227            self.composer = RealtimeComposer::with_canvas(&specs, self.canvas).ok();
228            self.composer_key = if self.composer.is_some() {
229                key
230            } else {
231                Vec::new()
232            };
233        }
234        let composer = self.composer.as_mut()?;
235        // Stamp every pushed frame with the composite's timeline PTS so the graph's
236        // per-frame animation tick (in `push_video`) evaluates each layer's opacity
237        // track at the same time. Frames from `from_rgba` carry PTS 0 otherwise, and
238        // any registered `AnimationEntry` would be frozen at t=0.
239        let ts = Timestamp::from_duration(t, Rational::new(1, 1_000_000));
240        base_frame.set_timestamp(ts);
241        if composer.push_layer(0, &base_frame).is_err() {
242            return None;
243        }
244        for (slot, &(li, ow, oh)) in overlays.iter().enumerate() {
245            let mut vf =
246                VideoFrame::from_rgba(ow, oh, self.overlay_layers[li].rgba.clone()).ok()?;
247            vf.set_timestamp(ts);
248            if composer.push_layer(slot + 1, &vf).is_err() {
249                return None;
250            }
251        }
252        // Push the lavfi overlay last (topmost slot), reading its held rgba from the
253        // disjoint `lavfi` field (as the overlay loop reads `overlay_layers`).
254        if let Some((lw, lh)) = lavfi_dims {
255            let rgba = self
256                .lavfi
257                .as_ref()
258                .map_or_else(Vec::new, |s| s.rgba.clone());
259            let mut vf = VideoFrame::from_rgba(lw, lh, rgba).ok()?;
260            vf.set_timestamp(ts);
261            if composer.push_layer(overlays.len() + 1, &vf).is_err() {
262                return None;
263            }
264        }
265        let f = composer.pull().ok().flatten()?;
266        let (w, h) = (f.width(), f.height());
267        f.to_rgba().map(|rgba| (rgba, w, h))
268    }
269
270    /// A/V sync presentation loop.
271    ///
272    /// Plays all clips in the primary video track from start to finish (or until
273    /// a [`PlayerCommand::Stop`] is received).
274    ///
275    /// Emits [`PlayerEvent::SeekCompleted`] after each successful seek,
276    /// [`PlayerEvent::PositionUpdate`] after each presented video frame,
277    /// [`PlayerEvent::Error`] on non-fatal decode errors, and
278    /// [`PlayerEvent::Eof`] before returning.
279    ///
280    /// # Errors
281    ///
282    /// Returns [`PreviewError::SeekOutOfRange`] if a seek command targets a
283    /// timestamp that falls outside all clips on the timeline.
284    #[allow(clippy::too_many_lines)]
285    pub fn run(mut self) -> Result<(), PreviewError> {
286        if self.clips.is_empty() {
287            let _ = self.event_tx.try_send(PlayerEvent::Eof);
288            return Ok(());
289        }
290
291        let fps = self.fps.max(1.0);
292        let frame_period = Duration::from_secs_f64(1.0 / fps);
293        self.clock.reset(Duration::ZERO);
294
295        loop {
296            // ── Drain commands ────────────────────────────────────────────────
297            let mut pending_seek: Option<Duration> = None;
298            while let Ok(cmd) = self.cmd_rx.try_recv() {
299                match cmd {
300                    PlayerCommand::Seek(pts) => pending_seek = Some(pts),
301                    PlayerCommand::Play => {
302                        // Always re-anchor the System clock on Play.
303                        //
304                        // PlayerHandle::play() sets the shared `paused` atomic
305                        // to `false` BEFORE enqueueing PlayerCommand::Play, so
306                        // paused.load() here always returns false — a guard on
307                        // `if paused` would never fire. Re-anchoring
308                        // unconditionally is safe: when the player was not
309                        // actually paused, resume_pts equals the last presented
310                        // frame PTS (or the seek target), which is already the
311                        // clock's current base, so clock.reset() is a no-op
312                        // in effect.
313                        self.clock.reset(self.resume_pts);
314                        self.stopped.store(false, Ordering::Release);
315                        self.paused.store(false, Ordering::Release);
316                    }
317                    PlayerCommand::Pause => {
318                        self.paused.store(true, Ordering::Release);
319                    }
320                    PlayerCommand::Stop => {
321                        self.stopped.store(true, Ordering::Release);
322                    }
323                    PlayerCommand::SetRate(r) => {
324                        if r != 0.0 {
325                            let was_negative = self.rate < 0.0;
326                            self.rate = r;
327                            if r > 0.0 {
328                                self.clock.set_rate(r);
329                                if was_negative {
330                                    // Returning from reverse: rebase clock and
331                                    // restart audio from the current video position.
332                                    let pts = Duration::from_micros(
333                                        self.current_pts.load(Ordering::Relaxed),
334                                    );
335                                    self.clock.reset(pts);
336                                    self.resume_pts = pts;
337                                    if let Err(e) = self.seek_timeline_coarse(pts) {
338                                        log::warn!(
339                                            "timeline reverse→forward seek failed \
340                                             pts={pts:?} error={e}"
341                                        );
342                                    } else {
343                                        let ci = self.active;
344                                        let clip_local = self.clips[ci].in_point
345                                            + pts.saturating_sub(self.clips[ci].timeline_start);
346                                        if let Some(m) = &self.audio_mixer {
347                                            m.lock()
348                                                .unwrap_or_else(std::sync::PoisonError::into_inner)
349                                                .invalidate_all();
350                                        }
351                                        self.restart_audio_at(ci, clip_local);
352                                    }
353                                }
354                            } else {
355                                // Entering reverse: silence audio.
356                                if let Some(cancel) = &self.active_audio_cancel {
357                                    cancel.store(true, Ordering::Release);
358                                }
359                                if let Some(m) = &self.audio_mixer {
360                                    m.lock()
361                                        .unwrap_or_else(std::sync::PoisonError::into_inner)
362                                        .invalidate_all();
363                                }
364                            }
365                        }
366                    }
367                    PlayerCommand::SetAvOffset(_) => {} // audio timing is system-clock driven
368                    PlayerCommand::UpdateLayout(scene) => {
369                        if let Err(e) = self.update_layout_in_place(&scene, self.resume_pts) {
370                            log::warn!("timeline layout update ignored: {e}");
371                        }
372                    }
373                }
374            }
375
376            // ── Apply pending seek ────────────────────────────────────────────
377            let had_seek = pending_seek.is_some();
378            if let Some(target) = pending_seek {
379                self.seek_timeline(target)?;
380                self.clock.reset(target);
381                self.resume_pts = target;
382                let _ = self.event_tx.try_send(PlayerEvent::SeekCompleted(target));
383            }
384
385            // When a seek arrives while paused, present one preview frame so
386            // the sink reflects the new position without resuming playback.
387            if had_seek && self.paused.load(Ordering::Acquire) {
388                let active = self.active;
389                let deadline = std::time::Instant::now() + Duration::from_millis(300);
390                loop {
391                    match self.clips[active].decode_buf.pop_frame() {
392                        FrameResult::Frame(f) => {
393                            let f_pts = f.timestamp().as_duration();
394                            let elapsed = f_pts.saturating_sub(self.clips[active].in_point);
395                            let tl_pts = self.clips[active].timeline_start
396                                + if (self.clips[active].speed - 1.0).abs() < 1e-9 {
397                                    elapsed
398                                } else {
399                                    elapsed.div_f64(self.clips[active].speed)
400                                };
401                            let w = f.width();
402                            let h = f.height();
403                            if self.sws_a.convert(&f, &mut self.rgba_a)
404                                && let Some(sink) = self.sink.as_mut()
405                            {
406                                sink.push_frame(&self.rgba_a, w, h, tl_pts);
407                            }
408                            self.current_pts.store(
409                                u64::try_from(tl_pts.as_micros()).unwrap_or(u64::MAX),
410                                Ordering::Relaxed,
411                            );
412                            let _ = self.event_tx.try_send(PlayerEvent::PositionUpdate(tl_pts));
413                            break;
414                        }
415                        FrameResult::Seeking(_) => {
416                            if std::time::Instant::now() > deadline {
417                                break;
418                            }
419                            thread::sleep(Duration::from_millis(2));
420                        }
421                        FrameResult::Eof => break,
422                    }
423                }
424            }
425
426            // ── Error events from active clip ─────────────────────────────────
427            {
428                let active = self.active;
429                while let Ok(msg) = self.clips[active].decode_buf.error_events().try_recv() {
430                    let _ = self.event_tx.try_send(PlayerEvent::Error(msg));
431                }
432            }
433            let trans_next = self.transition.as_ref().map(|tp| tp.next_idx);
434            if let Some(next_idx) = trans_next {
435                while let Ok(msg) = self.clips[next_idx].decode_buf.error_events().try_recv() {
436                    let _ = self.event_tx.try_send(PlayerEvent::Error(msg));
437                }
438            }
439
440            // ── Stopped / paused ──────────────────────────────────────────────
441            if self.stopped.load(Ordering::Acquire) {
442                break;
443            }
444            if self.paused.load(Ordering::Acquire) {
445                thread::sleep(Duration::from_millis(5));
446                continue;
447            }
448
449            // ── Reverse playback path ─────────────────────────────────────────
450            if self.rate < 0.0 {
451                let current = Duration::from_micros(self.current_pts.load(Ordering::Relaxed));
452                let step = Duration::from_secs_f64(self.rate.abs() / fps.max(f64::MIN_POSITIVE));
453                let target = current.saturating_sub(step);
454
455                let clip_idx = self
456                    .clips
457                    .iter()
458                    .position(|c| target >= c.timeline_start && target < c.timeline_end);
459
460                if let Some(ci) = clip_idx {
461                    let elapsed_tl = target.saturating_sub(self.clips[ci].timeline_start);
462                    let clip_local = self.clips[ci].in_point
463                        + if (self.clips[ci].speed - 1.0).abs() < 1e-9 {
464                            elapsed_tl
465                        } else {
466                            elapsed_tl.mul_f64(self.clips[ci].speed)
467                        };
468                    if self.clips[ci].decode_buf.seek_coarse(clip_local).is_ok() {
469                        if ci != self.active {
470                            self.active = ci;
471                            self.transition = None;
472                        }
473                        let deadline = std::time::Instant::now() + Duration::from_millis(300);
474                        let frame = loop {
475                            match self.clips[ci].decode_buf.pop_frame() {
476                                FrameResult::Frame(f) => break Some(f),
477                                FrameResult::Seeking(_) => {
478                                    if std::time::Instant::now() > deadline {
479                                        break None;
480                                    }
481                                    thread::sleep(Duration::from_millis(2));
482                                }
483                                FrameResult::Eof => break None,
484                            }
485                        };
486                        if let Some(f) = frame {
487                            let f_pts = f.timestamp().as_duration();
488                            let elapsed = f_pts.saturating_sub(self.clips[ci].in_point);
489                            let tl_pts = self.clips[ci].timeline_start
490                                + if (self.clips[ci].speed - 1.0).abs() < 1e-9 {
491                                    elapsed
492                                } else {
493                                    elapsed.div_f64(self.clips[ci].speed)
494                                };
495                            let w = f.width();
496                            let h = f.height();
497                            if self.sws_a.convert(&f, &mut self.rgba_a)
498                                && let Some(sink) = self.sink.as_mut()
499                            {
500                                sink.push_frame(&self.rgba_a, w, h, tl_pts);
501                            }
502                            self.current_pts.store(
503                                u64::try_from(tl_pts.as_micros()).unwrap_or(u64::MAX),
504                                Ordering::Relaxed,
505                            );
506                            self.resume_pts = tl_pts;
507                            let _ = self.event_tx.try_send(PlayerEvent::PositionUpdate(tl_pts));
508                        }
509                    }
510                }
511
512                if self
513                    .clips
514                    .first()
515                    .is_some_and(|c| target < c.timeline_start)
516                {
517                    self.paused.store(true, Ordering::Release);
518                }
519                thread::sleep(frame_period);
520                continue;
521            }
522
523            // ── Pop frame from active clip ─────────────────────────────────────
524            let active = self.active;
525            let pop_result = self.clips[active].decode_buf.pop_frame();
526
527            match pop_result {
528                FrameResult::Eof => {
529                    let old_active = active;
530                    if let Some(tp) = self.transition.take() {
531                        self.active = tp.next_idx;
532                    } else if active + 1 < self.clips.len() {
533                        self.active += 1;
534                    } else {
535                        break;
536                    }
537                    if self.active != old_active {
538                        // Clear the outgoing clip's pre-decoded audio so its stale
539                        // samples do not continue to mix in after the transition.
540                        if let Some(h) = self.clips[old_active].audio_track.clone() {
541                            h.clear();
542                        }
543                        let in_pt = self.clips[self.active].in_point;
544                        self.restart_audio_at(self.active, in_pt);
545                    }
546                }
547
548                FrameResult::Seeking(last) => {
549                    if let Some(ref f) = last {
550                        let f_pts = f.timestamp().as_duration();
551                        let in_pt = self.clips[active].in_point;
552                        // Suppress pre-seek artefact frames: when a DecodeBuffer
553                        // is opened and immediately seeked to in_point, the
554                        // background thread may have decoded one frame from
555                        // position 0 before processing the seek command. That
556                        // frame ends up as `last` and must not be displayed —
557                        // its content is from before the clip's in_point.
558                        if f_pts >= in_pt {
559                            let tl_start = self.clips[active].timeline_start;
560                            let elapsed = f_pts.saturating_sub(in_pt);
561                            let spd = self.clips[active].speed;
562                            let tl_pts = tl_start
563                                + if (spd - 1.0).abs() < 1e-9 {
564                                    elapsed
565                                } else {
566                                    elapsed.div_f64(spd)
567                                };
568                            let w = f.width();
569                            let h = f.height();
570                            if self.sws_a.convert(f, &mut self.rgba_a)
571                                && let Some(sink) = self.sink.as_mut()
572                            {
573                                sink.push_frame(&self.rgba_a, w, h, tl_pts);
574                            }
575                        }
576                    }
577                }
578
579                FrameResult::Frame(frame) => {
580                    let f_pts = frame.timestamp().as_duration();
581                    let clip_in = self.clips[active].in_point;
582                    let clip_out = self.clips[active].out_point;
583                    let clip_tl_start = self.clips[active].timeline_start;
584                    let clip_tl_end = self.clips[active].timeline_end;
585                    let clip_speed = self.clips[active].speed;
586
587                    // Skip frames before in_point (e.g. right after a seek).
588                    if f_pts < clip_in {
589                        continue;
590                    }
591
592                    // Treat frames past out_point as EOF for this clip.
593                    let past_out = clip_out.is_some_and(|op| f_pts >= op);
594                    let elapsed = f_pts.saturating_sub(clip_in);
595                    // Remap source PTS → timeline PTS via speed factor.
596                    // For speed=2.0 the clip occupies half the timeline duration;
597                    // for speed=0.5 it occupies double.
598                    let tl_elapsed = if (clip_speed - 1.0).abs() < 1e-9 {
599                        elapsed
600                    } else {
601                        elapsed.div_f64(clip_speed)
602                    };
603                    let past_end = clip_tl_start + tl_elapsed >= clip_tl_end;
604
605                    if past_out || past_end {
606                        let old_active = active;
607                        if let Some(tp) = self.transition.take() {
608                            self.active = tp.next_idx;
609                        } else if active + 1 < self.clips.len() {
610                            self.active += 1;
611                        } else {
612                            break;
613                        }
614                        if self.active != old_active {
615                            // Clear the outgoing clip's pre-decoded audio so its
616                            // stale samples do not continue to mix in after the
617                            // transition.
618                            if let Some(h) = self.clips[old_active].audio_track.clone() {
619                                h.clear();
620                            }
621                            let in_pt = self.clips[self.active].in_point;
622                            self.restart_audio_at(self.active, in_pt);
623                        }
624                        continue;
625                    }
626
627                    let timeline_pts = clip_tl_start + tl_elapsed;
628
629                    // ── Manage audio-only decode threads ──────────────────────
630                    for at in &mut self.audio_only_tracks {
631                        let should_run =
632                            timeline_pts >= at.timeline_start && timeline_pts < at.timeline_end;
633                        let is_running = at.cancel.is_some();
634                        if should_run && !is_running {
635                            let local =
636                                at.in_point + timeline_pts.saturating_sub(at.timeline_start);
637                            at.start_at(local);
638                        } else if !should_run && is_running {
639                            at.stop();
640                            // Clear stale pre-decoded samples so the mixer does
641                            // not play this track's buffered audio past clip end.
642                            at.handle.clear();
643                        }
644                        // Per-clip volume automation: an animated gain is evaluated at
645                        // the timeline PTS each tick (a static gain was set at open).
646                        if should_run && let AnimatedValue::Track(track) = &at.volume {
647                            at.handle
648                                .set_volume(db_to_linear(track.value_at(timeline_pts)));
649                        }
650                    }
651
652                    // Primary-track volume automation for the active clip.
653                    if let Some(handle) = &self.clips[active].audio_track
654                        && let AnimatedValue::Track(track) = &self.clips[active].volume
655                    {
656                        handle.set_volume(db_to_linear(track.value_at(timeline_pts)));
657                    }
658
659                    // Update shared current_pts and resume anchor.
660                    self.current_pts.store(
661                        u64::try_from(timeline_pts.as_micros()).unwrap_or(u64::MAX),
662                        Ordering::Relaxed,
663                    );
664                    self.resume_pts = timeline_pts;
665
666                    // ── Transition zone entry check ────────────────────────────
667                    if self.transition.is_none() && active + 1 < self.clips.len() {
668                        let next = &self.clips[active + 1];
669                        if next.xfade_dur > Duration::ZERO && timeline_pts >= next.timeline_start {
670                            if timeline_pts < next.timeline_start + next.xfade_dur {
671                                self.transition = Some(TransitionState {
672                                    next_idx: active + 1,
673                                    start: next.timeline_start,
674                                    duration: next.xfade_dur,
675                                    kind: next.xfade_kind.unwrap_or(XfadeTransition::Fade),
676                                });
677                            } else {
678                                // Jumped past the entire transition zone.
679                                let old_active = active;
680                                self.active = active + 1;
681                                if self.active != old_active {
682                                    let in_pt = self.clips[self.active].in_point;
683                                    self.restart_audio_at(self.active, in_pt);
684                                }
685                                continue;
686                            }
687                        }
688                    }
689
690                    // ── A/V sync (system clock) ───────────────────────────────
691                    {
692                        let clock_pts = self.clock.current_pts();
693                        let diff = timeline_pts.as_secs_f64() - clock_pts.as_secs_f64();
694                        let fp = frame_period.as_secs_f64();
695
696                        // Only enter gap fill for an actual gap between clips.
697                        // For slow-motion clips (speed < 1.0) the large diff is expected
698                        // and should be handled by the `diff > fp` sleep below instead.
699                        if diff > fp * 2.0
700                            && (clip_speed - 1.0) > -1e-9
701                            && self.transition.is_none()
702                            && self.last_frame_w > 0
703                        {
704                            // Gap in the primary track: the next V1 clip starts more than
705                            // 2 frame-periods ahead of the clock.  Synthesise black frames
706                            // composited with overlay-layer content for every missing
707                            // frame period so that V2 overlays and audio-only tracks
708                            // remain live during the gap.
709                            // With an explicit canvas, fill gaps at the canvas size so
710                            // the preview frame size stays constant across gaps.
711                            let (gw, gh) = self
712                                .canvas
713                                .unwrap_or((self.last_frame_w, self.last_frame_h));
714                            let n = (gw * gh * 4) as usize;
715                            'gap: loop {
716                                // Drain incoming commands.
717                                while let Ok(cmd) = self.cmd_rx.try_recv() {
718                                    match cmd {
719                                        PlayerCommand::Play => {
720                                            self.clock.reset(self.resume_pts);
721                                            self.stopped.store(false, Ordering::Release);
722                                            self.paused.store(false, Ordering::Release);
723                                        }
724                                        PlayerCommand::Pause => {
725                                            self.paused.store(true, Ordering::Release);
726                                        }
727                                        PlayerCommand::Stop => {
728                                            self.stopped.store(true, Ordering::Release);
729                                        }
730                                        PlayerCommand::SetRate(r) if r > 0.0 => {
731                                            self.rate = r;
732                                            self.clock.set_rate(r);
733                                        }
734                                        _ => {}
735                                    }
736                                }
737                                if self.stopped.load(Ordering::Acquire) {
738                                    break 'gap;
739                                }
740                                if self.paused.load(Ordering::Acquire) {
741                                    thread::sleep(Duration::from_millis(5));
742                                    continue 'gap;
743                                }
744                                let gap_pts = self.clock.current_pts();
745                                if gap_pts + frame_period >= timeline_pts {
746                                    break 'gap;
747                                }
748                                // Build a black base and composite the overlays onto it
749                                // through the shared compositor — same held-frame timing,
750                                // effects, and blend modes as the main present path.
751                                self.gap_buf.resize(n, 0);
752                                self.gap_buf.fill(0);
753                                let gap_overlays = self.sync_overlays(gap_pts);
754                                let gap_lavfi = self.sync_lavfi(gap_pts);
755                                // Composite when there is a file overlay OR a lavfi
756                                // overlay to draw over the gap's black base.
757                                let gap_composited =
758                                    if gap_overlays.is_empty() && gap_lavfi.is_none() {
759                                        None
760                                    } else {
761                                        let base_layer = RealtimeLayer {
762                                            width: gw,
763                                            height: gh,
764                                            pixel_format: PixelFormat::Rgba,
765                                            effects: Vec::new(),
766                                            opacity: AnimatedValue::Static(1.0),
767                                            x: AnimatedValue::Static(0.0),
768                                            y: AnimatedValue::Static(0.0),
769                                            scale_x: AnimatedValue::Static(1.0),
770                                            scale_y: AnimatedValue::Static(1.0),
771                                            rotation: AnimatedValue::Static(0.0),
772                                            blend_mode: BlendMode::Normal,
773                                            composite_op: ff_filter::CompositeOp::Over,
774                                        };
775                                        match VideoFrame::from_rgba(gw, gh, self.gap_buf.clone()) {
776                                            Ok(bf) => self.composite_frame(
777                                                base_layer,
778                                                usize::MAX,
779                                                bf,
780                                                gw,
781                                                gh,
782                                                &gap_overlays,
783                                                gap_pts,
784                                            ),
785                                            Err(_) => None,
786                                        }
787                                    };
788                                // Manage audio-only decode threads (A1/A2…).
789                                for at in &mut self.audio_only_tracks {
790                                    let should_run =
791                                        gap_pts >= at.timeline_start && gap_pts < at.timeline_end;
792                                    let is_running = at.cancel.is_some();
793                                    if should_run && !is_running {
794                                        let local =
795                                            at.in_point + gap_pts.saturating_sub(at.timeline_start);
796                                        at.start_at(local);
797                                    } else if !should_run && is_running {
798                                        at.stop();
799                                        at.handle.clear();
800                                    }
801                                }
802                                // Manage V1 inline audio: start it the moment the
803                                // gap clock reaches the active clip's timeline_start.
804                                if self.active_audio_cancel.is_none()
805                                    && self.clips[self.active].audio_track.is_some()
806                                    && gap_pts >= self.clips[self.active].timeline_start
807                                {
808                                    let tl_start = self.clips[self.active].timeline_start;
809                                    let in_pt = self.clips[self.active].in_point;
810                                    let gap_elapsed = gap_pts.saturating_sub(tl_start);
811                                    let spd = self.clips[self.active].speed;
812                                    let local = in_pt
813                                        + if (spd - 1.0).abs() < 1e-9 {
814                                            gap_elapsed
815                                        } else {
816                                            gap_elapsed.mul_f64(spd)
817                                        };
818                                    self.restart_audio_at(self.active, local);
819                                }
820                                self.current_pts.store(
821                                    u64::try_from(gap_pts.as_micros()).unwrap_or(u64::MAX),
822                                    Ordering::Relaxed,
823                                );
824                                self.resume_pts = gap_pts;
825                                let _ =
826                                    self.event_tx.try_send(PlayerEvent::PositionUpdate(gap_pts));
827                                if let Some(sink) = self.sink.as_mut() {
828                                    match &gap_composited {
829                                        Some((rgba, cw, ch)) => {
830                                            sink.push_frame(rgba, *cw, *ch, gap_pts);
831                                        }
832                                        None => sink.push_frame(&self.gap_buf, gw, gh, gap_pts),
833                                    }
834                                }
835                                thread::sleep(frame_period);
836                            }
837                        } else if diff > fp {
838                            let sleep_secs =
839                                (diff - fp / 2.0).max(0.0) / self.rate.max(f64::MIN_POSITIVE);
840                            thread::sleep(Duration::from_secs_f64(sleep_secs));
841                        } else if diff < -fp {
842                            log::debug!(
843                                "timeline dropped late frame timeline_pts={timeline_pts:?} \
844                                 clock_pts={clock_pts:?}"
845                            );
846                            continue;
847                        }
848                    }
849
850                    // Start V1 inline audio on the first presented frame when a
851                    // pre-roll gap prevented the thread from starting at open() time.
852                    // The gap-fill loop attempts this but exits one frame-period before
853                    // timeline_start, so we catch the remaining case here.
854                    if self.active_audio_cancel.is_none()
855                        && self.clips[active].audio_track.is_some()
856                    {
857                        let in_pt = self.clips[active].in_point;
858                        let elapsed_tl =
859                            timeline_pts.saturating_sub(self.clips[active].timeline_start);
860                        let local = in_pt
861                            + if (clip_speed - 1.0).abs() < 1e-9 {
862                                elapsed_tl
863                            } else {
864                                elapsed_tl.mul_f64(clip_speed)
865                            };
866                        self.restart_audio_at(active, local);
867                    }
868
869                    // ── Present frame ─────────────────────────────────────────
870                    let w = frame.width();
871                    let h = frame.height();
872                    self.last_frame_w = w;
873                    self.last_frame_h = h;
874
875                    // Copy transition fields to avoid holding a borrow while
876                    // calling `pop_frame` on the next clip.
877                    let (in_trans, next_idx, trans_start, trans_dur, trans_kind) =
878                        match &self.transition {
879                            Some(tp) => (true, tp.next_idx, tp.start, tp.duration, tp.kind),
880                            None => (
881                                false,
882                                0,
883                                Duration::ZERO,
884                                Duration::ZERO,
885                                XfadeTransition::Fade,
886                            ),
887                        };
888
889                    let a_ok = self.sws_a.convert(&frame, &mut self.rgba_a);
890
891                    if a_ok {
892                        // V1 per-clip opacity: pre-multiply toward black (producer-side;
893                        // the composer ignores base-layer opacity). The merged opacity is
894                        // an `AnimatedValue`; a track is evaluated at the timeline PTS
895                        // (tracks are timeline-global), so base-layer opacity animates too.
896                        let v1_op = match &self.clips[active].layer_desc.opacity {
897                            // Value is clamped to [0.0, 1.0], so the f32 narrowing is safe.
898                            #[allow(clippy::cast_possible_truncation)]
899                            AnimatedValue::Track(track) => {
900                                track.value_at(timeline_pts).clamp(0.0, 1.0) as f32
901                            }
902                            AnimatedValue::Static(_) => self.clips[active].opacity,
903                        };
904                        if (v1_op - 1.0).abs() > 1e-6 {
905                            for chunk in self.rgba_a.chunks_exact_mut(4) {
906                                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
907                                {
908                                    chunk[0] = (f32::from(chunk[0]) * v1_op).round() as u8;
909                                    chunk[1] = (f32::from(chunk[1]) * v1_op).round() as u8;
910                                    chunk[2] = (f32::from(chunk[2]) * v1_op).round() as u8;
911                                }
912                            }
913                        }
914
915                        // Transition crossfade (producer-side): blend the incoming clip
916                        // into rgba_a so the composer grades the crossfaded V1 frame.
917                        if in_trans
918                            && let FrameResult::Frame(next_frame) =
919                                self.clips[next_idx].decode_buf.pop_frame()
920                            && self.sws_b.convert(&next_frame, &mut self.rgba_b)
921                        {
922                            let alpha = (timeline_pts.saturating_sub(trans_start).as_secs_f32()
923                                / trans_dur.as_secs_f32())
924                            .clamp(0.0, 1.0);
925                            inner::apply_xfade(
926                                trans_kind,
927                                &self.rgba_a,
928                                &self.rgba_b,
929                                alpha,
930                                w,
931                                h,
932                                &mut self.blend_buf,
933                            );
934                            std::mem::swap(&mut self.rgba_a, &mut self.blend_buf);
935                        }
936
937                        // Update overlays (held-frame, advanced by PTS) and composite
938                        // the V1 base with them through the shared compositor.
939                        let active_overlays = self.sync_overlays(timeline_pts);
940                        self.sync_lavfi(timeline_pts);
941                        let base_layer = RealtimeLayer::with_dimensions(
942                            self.clips[active].layer_desc.clone(),
943                            w,
944                            h,
945                            PixelFormat::Rgba,
946                        );
947                        let composited = match VideoFrame::from_rgba(w, h, self.rgba_a.clone()) {
948                            Ok(bf) => self.composite_frame(
949                                base_layer,
950                                active,
951                                bf,
952                                w,
953                                h,
954                                &active_overlays,
955                                timeline_pts,
956                            ),
957                            Err(_) => None,
958                        };
959
960                        // Deliver: the composited frame, or the raw V1 as a fallback.
961                        if let Some(sink) = self.sink.as_mut() {
962                            match &composited {
963                                Some((rgba, cw, ch)) => {
964                                    sink.push_frame(rgba, *cw, *ch, timeline_pts);
965                                }
966                                None => sink.push_frame(&self.rgba_a, w, h, timeline_pts),
967                            }
968                        }
969
970                        // Advance past a completed transition.
971                        if in_trans && timeline_pts >= trans_start + trans_dur {
972                            let old_active = self.active;
973                            self.transition = None;
974                            self.active = next_idx;
975                            if self.active != old_active {
976                                let in_pt = self.clips[self.active].in_point;
977                                self.restart_audio_at(self.active, in_pt);
978                            }
979                        }
980                    }
981
982                    let _ = self
983                        .event_tx
984                        .try_send(PlayerEvent::PositionUpdate(timeline_pts));
985                }
986            }
987        }
988
989        let _ = self.event_tx.try_send(PlayerEvent::Eof);
990        if let Some(sink) = self.sink.as_mut() {
991            sink.flush();
992        }
993        Ok(())
994    }
995
996    /// Seek all decode buffers so that `active` is the clip containing `target`
997    /// and that clip's buffer is positioned at the correct source-file PTS.
998    ///
999    /// When `target` falls in a pre-roll or inter-clip gap the method finds the
1000    /// next clip after `target`, seeks it to its `in_point`, and returns without
1001    /// starting audio — the gap-fill loop in `run()` will start audio at the
1002    /// right time.
1003    pub(super) fn seek_timeline(&mut self, target: Duration) -> Result<(), PreviewError> {
1004        // Try to find a clip that contains `target`.
1005        let clip_in_range = self
1006            .clips
1007            .iter()
1008            .position(|c| target >= c.timeline_start && target < c.timeline_end);
1009
1010        // If target is in a gap, find the next clip after `target`.
1011        let (clip_idx, clip_local_pts, is_gap_seek) = if let Some(ci) = clip_in_range {
1012            let elapsed_tl = target.saturating_sub(self.clips[ci].timeline_start);
1013            let local = self.clips[ci].in_point
1014                + if (self.clips[ci].speed - 1.0).abs() < 1e-9 {
1015                    elapsed_tl
1016                } else {
1017                    elapsed_tl.mul_f64(self.clips[ci].speed)
1018                };
1019            (ci, local, false)
1020        } else if let Some(ci) = self.clips.iter().position(|c| c.timeline_start > target) {
1021            // Seek the clip to its in_point; gap-fill loop will tick until it starts.
1022            (ci, self.clips[ci].in_point, true)
1023        } else {
1024            return Err(PreviewError::SeekOutOfRange { pts: target });
1025        };
1026
1027        self.clips[clip_idx].decode_buf.seek(clip_local_pts)?;
1028        self.active = clip_idx;
1029        self.transition = None;
1030
1031        // Discard stale audio and restart from the seek position.
1032        if let Some(mixer_arc) = &self.audio_mixer {
1033            mixer_arc
1034                .lock()
1035                .unwrap_or_else(std::sync::PoisonError::into_inner)
1036                .invalidate_all();
1037        }
1038        if is_gap_seek {
1039            // Cancel any running V1 audio thread; the gap loop will restart it
1040            // once the clock reaches the clip's timeline_start.
1041            if let Some(cancel) = self.active_audio_cancel.take() {
1042                cancel.store(true, Ordering::Release);
1043            }
1044            drop(self.active_audio_thread.take());
1045        } else {
1046            self.restart_audio_at(clip_idx, clip_local_pts);
1047        }
1048
1049        // Seek overlay layers to the new target position.
1050        for layer in &mut self.overlay_layers {
1051            let cidx = layer
1052                .clips
1053                .iter()
1054                .position(|c| target >= c.timeline_start && target < c.timeline_end);
1055            if let Some(cidx) = cidx {
1056                let local = layer.clips[cidx].in_point
1057                    + target.saturating_sub(layer.clips[cidx].timeline_start);
1058                let _ = layer.clips[cidx].decode_buf.seek(local);
1059                layer.active = cidx;
1060            }
1061        }
1062
1063        // The lavfi overlay source exposes no seek — rebuild it so it restarts from
1064        // t=0. Static overlays are unaffected; a time-varying lavfi restarts (a
1065        // documented limitation).
1066        if let Some(st) = &mut self.lavfi {
1067            st.rebuild();
1068        }
1069
1070        // Stop all audio-only threads; they restart on the next frame tick.
1071        for at in &mut self.audio_only_tracks {
1072            at.stop();
1073        }
1074
1075        Ok(())
1076    }
1077
1078    /// Coarse (I-frame only) seek variant of [`seek_timeline`].
1079    ///
1080    /// Does not restart audio or invalidate the mixer — caller is responsible.
1081    /// Used for the reverse→forward recovery path where latency matters more
1082    /// than frame-accurate positioning.
1083    fn seek_timeline_coarse(&mut self, target: Duration) -> Result<(), PreviewError> {
1084        let clip_idx = self
1085            .clips
1086            .iter()
1087            .position(|c| target >= c.timeline_start && target < c.timeline_end)
1088            .ok_or(PreviewError::SeekOutOfRange { pts: target })?;
1089        let elapsed_tl = target.saturating_sub(self.clips[clip_idx].timeline_start);
1090        let clip_local_pts = self.clips[clip_idx].in_point
1091            + if (self.clips[clip_idx].speed - 1.0).abs() < 1e-9 {
1092                elapsed_tl
1093            } else {
1094                elapsed_tl.mul_f64(self.clips[clip_idx].speed)
1095            };
1096        self.clips[clip_idx]
1097            .decode_buf
1098            .seek_coarse(clip_local_pts)?;
1099        self.active = clip_idx;
1100        self.transition = None;
1101        // Keep the lavfi overlay consistent with the main seek path (no source seek).
1102        if let Some(st) = &mut self.lavfi {
1103            st.rebuild();
1104        }
1105        Ok(())
1106    }
1107
1108    /// Cancel the current audio decode thread (if any) and start a new one
1109    /// for `clip_idx` beginning at `start_pts`.
1110    fn restart_audio_at(&mut self, clip_idx: usize, start_pts: Duration) {
1111        // Cancel and drop the previous thread.
1112        if let Some(cancel) = &self.active_audio_cancel {
1113            cancel.store(true, Ordering::Release);
1114        }
1115        drop(self.active_audio_thread.take());
1116        self.active_audio_cancel = None;
1117
1118        let Some(handle) = self.clips.get(clip_idx).and_then(|c| c.audio_track.clone()) else {
1119            return;
1120        };
1121        handle.clear(); // discard stale samples
1122
1123        let c = &self.clips[clip_idx];
1124        let source = c.source.clone();
1125        // V1 clip audio honours its own fades + speed (the A-track path already does).
1126        // `clip_dur` must be the SOURCE-time span (the resampler multiplies it by
1127        // `1/speed` to get timeline time, as the A-tracks feed `out-in` source span);
1128        // the timeline span is `source/speed`, so scale it back by `speed`.
1129        let fades = AudioFadeConfig {
1130            fade_in: c.fade_in,
1131            fade_out: c.fade_out,
1132            clip_dur: c
1133                .timeline_end
1134                .saturating_sub(c.timeline_start)
1135                .mul_f64(c.speed),
1136            in_point: c.in_point,
1137            speed: c.speed,
1138        };
1139        let cancel = Arc::new(AtomicBool::new(false));
1140        let thread =
1141            spawn_audio_track_thread(source, start_pts, handle, Arc::clone(&cancel), fades);
1142        self.active_audio_cancel = Some(cancel);
1143        self.active_audio_thread = Some(thread);
1144    }
1145}
1146
1147impl Drop for SceneRunner {
1148    fn drop(&mut self) {
1149        if let Some(cancel) = &self.active_audio_cancel {
1150            cancel.store(true, Ordering::Release);
1151        }
1152        if let Some(h) = self.active_audio_thread.take() {
1153            let _ = h.join();
1154        }
1155    }
1156}