Skip to main content

ffmpeg_frame_grabber/
ffmpeg.rs

1use crate::ffprobe::FFProbeInfo;
2use crate::{
3    error::{CommandSpawnError, FFMpegError, IOError},
4    ffprobe::VideoStreamInfo,
5};
6use image::{ImageBuffer, Rgb};
7use regex::Regex;
8use snafu::ResultExt;
9use std::process::{ChildStderr, ChildStdout, Command, Stdio};
10use std::result::Result;
11use std::str::FromStr;
12use std::time::Duration;
13use std::{collections::HashMap, path::Path};
14use std::{
15    io::{BufRead, BufReader, ErrorKind, Read},
16    path::PathBuf,
17};
18
19pub struct FFMpegVideo {
20    stdout: ChildStdout,
21    stderr: BufReader<ChildStderr>,
22    info: FFProbeInfo,
23    primary_video_stream_info: VideoStreamInfo,
24}
25
26#[derive(Default)]
27pub struct FFMpegVideoOptions {
28    sampling_interval: Option<Duration>,
29    ffmpeg_path: Option<PathBuf>,
30    ffprobe_path: Option<PathBuf>,
31}
32
33impl FFMpegVideoOptions {
34    pub fn with_sampling_interval(self, sampling_interval: Duration) -> Self {
35        FFMpegVideoOptions {
36            sampling_interval: Some(sampling_interval),
37            ..self
38        }
39    }
40
41    pub fn with_ffmpeg_path(self, path: PathBuf) -> Self {
42        FFMpegVideoOptions {
43            ffmpeg_path: Some(path),
44            ..self
45        }
46    }
47
48    pub fn with_ffprobe_path(self, path: PathBuf) -> Self {
49        FFMpegVideoOptions {
50            ffprobe_path: Some(path),
51            ..self
52        }
53    }
54}
55
56impl FFMpegVideo {
57    pub fn open(video_path: &Path, options: FFMpegVideoOptions) -> Result<Self, FFMpegError> {
58        let info = FFProbeInfo::of(video_path, options.ffprobe_path)?;
59
60        let mut cmd = Command::new(
61            options
62                .ffmpeg_path
63                .map_or("ffmpeg".to_owned(), |p| p.to_string_lossy().into()),
64        );
65        cmd.args(&["-i", &video_path.to_string_lossy()]);
66
67        let mut filters = Vec::<String>::new();
68
69        if let Some(interval) = options.sampling_interval {
70            filters.push(format!("fps=1/{:?}", interval.as_secs()));
71        }
72
73        filters.push("showinfo".to_owned());
74
75        cmd.args(&["-vf", &filters.join(",")]);
76
77        cmd.args(&[
78            "-f",
79            "image2pipe",
80            "-an", // disable audio processing
81            "-sn", // disable sub-title processing
82            "-pix_fmt",
83            "rgb24",
84            "-nostats",
85            "-vcodec",
86            "rawvideo",
87            "-",
88        ]);
89        cmd.stdout(Stdio::piped());
90        cmd.stderr(Stdio::piped());
91
92        let child = cmd.spawn().context(CommandSpawnError)?;
93
94        let stdout = child.stdout.unwrap();
95        let stderr = child.stderr.unwrap();
96
97        let primary_video_stream_info = info
98            .primary_video_stream()
99            .ok_or(FFMpegError::ParseError)?
100            .clone();
101
102        Ok(FFMpegVideo {
103            stdout,
104            stderr: BufReader::new(stderr),
105            info,
106            primary_video_stream_info,
107        })
108    }
109
110    pub fn duration(&self) -> Duration {
111        self.info.duration
112    }
113}
114
115pub struct Frame {
116    /// The decoded image.
117    pub image: FrameBuffer,
118
119    /// The offset of this frame in the video. Might not be the true time offset.
120    pub time_offset: Duration,
121}
122
123pub type FrameBuffer = ImageBuffer<Rgb<u8>, Vec<u8>>;
124
125impl FFMpegVideo {
126    fn get_next(&mut self) -> Result<Option<Frame>, FFMpegError> {
127        let mut infos = std::collections::HashMap::new();
128        let mut line = String::new();
129
130        // There are two show info lines.
131        // If we don't read all of them, the stream will block.
132        let mut lines_to_read = 2;
133        while lines_to_read > 0 {
134            line.clear();
135            self.stderr.read_line(&mut line).context(IOError)?;
136            if line.len() == 0 {
137                return Ok(None);
138            }
139            if parse_showinfo(&line, &mut infos).is_some() {
140                lines_to_read = lines_to_read - 1;
141            }
142        }
143        let time_seconds = f64::from_str(infos.get("pts_time").unwrap()).unwrap();
144
145        let i = &self.primary_video_stream_info;
146        let mut buffer = vec![0u8; (i.width * i.height * 3) as usize];
147
148        if let Err(err) = self.stdout.read_exact(&mut buffer) {
149            if err.kind() == ErrorKind::UnexpectedEof {
150                // Indicates the last frame has been read.
151                return Ok(None);
152            }
153            return Err(FFMpegError::IOError { source: err });
154        }
155
156        let image = FrameBuffer::from_raw(
157            self.primary_video_stream_info.width,
158            self.primary_video_stream_info.height,
159            buffer,
160        )
161        .expect("Buffer to have correct size");
162
163        Ok(Some(Frame {
164            image,
165            time_offset: Duration::from_secs_f64(time_seconds),
166        }))
167    }
168}
169
170impl Iterator for FFMpegVideo {
171    type Item = Result<Frame, FFMpegError>;
172
173    fn next(&mut self) -> Option<Self::Item> {
174        match self.get_next() {
175            Err(err) => Some(Err(err)),
176            Ok(Some(val)) => Some(Ok(val)),
177            Ok(None) => None,
178        }
179    }
180}
181
182fn parse_showinfo(line: &str, props: &mut HashMap<String, String>) -> std::option::Option<()> {
183    if !line.starts_with(&"[Parsed_showinfo_") {
184        return None;
185    }
186
187    if line.contains("] config") {
188        return None;
189    }
190
191    lazy_static! {
192        static ref RE: Regex = Regex::new(r"((?P<key>\w+):\s*(?P<value>(\[.*?]|\S+)))").unwrap();
193    }
194
195    for cap in RE.captures_iter(line) {
196        let key = cap.name("key").unwrap().as_str();
197        let name = cap.name("value").unwrap().as_str();
198        props.insert(key.to_string(), name.to_string());
199    }
200
201    Some(())
202}
203
204#[cfg(test)]
205mod tests {
206    use insta::{assert_json_snapshot, Settings};
207    use std::collections::HashMap;
208
209    fn test_parse_show_info(line: &str) -> Option<HashMap<String, String>> {
210        let mut map = HashMap::new();
211        super::parse_showinfo(line, &mut map).map(|_| map)
212    }
213
214    pub fn setup() -> () {
215        let mut s = Settings::new();
216        s.set_sort_maps(true);
217        s.bind_to_thread();
218    }
219
220    #[test]
221    fn test_parse_showinfo1() {
222        setup();
223        assert_json_snapshot!(
224            test_parse_show_info(
225                "[Parsed_showinfo_1 @ 000002669dfefec0] n:   1 pts:      1 pts_time:120     pos: 14185698 fmt:yuv420p sar:1/1 s:1920x1080 i:P iskey:0 type:B checksum:A91F982B plane_checksum:[7BFA6F14 ED4B1E62 92900AB5] mean:[227 127 130] stdev:[35.1 10.3 10.0]",
226            ),
227            @r###"
228        {
229          "checksum": "A91F982B",
230          "fmt": "yuv420p",
231          "i": "P",
232          "iskey": "0",
233          "mean": "[227 127 130]",
234          "n": "1",
235          "plane_checksum": "[7BFA6F14 ED4B1E62 92900AB5]",
236          "pos": "14185698",
237          "pts": "1",
238          "pts_time": "120",
239          "s": "1920x1080",
240          "sar": "1/1",
241          "stdev": "[35.1 10.3 10.0]",
242          "type": "B"
243        }
244        "###
245        );
246    }
247
248    #[test]
249    fn test_parse_showinfo2() {
250        setup();
251        assert_json_snapshot!(
252            test_parse_show_info(
253                "[Parsed_showinfo_1 @ 000002669dfefec0] color_range:unknown color_space:unknown color_primaries:unknown color_trc:unknown"
254            ),
255            @r###"
256        {
257          "color_primaries": "unknown",
258          "color_range": "unknown",
259          "color_space": "unknown",
260          "color_trc": "unknown"
261        }
262        "###
263        );
264    }
265
266    #[test]
267    fn test_parse_showinfo3() {
268        setup();
269        assert_json_snapshot!(
270            test_parse_show_info("Output #0, image2pipe, to 'pipe:':"),
271            @"null"
272        );
273    }
274
275    #[test]
276    fn test_parse_showinfo4() {
277        setup();
278        assert_json_snapshot!(
279            test_parse_show_info(
280                "[Parsed_showinfo_1 @ 000002669dfefec0] config in time_base: 120/1, frame_rate: 1/120",
281            ),
282            @"null"
283        );
284    }
285
286    #[test]
287    fn test_parse_showinfo5() {
288        setup();
289        assert_json_snapshot!(
290            test_parse_show_info(
291                "[Parsed_showinfo_1 @ 000002669dfefec0] config out time_base: 0/0, frame_rate: 0/0",
292            ),
293            @"null"
294        );
295    }
296}