substudy/
video.rs

1//! Tools for working with video files.
2
3use std::{
4    collections::BTreeMap,
5    ffi::OsStr,
6    path::{Path, PathBuf},
7    process::Command,
8    result,
9    str::{from_utf8, FromStr},
10};
11
12use anyhow::{anyhow, Context as _};
13use cast;
14use indicatif::ProgressBar;
15use log::debug;
16use num::rational::Ratio;
17use regex::Regex;
18use serde::{de, Deserialize, Deserializer};
19use serde_json;
20
21use crate::{
22    errors::RunCommandError, lang::Lang, progress::default_progress_style,
23    time::Period, Result,
24};
25
26/// Information about an MP3 track (optional).
27#[derive(Debug, Default)]
28#[allow(missing_docs)]
29pub struct Id3Metadata {
30    pub genre: Option<String>,
31    pub artist: Option<String>,
32    pub album: Option<String>,
33    pub track_number: Option<(usize, usize)>,
34    pub track_name: Option<String>,
35    pub lyrics: Option<String>,
36}
37
38impl Id3Metadata {
39    fn add_args(&self, cmd: &mut Command) {
40        if let Some(ref genre) = self.genre {
41            cmd.arg("-metadata").arg(format!("genre={}", genre));
42        }
43        if let Some(ref artist) = self.artist {
44            cmd.arg("-metadata").arg(format!("artist={}", artist));
45        }
46        if let Some(ref album) = self.album {
47            cmd.arg("-metadata").arg(format!("album={}", album));
48        }
49        if let Some((track, total)) = self.track_number {
50            cmd.arg("-metadata")
51                .arg(format!("track={}/{}", track, total));
52        }
53        if let Some(ref track_name) = self.track_name {
54            cmd.arg("-metadata").arg(format!("title={}", track_name));
55        }
56        if let Some(ref lyrics) = self.lyrics {
57            cmd.arg("-metadata").arg(format!("lyrics={}", lyrics));
58        }
59    }
60}
61
62/// Individual streams inside a video are labelled with a codec type.
63#[derive(Debug, PartialEq, Eq)]
64#[allow(missing_docs)]
65pub enum CodecType {
66    Audio,
67    Video,
68    Subtitle,
69    Other(String),
70}
71
72impl<'de> Deserialize<'de> for CodecType {
73    fn deserialize<D: Deserializer<'de>>(d: D) -> result::Result<Self, D::Error> {
74        let s = String::deserialize(d)?;
75        match &s[..] {
76            "audio" => Ok(CodecType::Audio),
77            "video" => Ok(CodecType::Video),
78            "subtitle" => Ok(CodecType::Subtitle),
79            s => Ok(CodecType::Other(s.to_owned())),
80        }
81    }
82}
83
84/// A wrapper around `Ratio` with custom serialization support.
85#[derive(Debug)]
86pub struct Fraction(Ratio<u32>);
87
88impl Fraction {
89    fn deserialize_parts<'de, D>(d: D) -> result::Result<(u32, u32), D::Error>
90    where
91        D: Deserializer<'de>,
92    {
93        let s = String::deserialize(d)?;
94        let re = Regex::new(r"^(\d+)/(\d+)$").unwrap();
95        let cap = re.captures(&s).ok_or_else(|| {
96            <D::Error as de::Error>::custom(format!("Expected fraction: {}", &s))
97        })?;
98        Ok((
99            FromStr::from_str(cap.get(1).unwrap().as_str()).unwrap(),
100            FromStr::from_str(cap.get(2).unwrap().as_str()).unwrap(),
101        ))
102    }
103}
104
105impl<'de> Deserialize<'de> for Fraction {
106    fn deserialize<D: Deserializer<'de>>(d: D) -> result::Result<Self, D::Error> {
107        let (num, denom) = Fraction::deserialize_parts(d)?;
108        if denom == 0 {
109            Err(<D::Error as de::Error>::custom(
110                "Found fraction with a denominator of 0",
111            ))
112        } else {
113            Ok(Fraction(Ratio::new(num, denom)))
114        }
115    }
116}
117
118/// An individual content stream within a video.
119#[derive(Debug, Deserialize)]
120#[allow(missing_docs)]
121pub struct Stream {
122    pub index: usize,
123    pub codec_type: CodecType,
124    tags: Option<BTreeMap<String, String>>,
125}
126
127impl Stream {
128    /// Return the language associated with this stream, if we can figure
129    /// it out.
130    pub fn language(&self) -> Option<Lang> {
131        self.tags
132            .as_ref()
133            .and_then(|tags| tags.get("language"))
134            .and_then(|lang| Lang::iso639(lang).ok())
135    }
136}
137
138#[test]
139fn test_stream_decode() {
140    let json = "
141{
142  \"index\" : 2,
143  \"codec_name\" : \"aac\",
144  \"codec_long_name\" : \"AAC (Advanced Audio Coding)\",
145  \"codec_type\" : \"audio\",
146  \"codec_time_base\" : \"1/48000\",
147  \"codec_tag_string\" : \"[0][0][0][0]\",
148  \"codec_tag\" : \"0x0000\",
149  \"sample_rate\" : \"48000.000000\",
150  \"channels\" : 2,
151  \"bits_per_sample\" : 0,
152  \"avg_frame_rate\" : \"0/0\",
153  \"time_base\" : \"1/1000\",
154  \"start_time\" : \"0.000000\",
155  \"duration\" : \"N/A\",
156  \"tags\" : {
157    \"language\" : \"eng\"
158  }
159}
160";
161    let stream: Stream = serde_json::from_str(json).unwrap();
162    assert_eq!(CodecType::Audio, stream.codec_type);
163    assert_eq!(Some(Lang::iso639("en").unwrap()), stream.language())
164}
165
166/// What kind of data do we want to extract, and from what position in the
167/// video clip?
168pub enum ExtractionSpec {
169    /// Extract an image at the specified time.
170    Image(f32),
171    /// Extract an audio clip covering the specified stream and period.
172    Audio(Option<usize>, Period, Id3Metadata),
173}
174
175impl ExtractionSpec {
176    /// The earliest time at which we might need to extract data.
177    fn earliest_time(&self) -> f32 {
178        match self {
179            &ExtractionSpec::Image(time) => time,
180            &ExtractionSpec::Audio(_, period, _) => period.begin(),
181        }
182    }
183
184    /// Can we combine this extraction with others in a giant batch
185    /// request?
186    fn can_be_batched(&self) -> bool {
187        match self {
188            // Batch processing of images requires decoding the whole
189            // video, but we can do a "fast seek" and extract one image
190            // extremely quickly.
191            &ExtractionSpec::Image(_) => false,
192            _ => true,
193        }
194    }
195
196    /// Figure out what ffmpeg args we would need to extract the requested
197    /// data.  Assume that the "fast seek" feature has been used to start
198    /// decoding at `time_base`.
199    fn add_args(&self, cmd: &mut Command, time_base: f32) {
200        match self {
201            &ExtractionSpec::Image(time) => {
202                let scale_filter =
203                    format!("scale=iw*min(1\\,min({}/iw\\,{}/ih)):-1", 240, 160);
204                cmd.arg("-ss")
205                    .arg(format!("{}", time - time_base))
206                    .arg("-vframes")
207                    .arg("1")
208                    .arg("-filter_complex")
209                    .arg(&scale_filter)
210                    .arg("-f")
211                    .arg("image2");
212            }
213            &ExtractionSpec::Audio(stream, period, ref metadata) => {
214                if let Some(sid) = stream {
215                    cmd.arg("-map").arg(format!("0:{}", sid));
216                }
217                metadata.add_args(cmd);
218                cmd.arg("-ss")
219                    .arg(format!("{}", period.begin() - time_base))
220                    .arg("-t")
221                    .arg(format!("{}", period.duration()));
222            }
223        }
224    }
225}
226
227/// Information about what kind of data we want to extract.
228pub struct Extraction {
229    /// The path to extract to.
230    pub path: PathBuf,
231    /// What kind of data to extract.
232    pub spec: ExtractionSpec,
233}
234
235impl Extraction {
236    /// Add the necessary args to `cmd` to perform this extraction.
237    fn add_args(&self, cmd: &mut Command, time_base: f32) {
238        self.spec.add_args(cmd, time_base);
239        cmd.arg(self.path.clone());
240    }
241}
242
243/// Metadata associated with a video.
244#[derive(Debug, Deserialize)]
245struct Metadata {
246    streams: Vec<Stream>,
247}
248
249/// Represents a video file on disk.
250#[derive(Debug)]
251pub struct Video {
252    path: PathBuf,
253    metadata: Metadata,
254}
255
256impl Video {
257    /// Create a new video file, given a path.
258    pub fn new(path: &Path) -> Result<Video> {
259        // Ensure we have an actual file before doing anything else.
260        if !path.is_file() {
261            return Err(anyhow!("No such file {:?}", path.display()));
262        }
263
264        // Run our probe command.
265        let mkerr = || RunCommandError::new("ffprobe");
266        let cmd = Command::new("ffprobe")
267            .arg("-v")
268            .arg("quiet")
269            .arg("-show_streams")
270            .arg("-of")
271            .arg("json")
272            .arg(path)
273            .output();
274        let output = cmd.with_context(mkerr)?;
275        let stdout = from_utf8(&output.stdout).with_context(mkerr)?;
276        debug!("Video metadata: {}", stdout);
277        let metadata = serde_json::from_str(stdout).with_context(mkerr)?;
278
279        Ok(Video {
280            path: path.to_owned(),
281            metadata: metadata,
282        })
283    }
284
285    /// Get just the file name of this video file.
286    pub fn file_name(&self) -> &OsStr {
287        self.path.file_name().unwrap()
288    }
289
290    /// Get just the file stem of this video file, stripped of any
291    /// extensions.
292    pub fn file_stem(&self) -> &OsStr {
293        self.path.file_stem().unwrap()
294    }
295
296    /// List all the tracks in a video file.
297    pub fn streams(&self) -> &[Stream] {
298        &self.metadata.streams
299    }
300
301    /// Choose the best audio for the specified language.
302    pub fn audio_for(&self, lang: Lang) -> Option<usize> {
303        self.streams().iter().position(|s| {
304            s.codec_type == CodecType::Audio && s.language() == Some(lang)
305        })
306    }
307
308    /// Create an extraction command using the specified `time_base`.  This
309    /// allows us to start extractions at any arbitrary point in the video
310    /// rapidly.
311    fn extract_command(&self, time_base: f32) -> Command {
312        let mut cmd = Command::new("ffmpeg");
313        cmd.arg("-ss").arg(format!("{}", time_base));
314        cmd.arg("-i").arg(&self.path);
315        cmd
316    }
317
318    /// Perform a single extraction.
319    fn extract_one(&self, extraction: &Extraction) -> Result<()> {
320        let time_base = extraction.spec.earliest_time();
321        let mut cmd = self.extract_command(time_base);
322        extraction.add_args(&mut cmd, time_base);
323        cmd.output()
324            .with_context(|| RunCommandError::new("ffmpg"))?;
325        Ok(())
326    }
327
328    /// Perform a batch extraction.  We assume that the extractions are
329    /// sorted in temporal order.
330    fn extract_batch(&self, extractions: &[&Extraction]) -> Result<()> {
331        // Bail early if we have nothing to extract
332        if extractions.is_empty() {
333            return Ok(());
334        }
335        let time_base = extractions[0].spec.earliest_time();
336
337        // Build and run our batch extraction command.
338        let mut cmd = self.extract_command(time_base);
339        for e in extractions {
340            assert!(e.spec.can_be_batched());
341            e.add_args(&mut cmd, time_base);
342        }
343        cmd.output()
344            .with_context(|| RunCommandError::new("ffmpg"))?;
345        Ok(())
346    }
347
348    /// Perform a list of extractions as efficiently as possible.  We use a
349    /// batch interface to avoid making too many passes through the file.
350    /// We assume that the extractions are sorted in temporal order.
351    pub fn extract(&self, extractions: &[Extraction]) -> Result<()> {
352        let pb = ProgressBar::new(cast::u64(extractions.len()));
353        pb.set_style(default_progress_style());
354        pb.set_prefix("✂️  Extracting media");
355        pb.tick();
356
357        let mut batch: Vec<&Extraction> = vec![];
358        for e in extractions {
359            if e.spec.can_be_batched() {
360                batch.push(e);
361            } else {
362                self.extract_one(e)?;
363                pb.inc(1);
364            }
365        }
366
367        for chunk in batch.chunks(20) {
368            self.extract_batch(chunk)?;
369            pb.inc(cast::u64(chunk.len()));
370        }
371        Ok(())
372    }
373}