Skip to main content

ff_preview/scene/
mod.rs

1//! Real-time playback of a [`Scene`].
2//!
3//! [`ScenePlayer`] opens every placement on the base video track of a [`Scene`]
4//! and plays them back in order, mapping each clip's frame PTS to the unified
5//! timeline coordinate. The `Scene` is a model-agnostic description an engine
6//! derives from its editing model.
7//!
8//! | Type | Role |
9//! |------|------|
10//! | [`ScenePlayer`] | Thin builder: call [`open`](ScenePlayer::open) |
11//! | [`SceneRunner`] | Owns the decode pipelines; move to a thread and call [`run`](SceneRunner::run) |
12//! | [`PlayerHandle`] | Shared, cloneable control handle |
13//!
14//! ## Audio
15//!
16//! When any placement on the base video track carries an audio stream,
17//! [`ScenePlayer::open`] creates an [`AudioMixer`] with one track per
18//! audio-bearing clip.  A background [`AudioDecoder`](ff_decode::AudioDecoder) thread is started for
19//! the active clip and pushes mono samples via [`AudioTrackHandle`].  On clip
20//! transition or seek the old thread is cancelled and a new one is started.
21//! [`PlayerHandle::pop_audio_samples`] calls [`AudioMixer::mix`] and returns
22//! interleaved stereo `f32` output.
23
24mod audio_resampling;
25mod compositor;
26mod inner;
27mod runner;
28mod runner_layout;
29mod state;
30mod types;
31
32use std::path::PathBuf;
33use std::sync::atomic::{AtomicBool, AtomicU64};
34use std::sync::{Arc, Mutex, mpsc};
35use std::time::{Duration, Instant};
36
37use crate::audio::{AudioMixer, AudioTrackHandle};
38use crate::error::PreviewError;
39use crate::event::PlayerEvent;
40use crate::playback::SwsRgbaConverter;
41use crate::playback::decode_buffer::DecodeBuffer;
42use crate::playback::master_clock::MasterClock;
43use crate::playback::player_handle::PlayerHandle;
44
45pub use compositor::PreviewCompositor;
46pub use inner::apply_xfade;
47pub use runner::{Pacing, SceneRunner};
48pub use types::{
49    Scene, SceneAudioPlacement, SceneAudioTrack, ScenePlacement, SceneSource, SceneVideoTrack,
50};
51
52use audio_resampling::spawn_audio_track_thread;
53use ff_filter::{AnimatedValue, SolidSource, TextSource, XfadeTransition};
54use ff_format::VideoFrame;
55use state::{
56    AudioFadeConfig, AudioOnlyTrack, ClipState, ClipVideoSource, LavfiOverlayState, OverlayLayer,
57    db_to_linear,
58};
59
60/// Resolves the canvas size for generated (solid/text) sources: the explicit scene
61/// canvas, else the first file placement's video size, else `(0, 0)` (no file to
62/// size from, so a generated source renders nothing).
63fn resolve_canvas_dims(scene: &Scene) -> (u32, u32) {
64    if let Some(dims) = scene.canvas {
65        return dims;
66    }
67    for track in &scene.video_tracks {
68        for p in &track.placements {
69            if let Some(path) = p.source.as_file()
70                && let Ok(info) = ff_probe::open(path)
71                && let Some(v) = info.primary_video()
72            {
73                return (v.width(), v.height());
74            }
75        }
76    }
77    (0, 0)
78}
79
80/// Builds the constant held frame for a generated source (pulled once via
81/// `ff-filter`'s `SolidSource` / `TextSource`, the same `color` / `drawtext` filters
82/// export uses), or `None` when unavailable — e.g. the filters are missing on a
83/// minimal `FFmpeg` (RK-002), so the clip renders nothing rather than failing `open`.
84fn generated_held_frame(source: &SceneSource, cw: u32, ch: u32, fps: f64) -> Option<VideoFrame> {
85    if cw == 0 || ch == 0 {
86        log::warn!("generated source has no canvas size to render into, cw={cw} ch={ch}");
87        return None;
88    }
89    let pulled = match source {
90        SceneSource::Solid(color) => {
91            SolidSource::new(*color, cw, ch, fps).map(|mut s| pull_first(&mut s))
92        }
93        SceneSource::Text(spec) => {
94            TextSource::new(spec, cw, ch, fps).map(|mut s| pull_first(&mut s))
95        }
96        SceneSource::File(_) => return None,
97    };
98    match pulled {
99        Ok(Some(frame)) => Some(frame),
100        Ok(None) => {
101            log::warn!("generated source produced no frame; rendering nothing");
102            None
103        }
104        Err(e) => {
105            log::warn!("generated source unavailable, rendering nothing, error={e}");
106            None
107        }
108    }
109}
110
111/// The timeline span of a generated clip, from its `out_point` (a generated source is
112/// infinite, so `out_point` bounds it — mirroring the export-side
113/// `GeneratedSourceNeedsDuration`). Warns and yields zero when unbounded.
114fn generated_span(out_point: Option<Duration>, in_pt: Duration) -> Duration {
115    if let Some(op) = out_point {
116        op.saturating_sub(in_pt)
117    } else {
118        log::warn!(
119            "generated clip has no out_point; preview shows zero duration (bound it with a trim)"
120        );
121        Duration::ZERO
122    }
123}
124
125/// Opens the per-clip video source: a decoding [`DecodeBuffer`] seeked to `in_pt`
126/// for a file, or a constant [`Held`](ClipVideoSource::Held) frame (built at
127/// `cw`x`ch`) for a generated source.
128fn open_clip_video_source(
129    source: &SceneSource,
130    in_pt: Duration,
131    cw: u32,
132    ch: u32,
133    fps: f64,
134) -> Result<ClipVideoSource, PreviewError> {
135    match source.as_file() {
136        Some(path) => {
137            let mut buf = DecodeBuffer::open(path).build()?;
138            if in_pt > Duration::ZERO {
139                buf.seek(in_pt)?;
140            }
141            Ok(ClipVideoSource::File(buf))
142        }
143        None => Ok(ClipVideoSource::held(
144            generated_held_frame(source, cw, ch, fps),
145            in_pt,
146            fps,
147        )),
148    }
149}
150
151/// The file path of a source (empty for a generated one) — the `ClipState.source`
152/// field, used only to spawn an audio thread (a generated clip has no audio).
153fn source_path(source: &SceneSource) -> PathBuf {
154    source
155        .as_file()
156        .map(std::path::Path::to_path_buf)
157        .unwrap_or_default()
158}
159
160/// Pulls the first frame from a freshly built generated source, retrying while the
161/// graph is still priming (`Ok(None)`). Returns `None` on error or if the source
162/// never yields a frame.
163fn pull_first<S: GeneratedPull>(source: &mut S) -> Option<VideoFrame> {
164    for _ in 0..16 {
165        match source.pull() {
166            Ok(Some(frame)) => return Some(frame),
167            Ok(None) => {}
168            Err(_) => return None,
169        }
170    }
171    None
172}
173
174/// Shared `pull` shape of the generated frame sources so [`pull_first`] can retry
175/// either.
176trait GeneratedPull {
177    fn pull(&mut self) -> Result<Option<VideoFrame>, ff_filter::FilterError>;
178}
179impl GeneratedPull for SolidSource {
180    fn pull(&mut self) -> Result<Option<VideoFrame>, ff_filter::FilterError> {
181        SolidSource::pull(self)
182    }
183}
184impl GeneratedPull for TextSource {
185    fn pull(&mut self) -> Result<Option<VideoFrame>, ff_filter::FilterError> {
186        TextSource::pull(self)
187    }
188}
189
190// -- Constants --
191
192const CHANNEL_CAP: usize = 64;
193
194// ScenePlayer
195
196/// Thin builder for a ([`SceneRunner`], [`PlayerHandle`]) pair backed by a
197/// [`Scene`].
198///
199/// Playback is limited to the base video track (`video_tracks[0]`). When any
200/// placement carries an audio stream, an [`AudioMixer`] is created and audio is
201/// mixed into the stereo output from [`PlayerHandle::pop_audio_samples`].
202///
203/// This player is model-agnostic: an engine derives the [`Scene`] from its
204/// editing model and hands it here.
205pub struct ScenePlayer;
206
207impl ScenePlayer {
208    /// Open a [`Scene`] for real-time preview playback.
209    ///
210    /// Resolves the scene against the media (probing each placement's source for
211    /// duration, audio availability, and frame size), opens a [`DecodeBuffer`]
212    /// per base-track clip and seeks it to `in_point`, and builds the audio mixer
213    /// and tracks.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`PreviewError`] when:
218    /// - the scene has no video tracks or the base track is empty,
219    /// - a placement source file cannot be found or opened,
220    /// - a placement cannot be probed for duration.
221    #[allow(clippy::too_many_lines)]
222    pub fn open(scene: &Scene) -> Result<(SceneRunner, PlayerHandle), PreviewError> {
223        struct ProbeResult {
224            source: SceneSource,
225            in_pt: Duration,
226            clip_dur: Duration,
227            offset: Duration,
228            out_point: Option<Duration>,
229            xfade_dur: Duration,
230            xfade_kind: Option<XfadeTransition>,
231            video_handle: Duration,
232            has_audio: bool,
233            /// Video frame dimensions — used to pre-populate `last_frame_w/h` so the
234            /// gap-fill loop can synthesise black frames before the first real frame.
235            video_w: u32,
236            video_h: u32,
237            speed: f64,
238            opacity: f32,
239        }
240
241        let v_tracks = &scene.video_tracks;
242        if v_tracks.is_empty() || v_tracks[0].placements.is_empty() {
243            return Err(PreviewError::Ffmpeg {
244                code: 0,
245                message: "timeline has no video clips in the primary track".into(),
246            });
247        }
248
249        let fps = scene.fps.max(1.0);
250        // Canvas size for generated (solid/text) sources, which have no file to probe.
251        let canvas = resolve_canvas_dims(scene);
252        let clip_list = &v_tracks[0].placements;
253
254        // Phase 1: probe all clips
255
256        let mut probes: Vec<ProbeResult> = Vec::with_capacity(clip_list.len());
257        let mut has_any_audio = false;
258
259        for p in clip_list {
260            let in_pt = p.in_point;
261            let speed = p.speed;
262
263            // A file clip is probed; a generated (solid/text) clip is sized from the
264            // canvas, bounded by its `out_point`, and carries no audio.
265            let (video_w, video_h, unscaled_dur, has_audio) = if let Some(path) = p.source.as_file()
266            {
267                let info = ff_probe::open(path)?;
268                let dur = p.out_point.map_or_else(
269                    || info.duration().saturating_sub(in_pt),
270                    |op| op.saturating_sub(in_pt),
271                );
272                let (w, h) = info
273                    .primary_video()
274                    .map_or((0, 0), |v| (v.width(), v.height()));
275                (w, h, dur, info.has_audio())
276            } else {
277                (
278                    canvas.0,
279                    canvas.1,
280                    generated_span(p.out_point, in_pt),
281                    false,
282                )
283            };
284            let clip_dur = if (speed - 1.0).abs() < 1e-9 {
285                unscaled_dur
286            } else {
287                unscaled_dur.div_f64(speed)
288            };
289
290            has_any_audio |= has_audio;
291
292            probes.push(ProbeResult {
293                source: p.source.clone(),
294                in_pt,
295                clip_dur,
296                offset: p.offset,
297                out_point: p.out_point,
298                xfade_dur: p.xfade_dur,
299                xfade_kind: p.xfade_kind,
300                video_handle: p.video_handle,
301                has_audio,
302                video_w,
303                video_h,
304                speed,
305                opacity: p.opacity,
306            });
307        }
308
309        // Phase 2: build mixer and track handles (if audio present)
310
311        let (mut mixer_arc, audio_track_handles): (
312            Option<Arc<Mutex<AudioMixer>>>,
313            Vec<Option<AudioTrackHandle>>,
314        ) = if has_any_audio {
315            let mut mixer = AudioMixer::new(48_000);
316            let handles: Vec<Option<AudioTrackHandle>> = probes
317                .iter()
318                .map(|p| {
319                    if p.has_audio {
320                        Some(mixer.add_track())
321                    } else {
322                        None
323                    }
324                })
325                .collect();
326            (Some(Arc::new(Mutex::new(mixer))), handles)
327        } else {
328            (None, probes.iter().map(|_| None).collect())
329        };
330
331        // Phase 3: build ClipState objects
332
333        let mut clip_states: Vec<ClipState> = Vec::with_capacity(probes.len());
334        for (i, p) in probes.iter().enumerate() {
335            let timeline_start = p.offset;
336            let timeline_end = timeline_start + p.clip_dur;
337
338            let decode_buf = open_clip_video_source(&p.source, p.in_pt, p.video_w, p.video_h, fps)?;
339
340            // Apply a static V1 audio gain once at open; an animated gain is driven
341            // per-tick by the runner.
342            if let (Some(handle), AnimatedValue::Static(db)) =
343                (&audio_track_handles[i], &clip_list[i].volume)
344                && *db != 0.0
345            {
346                handle.set_volume(db_to_linear(*db));
347            }
348            // Apply pan once at open at its `t=0` value (an animated pan uses its
349            // initial value, matching the export mixer).
350            if let Some(handle) = &audio_track_handles[i] {
351                let pan0 = clip_list[i].pan.value_at(Duration::ZERO);
352                if pan0 != 0.0 {
353                    // `set_pan` clamps to [-1.0, 1.0], so the f32 narrowing is safe.
354                    #[allow(clippy::cast_possible_truncation)]
355                    handle.set_pan(pan0 as f32);
356                }
357            }
358            clip_states.push(ClipState {
359                source: p.source.clone(),
360                decode_buf,
361                timeline_start,
362                timeline_end,
363                in_point: p.in_pt,
364                out_point: p.out_point,
365                xfade_dur: p.xfade_dur,
366                xfade_kind: p.xfade_kind,
367                video_handle: p.video_handle,
368                audio_track: audio_track_handles[i].clone(),
369                speed: p.speed,
370                opacity: p.opacity,
371                layer_desc: clip_list[i].layer.clone(),
372                volume: clip_list[i].volume.clone(),
373                fade_in: clip_list[i].fade_in,
374                fade_out: clip_list[i].fade_out,
375                pitch: clip_list[i].pitch,
376            });
377        }
378
379        // Phase 4: build overlay layers (V2, V3, …)
380        // Audio from V2+ clips is routed through AudioOnlyTrack (same mechanism as
381        // A1) so it is started/stopped as the playhead crosses each clip window.
382
383        let mut audio_only_tracks: Vec<AudioOnlyTrack> = Vec::new();
384
385        let mut overlay_layers: Vec<OverlayLayer> = Vec::new();
386        for layer in v_tracks.iter().skip(1) {
387            if layer.placements.is_empty() {
388                continue;
389            }
390            let mut layer_clips: Vec<ClipState> = Vec::new();
391            for p in &layer.placements {
392                let in_pt = p.in_point;
393                // File clip: probe. Generated (solid/text) clip: canvas-sized, bounded
394                // by `out_point`, no audio.
395                let (clip_dur, has_audio) = match p.source.as_file() {
396                    Some(path) => {
397                        let info = ff_probe::open(path)?;
398                        let dur = p.out_point.map_or_else(
399                            || info.duration().saturating_sub(in_pt),
400                            |op| op.saturating_sub(in_pt),
401                        );
402                        (dur, info.has_audio())
403                    }
404                    None => (generated_span(p.out_point, in_pt), false),
405                };
406                let timeline_start = p.offset;
407                let timeline_end = timeline_start + clip_dur;
408                let decode_buf = open_clip_video_source(&p.source, in_pt, canvas.0, canvas.1, fps)?;
409                if has_audio {
410                    let mixer_ref = mixer_arc
411                        .get_or_insert_with(|| Arc::new(Mutex::new(AudioMixer::new(48_000))));
412                    let handle = mixer_ref
413                        .lock()
414                        .unwrap_or_else(std::sync::PoisonError::into_inner)
415                        .add_track();
416                    if let AnimatedValue::Static(db) = &p.volume
417                        && *db != 0.0
418                    {
419                        handle.set_volume(db_to_linear(*db));
420                    }
421                    // Apply pan once at open at its `t=0` value (an animated pan uses
422                    // its initial value, matching the export mixer).
423                    let pan0 = p.pan.value_at(Duration::ZERO);
424                    if pan0 != 0.0 {
425                        // `set_pan` clamps to [-1.0, 1.0], so the f32 narrowing is safe.
426                        #[allow(clippy::cast_possible_truncation)]
427                        handle.set_pan(pan0 as f32);
428                    }
429                    audio_only_tracks.push(AudioOnlyTrack {
430                        source: source_path(&p.source),
431                        timeline_start,
432                        timeline_end,
433                        in_point: in_pt,
434                        fade_in: p.fade_in,
435                        fade_out: p.fade_out,
436                        clip_dur,
437                        speed: p.speed,
438                        pitch: p.pitch,
439                        handle,
440                        volume: p.volume.clone(),
441                        cancel: None,
442                        thread: None,
443                    });
444                }
445                layer_clips.push(ClipState {
446                    source: p.source.clone(),
447                    decode_buf,
448                    timeline_start,
449                    timeline_end,
450                    in_point: in_pt,
451                    out_point: p.out_point,
452                    xfade_dur: Duration::ZERO,
453                    xfade_kind: None,
454                    // Overlays carry no transition, so there is nothing to feed.
455                    video_handle: Duration::ZERO,
456                    audio_track: None,
457                    speed: p.speed,
458                    opacity: p.opacity,
459                    layer_desc: p.layer.clone(),
460                    volume: p.volume.clone(),
461                    fade_in: p.fade_in,
462                    fade_out: p.fade_out,
463                    pitch: p.pitch,
464                });
465            }
466            overlay_layers.push(OverlayLayer {
467                clips: layer_clips,
468                active: 0,
469                sws: SwsRgbaConverter::new(),
470                rgba: Vec::new(),
471                cur_dims: None,
472                pending: None,
473            });
474        }
475
476        // Phase 5: build audio-only tracks (A1, A2, …)
477
478        for track in &scene.audio_tracks {
479            for p in &track.placements {
480                let in_pt = p.in_point;
481                let info = ff_probe::open(&p.source)?;
482                if !info.has_audio() {
483                    continue;
484                }
485                let clip_dur = p.out_point.map_or_else(
486                    || info.duration().saturating_sub(in_pt),
487                    |op| op.saturating_sub(in_pt),
488                );
489                let timeline_start = p.offset;
490                let timeline_end = timeline_start + clip_dur;
491                // Lazily create the mixer if no V1 clip had audio.
492                let mixer_ref =
493                    mixer_arc.get_or_insert_with(|| Arc::new(Mutex::new(AudioMixer::new(48_000))));
494                let handle = mixer_ref
495                    .lock()
496                    .unwrap_or_else(std::sync::PoisonError::into_inner)
497                    .add_track();
498                // Apply a static gain once at open; an animated gain (a track) is driven
499                // per-tick by the runner.
500                if let AnimatedValue::Static(db) = &p.volume
501                    && *db != 0.0
502                {
503                    handle.set_volume(db_to_linear(*db));
504                }
505                // Apply pan once at open at its `t=0` value (an animated pan uses its
506                // initial value, matching the export mixer).
507                let pan0 = p.pan.value_at(Duration::ZERO);
508                if pan0 != 0.0 {
509                    // `set_pan` clamps to [-1.0, 1.0], so the f32 narrowing is safe.
510                    #[allow(clippy::cast_possible_truncation)]
511                    handle.set_pan(pan0 as f32);
512                }
513                audio_only_tracks.push(AudioOnlyTrack {
514                    source: p.source.clone(),
515                    timeline_start,
516                    timeline_end,
517                    in_point: in_pt,
518                    fade_in: p.fade_in,
519                    fade_out: p.fade_out,
520                    clip_dur,
521                    speed: p.speed,
522                    pitch: p.pitch,
523                    handle,
524                    volume: p.volume.clone(),
525                    cancel: None,
526                    thread: None,
527                });
528            }
529        }
530
531        // Compute total duration
532
533        let total_dur = clip_states
534            .iter()
535            .map(|c| c.timeline_end)
536            .max()
537            .unwrap_or(Duration::ZERO);
538        let duration_millis = u64::try_from(total_dur.as_millis()).unwrap_or(u64::MAX);
539
540        // Build runner and handle
541
542        let current_pts = Arc::new(AtomicU64::new(0));
543        let paused = Arc::new(AtomicBool::new(false));
544        let stopped = Arc::new(AtomicBool::new(false));
545        let (cmd_tx, cmd_rx) = mpsc::sync_channel(CHANNEL_CAP);
546        let (event_tx, event_rx) = mpsc::sync_channel::<PlayerEvent>(CHANNEL_CAP);
547
548        // Only start the audio thread for the first V1 clip immediately when that
549        // clip begins at timeline position 0.  When there is a pre-roll gap the
550        // gap-fill loop starts the audio at the correct timeline position instead.
551        let first_clip_at_origin = clip_states
552            .first()
553            .is_some_and(|c| c.timeline_start == Duration::ZERO);
554        let (initial_audio_cancel, initial_audio_thread) = if first_clip_at_origin {
555            if let Some(handle) = clip_states.first().and_then(|c| c.audio_track.clone()) {
556                // The first V1 clip has audio, so it is a file source; derive its path.
557                let source = source_path(&clip_states[0].source);
558                let in_pt = clip_states[0].in_point;
559                let clip0_speed = clip_states[0].speed;
560                let clip0_pitch = clip_states[0].pitch;
561                let cancel = Arc::new(AtomicBool::new(false));
562                let thread = spawn_audio_track_thread(
563                    source,
564                    in_pt,
565                    handle,
566                    Arc::clone(&cancel),
567                    AudioFadeConfig {
568                        speed: clip0_speed,
569                        pitch: clip0_pitch,
570                        ..AudioFadeConfig::NONE
571                    },
572                );
573                (Some(cancel), Some(thread))
574            } else {
575                (None, None)
576            }
577        } else {
578            (None, None)
579        };
580
581        // Pre-populate frame dimensions from the first clip's probe so the gap-fill
582        // loop can synthesise black frames even before the first real frame arrives.
583        let (initial_last_w, initial_last_h) =
584            probes.first().map_or((0, 0), |p| (p.video_w, p.video_h));
585
586        let runner = SceneRunner {
587            clips: clip_states,
588            overlay_layers,
589            audio_only_tracks,
590            active: 0,
591            transition: None,
592            cmd_rx,
593            event_tx,
594            sink: None,
595            gpu_compositor: None,
596            current_pts: Arc::clone(&current_pts),
597            paused: Arc::clone(&paused),
598            stopped: Arc::clone(&stopped),
599            fps,
600            rate: 1.0,
601            clock: MasterClock::System {
602                started_at: Instant::now(),
603                base_pts: Duration::ZERO,
604                rate: 1.0,
605            },
606            resume_pts: Duration::ZERO,
607            sws_a: SwsRgbaConverter::new(),
608            sws_b: SwsRgbaConverter::new(),
609            rgba_a: Vec::new(),
610            rgba_b: Vec::new(),
611            blend_buf: Vec::new(),
612            dissolve_field: Vec::new(),
613            dissolve_field_dims: (0, 0),
614            last_frame_w: initial_last_w,
615            last_frame_h: initial_last_h,
616            gap_buf: Vec::new(),
617            audio_mixer: mixer_arc.clone(),
618            active_audio_cancel: initial_audio_cancel,
619            active_audio_thread: initial_audio_thread,
620            composer: None,
621            composer_key: Vec::new(),
622            canvas: scene.canvas,
623            lavfi: scene
624                .lavfi_overlay
625                .as_deref()
626                .and_then(LavfiOverlayState::new),
627        };
628
629        let handle = PlayerHandle::for_timeline(
630            cmd_tx,
631            Arc::new(Mutex::new(event_rx)),
632            current_pts,
633            paused,
634            stopped,
635            duration_millis,
636            mixer_arc,
637        );
638
639        Ok((runner, handle))
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    #[test]
648    fn resolve_canvas_dims_should_prefer_explicit_canvas_then_fall_back() {
649        let scene = |canvas| Scene {
650            fps: 30.0,
651            canvas,
652            lavfi_overlay: None,
653            video_tracks: vec![],
654            audio_tracks: vec![],
655        };
656        // An explicit canvas wins.
657        assert_eq!(
658            resolve_canvas_dims(&scene(Some((1920, 1080)))),
659            (1920, 1080)
660        );
661        // No explicit canvas and no file placement to size from -> (0, 0), so a
662        // generated source renders nothing rather than guessing.
663        assert_eq!(resolve_canvas_dims(&scene(None)), (0, 0));
664    }
665
666    #[test]
667    #[ignore = "requires the color/drawtext filters; run with -- --include-ignored"]
668    fn preview_should_render_text_and_solid_sources() {
669        use ff_format::{Color, TextSpec};
670
671        // #1615: a generated source's held frame is built via `SolidSource` /
672        // `TextSource` — the same `color` / `drawtext` filters export uses — so preview
673        // matches export. Probe-gated (RK-002): the filters are absent on a minimal
674        // FFmpeg, so `generated_held_frame` returns `None` and the test skips.
675        let red = Color::rgb(200, 30, 40);
676        let Some(frame) = generated_held_frame(&SceneSource::Solid(red), 16, 16, 30.0) else {
677            println!("Skipping: color filter unavailable");
678            return;
679        };
680        assert_eq!((frame.width(), frame.height()), (16, 16));
681        let Some(plane) = frame.plane(0) else {
682            println!("Skipping: no rgba plane");
683            return;
684        };
685        let stride = frame.stride(0).unwrap_or(16 * 4);
686        // Centre pixel (8, 8) in the rgba plane must be ~red (non-vacuous: an empty
687        // path would render nothing).
688        let off = 8 * stride + 8 * 4;
689        let (r, g, b) = (plane[off], plane[off + 1], plane[off + 2]);
690        assert!(
691            r.abs_diff(200) <= 6 && g.abs_diff(30) <= 6 && b.abs_diff(40) <= 6,
692            "solid centre pixel must be ~red, got ({r}, {g}, {b})"
693        );
694
695        // Text: the `color`→`drawtext` path must at least produce a canvas-sized frame
696        // where the drawtext filter is available.
697        if let Some(tf) =
698            generated_held_frame(&SceneSource::Text(TextSpec::new("Hi")), 64, 32, 30.0)
699        {
700            assert_eq!((tf.width(), tf.height()), (64, 32));
701        } else {
702            println!("Skipping text: drawtext filter unavailable");
703        }
704    }
705
706    // blend_rgba delegate
707
708    #[test]
709    fn inner_blend_rgba_at_zero_alpha_should_return_a() {
710        let a = vec![255u8, 0, 0, 255];
711        let b = vec![0u8, 0, 255, 255];
712        let mut dst = Vec::new();
713        inner::blend_rgba(&a, &b, 0.0, &mut dst);
714        assert_eq!(dst, a);
715    }
716
717    // open
718
719    #[test]
720    fn timeline_player_open_should_fail_when_no_video_tracks() {
721        let _ = PreviewError::SeekOutOfRange {
722            pts: Duration::from_secs(1),
723        };
724    }
725}