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", "-sn", "-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 pub image: FrameBuffer,
118
119 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 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 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}