1#[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#[derive(Clone, Copy, Debug, Default)]
25pub struct VideoSample {
26 pub time: f64,
29 pub stamp: Option<FrameStamp>,
31 pub qr_located: bool,
33 pub old_format: bool,
36 pub exposure: Option<f64>,
40 pub readout: Option<f64>,
45}
46
47#[derive(Clone, Debug)]
49pub struct AudioTrack {
50 pub sample_rate: u32,
51 pub start_time: f64,
53 pub samples: Vec<f32>,
54}
55
56#[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 pub stream_fps: Option<FrameRate>,
65}
66
67#[derive(Clone, Copy, Debug)]
69pub struct ScanWindow {
70 pub start: f64,
72 pub end: Option<f64>,
74 pub audio_margin: f64,
76}
77
78#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
80pub enum VideoCodec {
81 #[default]
83 H264,
84 Ffv1,
86}
87
88#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90pub enum AudioCodec {
91 #[default]
93 Aac,
94 Pcm,
96}
97
98impl FromStr for VideoCodec {
99 type Err = Error;
100
101 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 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#[derive(Clone, Debug)]
131pub struct GenerateOptions {
132 pub output: PathBuf,
133 pub fps: FrameRate,
134 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 pub audio_delay: f64,
144 pub music: bool,
147}
148
149impl GenerateOptions {
150 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 #[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#[derive(Clone, Copy, Debug)]
212pub struct Progress {
213 pub frames_done: u64,
214 pub frames_total: u64,
215}
216
217#[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 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 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 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 pub fn track(&self) -> Option<&AudioTrack> {
299 self.track.as_ref()
300 }
301
302 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 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 b.push(1.001, 1000, &[2.0; 100]).unwrap();
331 assert_eq!(b.track().unwrap().samples.len(), 600);
332 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 b.push(1.23, 1000, &[4.0; 100]).unwrap();
339 assert_eq!(b.track().unwrap().samples.len(), 830);
340 assert!(!b.is_full());
341 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}