Skip to main content

falcon_video/
lib.rs

1//! Video for falcon's viewer. Files play through the operating system's
2//! own media framework, AVFoundation on macOS (H.264, HEVC, ProRes, … in
3//! mp4, mov and m4v). Whatever it refuses (Matroska, WebM, AVI, WMV, FLV,
4//! Ogg, MPEG-TS, …) falls back to FFmpeg through `falcon-ffmpeg`, which
5//! loads an LGPL FFmpeg at run time when one is present, as Eagle falls
6//! back from Chromium's player to its bundled one. One [`Video`] drives
7//! the same viewer either way.
8//!
9//! A [`Video`] on the system player also moves to FFmpeg, once, when the
10//! system player fails after opening, or when it has drawn no picture within
11//! 1.5 s of opening: Eagle gives its `<video>` element the same time. The
12//! system player draws a poster picture while paused, so a paused clip
13//! counts as drawn. A slow disk gets one switch: FFmpeg is not left again,
14//! and if it can't open the file the system player stays and keeps its time.
15//!
16//! - [`probe`] reads a file's duration, display size and audio track.
17//! - [`frame_at`] decodes the frame at a time, on any thread.
18//! - [`Grabber`] decodes small pictures of one file again and again, for
19//!   previews such as the seek bar's hover thumbnail.
20//! - [`Video`] plays a file with sound and hands out each new frame as RGBA
21//!   pixels for the caller to upload as a texture. It lives on the main
22//!   thread, as AVFoundation's player requires.
23//! - [`Prepared`] readies a small [`Video`] off the main thread, for the
24//!   grid's hover previews: its frames fit a given side, and nothing about
25//!   opening or dropping it waits on the file.
26//!
27//! All Objective-C interop sits in the private `mac` module; this file and
28//! `pixels` are plain Rust.
29// Code only macOS calls so far; macOS builds keep the full check.
30#![cfg_attr(not(target_os = "macos"), allow(dead_code))]
31
32use std::cell::RefCell;
33use std::num::NonZero;
34use std::path::{Path, PathBuf};
35use std::sync::mpsc;
36use std::time::{Duration, Instant};
37
38mod pixels;
39
40#[cfg(target_os = "macos")]
41mod mac;
42#[cfg(target_os = "macos")]
43use mac as sys;
44
45#[cfg(not(target_os = "macos"))]
46mod other;
47#[cfg(not(target_os = "macos"))]
48use other as sys;
49
50pub use falcon_ffmpeg::Info;
51pub use pixels::{Frame, quarter_turns};
52
53/// Why a file could not be opened, probed or decoded.
54#[derive(Clone, Debug, PartialEq)]
55pub struct Error(pub String);
56
57impl std::fmt::Display for Error {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.write_str(&self.0)
60    }
61}
62
63impl std::error::Error for Error {}
64
65impl From<falcon_ffmpeg::Error> for Error {
66    fn from(e: falcon_ffmpeg::Error) -> Self {
67        Error(e.0)
68    }
69}
70
71pub type Result<T> = std::result::Result<T, Error>;
72
73/// Playback speeds, slowest first: the steps the speed keys move through.
74pub const RATES: [f32; 11] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0, 4.0, 8.0];
75
76/// The next speed up (or down) from `rate`; the ends stay put. A rate not
77/// in [`RATES`] moves to the nearest step in that direction.
78pub fn step_rate(rate: f32, up: bool) -> f32 {
79    if up {
80        RATES.iter().copied().find(|&r| r > rate + 1e-4).unwrap_or(rate)
81    } else {
82        RATES.iter().rev().copied().find(|&r| r < rate - 1e-4).unwrap_or(rate)
83    }
84}
85
86/// Which decoder plays a file.
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub enum Backend {
89    /// The operating system's (AVFoundation).
90    System,
91    /// FFmpeg, loaded at run time.
92    Ffmpeg,
93}
94
95/// Whether the FFmpeg fallback can be used.
96pub fn ffmpeg_available() -> bool {
97    falcon_ffmpeg::available()
98}
99
100/// The FFmpeg fallback's version and where it was loaded from, or why it
101/// can't be used.
102pub fn ffmpeg_status() -> std::result::Result<(String, PathBuf), String> {
103    falcon_ffmpeg::status().map_err(|e| e.to_string())
104}
105
106/// Whether files with extension `ext` need the FFmpeg fallback: formats
107/// macOS can't play, such as Matroska, WebM, AVI, WMV and FLV.
108pub fn needs_ffmpeg(ext: &str) -> bool {
109    falcon_ffmpeg::handles(ext)
110}
111
112/// How long the system player has to draw its first picture.
113const NO_FRAME_LIMIT: Duration = Duration::from_millis(1500);
114
115/// Whether a system player opened `elapsed` ago should give way to FFmpeg
116/// for having drawn nothing. Once it has drawn a picture (a paused player's
117/// poster counts) it never does, however long it then takes.
118fn switch_for_no_frame(elapsed: Duration, had_frame: bool, ffmpeg: bool) -> bool {
119    ffmpeg && !had_frame && elapsed >= NO_FRAME_LIMIT
120}
121
122/// Runs `system`, and when it fails and FFmpeg is present, `ffmpeg`. The
123/// system's error is kept unless FFmpeg was tried.
124fn either<T>(system: impl FnOnce() -> Result<T>, ffmpeg: impl FnOnce() -> falcon_ffmpeg::Result<T>) -> Result<T> {
125    match system() {
126        Ok(v) => Ok(v),
127        Err(e) if !ffmpeg_available() => Err(e),
128        Err(_) => ffmpeg().map_err(Error::from),
129    }
130}
131
132/// Reads what the viewer needs before playing. Fails for files with no
133/// video track the system or FFmpeg can play.
134pub fn probe(path: &Path) -> Result<Info> {
135    either(|| sys::probe(path), || falcon_ffmpeg::probe(path))
136}
137
138/// The picture at `secs`, turned the way the track asks. Safe to call
139/// from any thread.
140pub fn frame_at(path: &Path, secs: f64) -> Result<Frame> {
141    either(|| sys::frame_at(path, secs), || falcon_ffmpeg::frame_at(path, secs))
142}
143
144/// Decodes small pictures of one file quickly, for previews such as the
145/// seek bar's hover thumbnail, with pictures no larger than `max_side` on
146/// their long side.
147///
148/// Usable from any thread, but not `Send`: open it on the thread that uses it.
149pub struct Grabber {
150    grab: RefCell<Grab>,
151    path: std::path::PathBuf,
152    max_side: u32,
153}
154
155enum Grab {
156    System(sys::Grabber),
157    Ffmpeg(falcon_ffmpeg::Grabber),
158}
159
160impl Grabber {
161    /// Opens `path` for grabbing; fails for anything [`probe`] refuses.
162    pub fn open(path: &Path, max_side: u32) -> Result<Grabber> {
163        let grab = either(
164            || sys::Grabber::open(path, max_side).map(Grab::System),
165            || falcon_ffmpeg::Grabber::open(path, max_side).map(Grab::Ffmpeg),
166        )?;
167        Ok(Grabber { grab: RefCell::new(grab), path: path.to_path_buf(), max_side })
168    }
169
170    /// The picture at `secs`, scaled down to fit the size given to `open`.
171    /// When the system opened the file but can't decode its pictures (AV1
172    /// in an MP4 on a Mac without an AV1 decoder, say), FFmpeg takes over
173    /// for this and every later picture.
174    pub fn grab(&self, secs: f64) -> Result<Frame> {
175        let mut grab = self.grab.borrow_mut();
176        match &mut *grab {
177            Grab::Ffmpeg(g) => g.grab(secs).map_err(Error::from),
178            Grab::System(g) => match g.grab(secs) {
179                Ok(f) => Ok(f),
180                Err(e) if !ffmpeg_available() => Err(e),
181                Err(e) => {
182                    let Ok(mut g) = falcon_ffmpeg::Grabber::open(&self.path, self.max_side) else { return Err(e) };
183                    let f = g.grab(secs).map_err(Error::from);
184                    *grab = Grab::Ffmpeg(g);
185                    f
186                }
187            },
188        }
189    }
190}
191
192/// The FFmpeg player's sound as a rodio source: stereo `f32` at 48 kHz,
193/// silent while paused, ending when the player is dropped.
194struct Sound(falcon_ffmpeg::AudioOut);
195
196impl Iterator for Sound {
197    type Item = rodio::Sample;
198
199    fn next(&mut self) -> Option<rodio::Sample> {
200        self.0.next_sample().map(|s| s as rodio::Sample)
201    }
202}
203
204impl rodio::Source for Sound {
205    fn current_span_len(&self) -> Option<usize> {
206        None
207    }
208
209    fn channels(&self) -> rodio::ChannelCount {
210        NonZero::new(falcon_ffmpeg::AudioOut::CHANNELS).unwrap_or(NonZero::<u16>::MIN)
211    }
212
213    fn sample_rate(&self) -> rodio::SampleRate {
214        NonZero::new(falcon_ffmpeg::AudioOut::RATE).unwrap_or(NonZero::<u32>::MIN)
215    }
216
217    fn total_duration(&self) -> Option<std::time::Duration> {
218        None
219    }
220}
221
222/// What opening FFmpeg's player gives: the player and its sound.
223type Opened = falcon_ffmpeg::Result<(falcon_ffmpeg::Player, Option<falcon_ffmpeg::AudioOut>)>;
224
225// Lives a moment and is moved once; only Windows' audio types tip it over.
226#[cfg_attr(windows, allow(clippy::large_enum_variant))]
227enum Readied {
228    System(sys::Prepared),
229    Ffmpeg(falcon_ffmpeg::Player, Option<falcon_ffmpeg::AudioOut>),
230}
231
232/// A preview read and ready to play, as [`Video::start`] of what any thread
233/// prepared: everything that waits on the file (AVFoundation reading its
234/// header, FFmpeg probing its streams) has been done by then.
235pub struct Prepared {
236    readied: Readied,
237    path: PathBuf,
238    max_side: u32,
239}
240
241impl Prepared {
242    /// Reads `path` for a preview whose pictures fit `max_side` on their
243    /// long side, on any thread. Fails for anything [`probe`] refuses.
244    pub fn new(path: &Path, max_side: u32) -> Result<Prepared> {
245        let readied = match sys::Prepared::new(path) {
246            Ok(p) => Readied::System(p),
247            Err(e) if !ffmpeg_available() => return Err(e),
248            Err(_) => {
249                let (player, sound) = falcon_ffmpeg::Player::open_preview(path, true, max_side)?;
250                Readied::Ffmpeg(player, sound)
251            }
252        };
253        Ok(Prepared { readied, path: path.to_path_buf(), max_side })
254    }
255
256    /// The player, paused at the start. Main thread only. FFmpeg's sound
257    /// stays silent until [`Video::set_mixer`] gives it an output, so a
258    /// muted preview needs none.
259    pub fn start(self) -> Result<Video> {
260        if !sys::on_main_thread() {
261            return Err(Error("video playback runs on the main thread".into()));
262        }
263        let (inner, held_sound) = match self.readied {
264            Readied::System(p) => (Inner::System(sys::Video::start(p, Some(self.max_side))?), None),
265            Readied::Ffmpeg(p, sound) => (Inner::Ffmpeg(p), sound),
266        };
267        let mut video = Video {
268            inner,
269            path: self.path,
270            mixer: None,
271            volume: 1.0,
272            muted: false,
273            fallback_error: None,
274            opened: Instant::now(),
275            had_frame: false,
276            held_frame: None,
277            preview_side: Some(self.max_side),
278            held_sound,
279            opening: None,
280        };
281        video.apply_mute();
282        Ok(video)
283    }
284}
285
286enum Inner {
287    System(sys::Video),
288    Ffmpeg(falcon_ffmpeg::Player),
289}
290
291/// A playing video, through whichever back end can play it. AVFoundation
292/// is tried first; a file it refuses on opening, fails on later, or draws no
293/// picture of within 1.5 s is played by FFmpeg when present, keeping the
294/// player's settings and place.
295///
296/// Main thread only (it is not `Send`). Dropping it stops playback.
297pub struct Video {
298    inner: Inner,
299    path: PathBuf,
300    /// The output the FFmpeg player's sound goes to.
301    mixer: Option<rodio::mixer::Mixer>,
302    volume: f32,
303    muted: bool,
304    /// Why FFmpeg could not take over from the system player, once tried.
305    fallback_error: Option<String>,
306    /// When the system player was opened, for [`NO_FRAME_LIMIT`].
307    opened: Instant,
308    /// The system player has handed out a picture.
309    had_frame: bool,
310    /// A picture taken while checking for one, for the next [`Video::frame`].
311    held_frame: Option<Frame>,
312    /// The side a preview's pictures fit: such a player's move to FFmpeg
313    /// keeps that size and opens off the main thread.
314    preview_side: Option<u32>,
315    /// A preview's FFmpeg sound while it has no output (its player is muted).
316    held_sound: Option<falcon_ffmpeg::AudioOut>,
317    /// FFmpeg's player being opened for a preview.
318    opening: Option<mpsc::Receiver<Opened>>,
319}
320
321impl Video {
322    /// Opens a file for playback, paused at the start, with no sound when
323    /// FFmpeg plays it. Fails off the main thread, and for anything
324    /// [`probe`] refuses.
325    pub fn open(path: &Path) -> Result<Video> {
326        Video::open_with(path, None)
327    }
328
329    /// As [`Video::open`]; when FFmpeg plays the file its sound goes to
330    /// `mixer` (AVFoundation plays its own).
331    pub fn open_with(path: &Path, mixer: Option<&rodio::mixer::Mixer>) -> Result<Video> {
332        if !sys::on_main_thread() {
333            return Err(Error("video playback runs on the main thread".into()));
334        }
335        let mixer = mixer.cloned();
336        let inner = match sys::Video::open(path) {
337            Ok(v) => Inner::System(v),
338            Err(e) if !ffmpeg_available() => return Err(e),
339            Err(_) => Inner::Ffmpeg(open_ffmpeg(path, mixer.as_ref())?),
340        };
341        Ok(Video { inner, path: path.to_path_buf(), mixer, volume: 1.0, muted: false, fallback_error: None, opened: Instant::now(), had_frame: false, held_frame: None, preview_side: None, held_sound: None, opening: None })
342    }
343
344    /// Which back end is playing.
345    pub fn backend(&self) -> Backend {
346        match self.inner {
347            Inner::System(_) => Backend::System,
348            Inner::Ffmpeg(_) => Backend::Ffmpeg,
349        }
350    }
351
352    pub fn info(&self) -> &Info {
353        match &self.inner {
354            Inner::System(v) => v.info(),
355            Inner::Ffmpeg(p) => p.info(),
356        }
357    }
358
359    /// The video has loaded and can show frames.
360    pub fn is_ready(&self) -> bool {
361        match &self.inner {
362            Inner::System(v) => v.is_ready(),
363            Inner::Ffmpeg(p) => p.is_ready(),
364        }
365    }
366
367    /// Why the video can't play, once every back end has given up. A system
368    /// failure FFmpeg may still recover from (on the next [`Video::tick`])
369    /// is not reported.
370    pub fn error(&self) -> Option<String> {
371        match &self.inner {
372            Inner::System(v) => v.error().filter(|_| self.fallback_error.is_some() || !ffmpeg_available()),
373            Inner::Ffmpeg(_) => None,
374        }
375    }
376
377    /// Plays at the current speed; from the end, plays from the start.
378    pub fn play(&mut self) {
379        match &mut self.inner {
380            Inner::System(v) => v.play(),
381            Inner::Ffmpeg(p) => p.play(),
382        }
383    }
384
385    pub fn pause(&mut self) {
386        match &mut self.inner {
387            Inner::System(v) => v.pause(),
388            Inner::Ffmpeg(p) => p.pause(),
389        }
390    }
391
392    pub fn toggle(&mut self) {
393        match &mut self.inner {
394            Inner::System(v) => v.toggle(),
395            Inner::Ffmpeg(p) => p.toggle(),
396        }
397    }
398
399    pub fn is_playing(&self) -> bool {
400        match &self.inner {
401            Inner::System(v) => v.is_playing(),
402            Inner::Ffmpeg(p) => p.is_playing(),
403        }
404    }
405
406    /// Jumps to `secs` exactly (clamped to the clip).
407    pub fn seek(&mut self, secs: f64) {
408        match &mut self.inner {
409            Inner::System(v) => v.seek(secs),
410            Inner::Ffmpeg(p) => p.seek(secs),
411        }
412    }
413
414    /// Moves `frames` frames forwards (or backwards when negative) and
415    /// pauses, as frame stepping does in any player.
416    pub fn step(&mut self, frames: i32) {
417        match &mut self.inner {
418            Inner::System(v) => v.step(frames),
419            Inner::Ffmpeg(p) => p.step(frames),
420        }
421    }
422
423    pub fn rate(&self) -> f32 {
424        match &self.inner {
425            Inner::System(v) => v.rate(),
426            Inner::Ffmpeg(p) => p.rate(),
427        }
428    }
429
430    pub fn set_rate(&mut self, rate: f32) {
431        let rate = rate.clamp(RATES[0], RATES[RATES.len() - 1]);
432        match &mut self.inner {
433            Inner::System(v) => v.set_rate(rate),
434            Inner::Ffmpeg(p) => p.set_rate(rate),
435        }
436    }
437
438    /// 0..=1.
439    pub fn set_volume(&mut self, volume: f32) {
440        self.volume = volume.clamp(0.0, 1.0);
441        match &mut self.inner {
442            Inner::System(v) => v.set_volume(self.volume),
443            Inner::Ffmpeg(p) => p.set_volume(self.volume),
444        }
445    }
446
447    pub fn set_muted(&mut self, muted: bool) {
448        self.muted = muted;
449        self.apply_mute();
450    }
451
452    /// Mutes the player as asked, and FFmpeg's while its sound has no output
453    /// to go to (its clock would wait for one).
454    fn apply_mute(&mut self) {
455        match &mut self.inner {
456            Inner::System(v) => v.set_muted(self.muted),
457            Inner::Ffmpeg(p) => p.set_muted(self.muted || self.held_sound.is_some()),
458        }
459    }
460
461    /// Whether this preview has yet to be given an output by [`Video::set_mixer`].
462    pub fn needs_mixer(&self) -> bool {
463        self.preview_side.is_some() && self.mixer.is_none()
464    }
465
466    /// Gives a preview the output its FFmpeg sound goes to, whichever
467    /// player has it now or takes over later.
468    pub fn set_mixer(&mut self, mixer: rodio::mixer::Mixer) {
469        if let Some(s) = self.held_sound.take() {
470            mixer.add(Sound(s));
471        }
472        self.mixer = Some(mixer);
473        self.apply_mute();
474    }
475
476    pub fn set_looping(&mut self, looping: bool) {
477        match &mut self.inner {
478            Inner::System(v) => v.set_looping(looping),
479            Inner::Ffmpeg(p) => p.set_looping(looping),
480        }
481    }
482
483    pub fn looping(&self) -> bool {
484        match &self.inner {
485            Inner::System(v) => v.looping(),
486            Inner::Ffmpeg(p) => p.looping(),
487        }
488    }
489
490    /// The playhead in seconds.
491    pub fn current_time(&self) -> f64 {
492        match &self.inner {
493            Inner::System(v) => v.current_time(),
494            Inner::Ffmpeg(p) => p.current_time(),
495        }
496    }
497
498    /// Call every frame: at the end of the clip, starts again from the top
499    /// when looping, or stops. When the system player has failed or has
500    /// drawn nothing in time, FFmpeg takes over here.
501    pub fn tick(&mut self) {
502        self.adopt_opened();
503        if let Inner::System(v) = &self.inner
504            && v.error().is_some()
505            && self.fallback_error.is_none()
506            && self.opening.is_none()
507            && ffmpeg_available()
508        {
509            self.fall_back();
510        }
511        self.check_first_frame();
512        match &mut self.inner {
513            Inner::System(v) => v.tick(),
514            Inner::Ffmpeg(p) => p.tick(),
515        }
516    }
517
518    /// Applies [`switch_for_no_frame`]. A picture that arrived since the last
519    /// [`Video::frame`] (a busy main thread can be that late) is kept, not
520    /// taken for a player that drew nothing.
521    fn check_first_frame(&mut self) {
522        let Inner::System(v) = &mut self.inner else { return };
523        if self.fallback_error.is_some() || self.opening.is_some() || !switch_for_no_frame(self.opened.elapsed(), self.had_frame, ffmpeg_available()) {
524            return;
525        }
526        if let Some(f) = v.frame() {
527            self.had_frame = true;
528            self.held_frame = Some(f);
529            return;
530        }
531        self.fall_back();
532    }
533
534    /// Makes the system player's time for its first picture run out now,
535    /// so tests needn't find a file it never draws.
536    #[doc(hidden)]
537    pub fn expire_first_frame_time(&mut self) {
538        self.opened = Instant::now().checked_sub(NO_FRAME_LIMIT).unwrap_or(self.opened);
539    }
540
541    /// Replaces the failed system player with FFmpeg's, carrying over the
542    /// speed, volume, mute, looping, place and whether it was playing. A
543    /// preview opens it on a worker and goes on until [`Video::tick`] finds
544    /// it ready.
545    fn fall_back(&mut self) {
546        if let Some(side) = self.preview_side {
547            let (tx, rx) = mpsc::channel();
548            let path = self.path.clone();
549            std::thread::spawn(move || {
550                let _ = tx.send(falcon_ffmpeg::Player::open_preview(&path, true, side));
551            });
552            self.opening = Some(rx);
553            return;
554        }
555        match open_ffmpeg(&self.path, self.mixer.as_ref()) {
556            Ok(p) => self.take_over(p),
557            Err(e) => self.fallback_error = Some(e.0),
558        }
559    }
560
561    /// Takes the player a worker opened for a preview, if it is ready.
562    fn adopt_opened(&mut self) {
563        let Some(rx) = &self.opening else { return };
564        let opened = match rx.try_recv() {
565            Ok(r) => r,
566            Err(mpsc::TryRecvError::Empty) => return,
567            Err(mpsc::TryRecvError::Disconnected) => Err(falcon_ffmpeg::Error("the FFmpeg worker stopped".into())),
568        };
569        self.opening = None;
570        match opened {
571            Ok((p, sound)) => {
572                match (&self.mixer, sound) {
573                    (Some(m), Some(s)) => m.add(Sound(s)),
574                    (None, s) => self.held_sound = s,
575                    (Some(_), None) => {}
576                }
577                self.take_over(p);
578            }
579            Err(e) => self.fallback_error = Some(e.0),
580        }
581    }
582
583    /// Puts FFmpeg's player in the system player's place, as it was.
584    fn take_over(&mut self, mut p: falcon_ffmpeg::Player) {
585        let Inner::System(old) = &self.inner else { return };
586        let (rate, looping, playing, at) = (old.rate(), old.looping(), old.is_playing(), old.current_time());
587        p.set_volume(self.volume);
588        p.set_muted(self.muted || self.held_sound.is_some());
589        p.set_rate(rate);
590        p.set_looping(looping);
591        if at > 0.0 {
592            p.seek(at);
593        }
594        if playing {
595            p.play();
596        }
597        self.inner = Inner::Ffmpeg(p);
598    }
599
600    /// The picture at the playhead at the clip's full size, turned upright:
601    /// what Eagle's frame tools (set as thumbnail, copy, save) capture.
602    /// Decoded afresh, so it does not disturb playback or the frames
603    /// [`Video::frame`] hands out.
604    pub fn current_frame(&self) -> Result<Frame> {
605        match &self.inner {
606            Inner::System(v) => v.current_frame(),
607            Inner::Ffmpeg(p) => p.current_frame().map_err(Error::from),
608        }
609    }
610
611    /// The frame due now, if it differs from the last one handed out.
612    pub fn frame(&mut self) -> Option<Frame> {
613        if let Some(f) = self.held_frame.take() {
614            return Some(f);
615        }
616        match &mut self.inner {
617            Inner::System(v) => {
618                let f = v.frame();
619                self.had_frame |= f.is_some();
620                f
621            }
622            Inner::Ffmpeg(p) => p.frame(),
623        }
624    }
625}
626
627/// Opens FFmpeg's player, its sound added to `mixer` when there is one.
628fn open_ffmpeg(path: &Path, mixer: Option<&rodio::mixer::Mixer>) -> Result<falcon_ffmpeg::Player> {
629    let (player, sound) = falcon_ffmpeg::Player::open(path, mixer.is_some())?;
630    if let (Some(m), Some(s)) = (mixer, sound) {
631        m.add(Sound(s));
632    }
633    Ok(player)
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    #[test]
641    fn rates_step_and_stop_at_the_ends() {
642        assert_eq!(step_rate(1.0, true), 1.25);
643        assert_eq!(step_rate(1.0, false), 0.75);
644        assert_eq!(step_rate(8.0, true), 8.0);
645        assert_eq!(step_rate(0.25, false), 0.25);
646        assert_eq!(step_rate(2.0, true), 3.0);
647        assert_eq!(step_rate(1.1, true), 1.25);
648        assert_eq!(step_rate(1.1, false), 1.0);
649    }
650
651    #[test]
652    fn a_player_with_no_picture_after_the_limit_switches() {
653        let (before, at, after) = (Duration::from_millis(1499), NO_FRAME_LIMIT, Duration::from_secs(60));
654        assert!(!switch_for_no_frame(Duration::ZERO, false, true));
655        assert!(!switch_for_no_frame(before, false, true), "still within its time");
656        assert!(switch_for_no_frame(at, false, true));
657        assert!(switch_for_no_frame(after, false, true));
658    }
659
660    #[test]
661    fn a_player_that_drew_a_picture_stays() {
662        // A paused player's poster picture counts, however long it then sits.
663        assert!(!switch_for_no_frame(NO_FRAME_LIMIT, true, true));
664        assert!(!switch_for_no_frame(Duration::from_secs(3600), true, true));
665    }
666
667    #[test]
668    fn without_ffmpeg_there_is_nothing_to_switch_to() {
669        assert!(!switch_for_no_frame(NO_FRAME_LIMIT, false, false));
670        assert!(!switch_for_no_frame(Duration::from_secs(60), false, false));
671    }
672
673    #[test]
674    fn missing_files_are_refused_by_both() {
675        let p = Path::new("/nonexistent/falcon-video-test.mkv");
676        assert!(probe(p).is_err());
677        assert!(frame_at(p, 0.0).is_err());
678        assert!(Grabber::open(p, 64).is_err());
679    }
680}