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