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}