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