Skip to main content

av_drift_detector/media/
mod.rs

1//! Captures as the analyser sees them, test-file settings, and FFmpeg input
2//! and output: linked through `ffmpeg-next` (the `ffmpeg` feature, modules
3//! `decode` and `encode`), or by running the `ffmpeg` and `ffprobe` programs
4//! (the `ffmpeg-cli` feature, module `ffmpeg_cli`).
5//!
6//! The types here are plain data, so other decoders (Android's MediaCodec, a
7//! capture card SDK, a live camera and microphone) can build a [`MediaScan`]
8//! and pass it to [`analyze_scan`](crate::analyze::analyze_scan).
9
10#[cfg(feature = "ffmpeg")]
11pub mod decode;
12#[cfg(feature = "ffmpeg")]
13pub mod encode;
14#[cfg(feature = "ffmpeg-cli")]
15pub mod ffmpeg_cli;
16
17use std::path::PathBuf;
18use std::str::FromStr;
19
20use crate::pattern::FrameStamp;
21use crate::{Error, FrameRate, Result};
22
23/// One decoded video frame.
24#[derive(Clone, Copy, Debug, Default)]
25pub struct VideoSample {
26    /// Presentation time in seconds (container timeline). For a camera, the
27    /// start of the exposure of the picture's top row, as phones record it.
28    pub time: f64,
29    /// The stamp read from the frame, if any.
30    pub stamp: Option<FrameStamp>,
31    /// Whether a QR code was found in the frame, even if it couldn't be read.
32    pub qr_located: bool,
33    /// Whether the frame carries a stamp in an earlier release's format,
34    /// which isn't read.
35    pub old_format: bool,
36    /// Camera frames: how long the picture was exposed, in seconds, if the
37    /// camera said (phones' capture metadata does). Video files don't record
38    /// it, and the analyzer then works it out from the frames, less exactly.
39    pub exposure: Option<f64>,
40    /// Camera frames: the rolling shutter's readout, in seconds, if the camera
41    /// said: row `y` of a picture `height` rows tall began its exposure
42    /// `readout · y / height` after [`time`](Self::time) (Android's
43    /// `SENSOR_ROLLING_SHUTTER_SKEW`).
44    pub readout: Option<f64>,
45}
46
47/// Decoded audio, downmixed to mono.
48#[derive(Clone, Debug)]
49pub struct AudioTrack {
50    pub sample_rate: u32,
51    /// Container time of `samples[0]`.
52    pub start_time: f64,
53    pub samples: Vec<f32>,
54}
55
56/// Everything the analyser needs from a file.
57#[derive(Clone, Debug)]
58pub struct MediaScan {
59    pub video: Vec<VideoSample>,
60    pub audio: Option<AudioTrack>,
61    pub width: u32,
62    pub height: u32,
63    /// The video stream's average frame rate, if the container reports one.
64    pub stream_fps: Option<FrameRate>,
65}
66
67/// Which part of the file to read.
68#[derive(Clone, Copy, Debug)]
69pub struct ScanWindow {
70    /// Video frames before this time (seconds) are skipped.
71    pub start: f64,
72    /// Video frames at or after this time are skipped; `None` reads to the end.
73    pub end: Option<f64>,
74    /// Extra audio to keep on either side of the video window, in seconds.
75    pub audio_margin: f64,
76}
77
78/// Video codec for generated files.
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
80pub enum VideoCodec {
81    /// H.264 via libx264 (CRF 18), the most portable choice.
82    #[default]
83    H264,
84    /// FFV1, lossless (.mkv or .mov only).
85    Ffv1,
86}
87
88/// Audio codec for generated files.
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90pub enum AudioCodec {
91    /// AAC at 256 kb/s.
92    #[default]
93    Aac,
94    /// Uncompressed 16-bit PCM (.mkv or .mov only).
95    Pcm,
96}
97
98impl FromStr for VideoCodec {
99    type Err = Error;
100
101    /// `h264` or `ffv1`.
102    fn from_str(s: &str) -> Result<Self> {
103        match s.to_ascii_lowercase().as_str() {
104            "h264" => Ok(VideoCodec::H264),
105            "ffv1" => Ok(VideoCodec::Ffv1),
106            _ => Err(Error::InvalidOption(format!(
107                "video codec must be h264 or ffv1, not `{s}`"
108            ))),
109        }
110    }
111}
112
113impl FromStr for AudioCodec {
114    type Err = Error;
115
116    /// `aac` or `pcm`.
117    fn from_str(s: &str) -> Result<Self> {
118        match s.to_ascii_lowercase().as_str() {
119            "aac" => Ok(AudioCodec::Aac),
120            "pcm" => Ok(AudioCodec::Pcm),
121            _ => Err(Error::InvalidOption(format!(
122                "audio codec must be aac or pcm, not `{s}`"
123            ))),
124        }
125    }
126}
127
128/// Settings for writing a test file, with `encode::generate` (the `ffmpeg`
129/// feature) or `ffmpeg_cli::generate` (the `ffmpeg-cli` feature).
130#[derive(Clone, Debug)]
131pub struct GenerateOptions {
132    pub output: PathBuf,
133    pub fps: FrameRate,
134    /// Duration in seconds, rounded to whole frames.
135    pub duration: f64,
136    pub width: u32,
137    pub height: u32,
138    pub sample_rate: u32,
139    pub video_codec: VideoCodec,
140    pub audio_codec: AudioCodec,
141    /// A deliberate audio delay in seconds (positive = audio late, negative = early),
142    /// to check an analyser or a pipeline against a known offset.
143    pub audio_delay: f64,
144    /// Mix the [`Music`](crate::audio::Music) in under the code (up to about
145    /// 60 fps). It doesn't affect analysis.
146    pub music: bool,
147}
148
149impl GenerateOptions {
150    /// 1280×720, 48 kHz, H.264 + AAC, no injected delay, with music.
151    pub fn new(output: impl Into<PathBuf>, fps: FrameRate, duration: f64) -> Self {
152        GenerateOptions {
153            output: output.into(),
154            fps,
155            duration,
156            width: 1280,
157            height: 720,
158            sample_rate: 48000,
159            video_codec: VideoCodec::default(),
160            audio_codec: AudioCodec::default(),
161            audio_delay: 0.0,
162            music: true,
163        }
164    }
165
166    pub fn total_frames(&self) -> u64 {
167        self.fps.frames_in(self.duration)
168    }
169
170    /// Checks the options before anything is written.
171    #[cfg(any(feature = "ffmpeg", feature = "ffmpeg-cli"))]
172    pub(crate) fn validate(&self) -> Result<()> {
173        use crate::pattern::barcode::MAX_FRAME_ID;
174        let opts = self;
175        let invalid = |msg: String| Err(Error::InvalidOption(msg));
176        if !(opts.duration.is_finite() && opts.duration > 0.0) {
177            return invalid(format!("duration must be positive, got {}", opts.duration));
178        }
179        if opts.total_frames() > u64::from(MAX_FRAME_ID) + 1 {
180            return invalid("duration exceeds 2^24 frames".into());
181        }
182        if !(8000..=192_000).contains(&opts.sample_rate) {
183            return invalid(format!("sample rate {} is out of range", opts.sample_rate));
184        }
185        if !opts.audio_delay.is_finite() || opts.audio_delay.abs() > 60.0 {
186            return invalid("audio delay must be within ±60 s".into());
187        }
188        let ext = opts
189            .output
190            .extension()
191            .and_then(|e| e.to_str())
192            .map(str::to_ascii_lowercase)
193            .unwrap_or_default();
194        let flexible = matches!(ext.as_str(), "mkv" | "mov");
195        if !flexible && !matches!(ext.as_str(), "mp4" | "m4v") {
196            return invalid(format!(
197                "unsupported output container `.{ext}` (use .mp4, .mov or .mkv)"
198            ));
199        }
200        if !flexible && opts.audio_codec == AudioCodec::Pcm {
201            return invalid("PCM audio needs a .mov or .mkv output".into());
202        }
203        if !flexible && opts.video_codec == VideoCodec::Ffv1 {
204            return invalid("FFV1 video needs a .mov or .mkv output".into());
205        }
206        Ok(())
207    }
208}
209
210/// Generation progress, reported once per video frame.
211#[derive(Clone, Copy, Debug)]
212pub struct Progress {
213    pub frames_done: u64,
214    pub frames_total: u64,
215}
216
217/// Assembles one continuous [`AudioTrack`] from timestamped chunks of mono
218/// audio, as a decoder delivers them.
219///
220/// The track's timeline stays continuous: gaps between chunks are filled with
221/// silence and overlaps are dropped. Mismatches up to `tolerance` seconds are
222/// treated as timestamp rounding, not real gaps.
223#[derive(Clone, Debug)]
224pub struct AudioTrackBuilder {
225    from: f64,
226    to: Option<f64>,
227    tolerance: f64,
228    track: Option<AudioTrack>,
229}
230
231impl AudioTrackBuilder {
232    /// Keeps audio from container time `from` until `to` (`None`: no limit).
233    pub fn new(from: f64, to: Option<f64>, tolerance: f64) -> Self {
234        AudioTrackBuilder {
235            from,
236            to,
237            tolerance,
238            track: None,
239        }
240    }
241
242    /// Appends `data`, whose first sample is at container time `time`.
243    pub fn push(&mut self, time: f64, rate: u32, data: &[f32]) -> Result<()> {
244        let r = f64::from(rate);
245        let track = match &mut self.track {
246            Some(track) => {
247                if track.sample_rate != rate {
248                    return Err(Error::Unsupported(
249                        "audio sample rate changes mid-stream".into(),
250                    ));
251                }
252                track
253            }
254            None => {
255                let skip = ((self.from - time) * r).ceil().max(0.0) as usize;
256                if skip >= data.len() {
257                    return Ok(());
258                }
259                self.track = Some(AudioTrack {
260                    sample_rate: rate,
261                    start_time: time + skip as f64 / r,
262                    samples: data[skip..].to_vec(),
263                });
264                self.trim();
265                return Ok(());
266            }
267        };
268        let mismatch = time - track.start_time - track.samples.len() as f64 / r;
269        let gap = if mismatch.abs() <= self.tolerance {
270            0
271        } else {
272            (mismatch * r).round() as i64
273        };
274        let data = if gap > 0 {
275            track
276                .samples
277                .resize(track.samples.len() + gap as usize, 0.0);
278            data
279        } else {
280            &data[((-gap) as usize).min(data.len())..]
281        };
282        track.samples.extend_from_slice(data);
283        self.trim();
284        Ok(())
285    }
286
287    /// Whether the track has reached its end time.
288    pub fn is_full(&self) -> bool {
289        match (self.to, &self.track) {
290            (Some(to), Some(track)) => {
291                track.samples.len() as f64 >= (to - track.start_time) * f64::from(track.sample_rate)
292            }
293            _ => false,
294        }
295    }
296
297    /// The track so far.
298    pub fn track(&self) -> Option<&AudioTrack> {
299        self.track.as_ref()
300    }
301
302    /// The finished track, or `None` if no audio fell within the range.
303    pub fn finish(self) -> Option<AudioTrack> {
304        self.track
305    }
306
307    fn trim(&mut self) {
308        let (Some(to), Some(track)) = (self.to, self.track.as_mut()) else {
309            return;
310        };
311        let keep = ((to - track.start_time) * f64::from(track.sample_rate))
312            .ceil()
313            .max(0.0) as usize;
314        track.samples.truncate(keep);
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn builds_a_continuous_track() {
324        let mut b = AudioTrackBuilder::new(0.5, Some(2.0), 0.002);
325        // Starts before `from`: the first half second is skipped.
326        b.push(0.0, 1000, &[1.0; 1000]).unwrap();
327        assert_eq!(b.track().unwrap().start_time, 0.5);
328        assert_eq!(b.track().unwrap().samples.len(), 500);
329        // A 1 ms rounding error is ignored.
330        b.push(1.001, 1000, &[2.0; 100]).unwrap();
331        assert_eq!(b.track().unwrap().samples.len(), 600);
332        // A 50 ms gap is filled with silence.
333        b.push(1.15, 1000, &[3.0; 100]).unwrap();
334        let t = b.track().unwrap();
335        assert_eq!(t.samples.len(), 750);
336        assert_eq!((t.samples[620], t.samples[660]), (0.0, 3.0));
337        // A 20 ms overlap is dropped.
338        b.push(1.23, 1000, &[4.0; 100]).unwrap();
339        assert_eq!(b.track().unwrap().samples.len(), 830);
340        assert!(!b.is_full());
341        // Stops at `to`.
342        b.push(1.33, 1000, &[5.0; 1000]).unwrap();
343        assert!(b.is_full());
344        assert_eq!(b.finish().unwrap().samples.len(), 1500);
345    }
346
347    #[test]
348    fn rejects_a_rate_change() {
349        let mut b = AudioTrackBuilder::new(0.0, None, 0.002);
350        b.push(0.0, 48000, &[0.0; 10]).unwrap();
351        assert!(b.push(0.1, 44100, &[0.0; 10]).is_err());
352    }
353}