Skip to main content

ff_preview/timeline/
runner.rs

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