extract_frames/main.rs
1#[cfg(test)]
2mod tests;
3
4use {
5 anyhow::{Context, Error, Result, anyhow, bail},
6 clap::Parser,
7 glob::glob,
8 image::RgbImage,
9 log::{debug, error, info},
10 num_traits::cast,
11 rayon::prelude::*,
12 std::{
13 env,
14 fs::{create_dir_all, remove_dir_all, remove_file},
15 path::{Path, PathBuf},
16 process::{Command, Stdio},
17 time::Instant,
18 },
19 video_rs::{decode::Decoder, error::Error::DecodeExhausted},
20};
21
22/// Command line argument parser using clap derive macro
23/// Defines the CLI interface for the frame extraction application
24///
25/// # Fields
26/// * `file` - Input video file path (default: "video.mp4")
27/// * `use_seek` - Enable seek-based frame extraction method
28/// * `multicore` - Enable parallel processing using multiple CPU cores
29#[derive(Parser, Debug)]
30#[command(version, about, long_about = None)]
31struct Args {
32 /// Path to the input video file to process
33 ///
34 /// Specifies the video file from which frames will be extracted. The file
35 /// must be in a format supported by the underlying video processing
36 /// library.
37 ///
38 /// # Default
39 /// If not specified, defaults to "video.mp4" in the current directory.
40 ///
41 /// # Supported Formats
42 /// Common formats like MP4, AVI, MOV, MKV are typically supported, though
43 /// actual support depends on the system's codec installation.
44 #[arg(short, long, default_value = "video.mp4")]
45 file: PathBuf,
46
47 /// Enable seek-based frame extraction method
48 ///
49 /// When enabled, extracts exactly one frame per second by seeking to
50 /// specific timestamps rather than processing sequentially. This method
51 /// is more accurate for temporal sampling but significantly slower due
52 /// to seek overhead.
53 ///
54 /// # Performance Impact
55 /// * Much slower than sequential processing due to seek operations
56 /// * More CPU intensive due to decoding from keyframes
57 /// * May skip frames in areas with sparse keyframes
58 /// * Incompatible with --multicore flag
59 #[arg(long)]
60 use_seek: bool,
61
62 /// Enable multi-core parallel processing
63 ///
64 /// When enabled, splits the input video into time-based segments and
65 /// processes them in parallel using all available CPU cores. This can
66 /// significantly reduce processing time for large videos on multi-core
67 /// systems.
68 ///
69 /// # Requirements
70 /// * ffmpeg must be installed and available in system PATH
71 /// * Sufficient disk space for temporary segment files
72 /// * Incompatible with --use-seek flag
73 #[arg(long, action = clap::ArgAction::SetTrue)]
74 multicore: bool,
75}
76
77/// Number of frames to skip between extracted frames
78/// For 30fps video, skipping 30 frames extracts 1 frame per second
79/// Adjust this value to control extraction frequency and output size
80const FRAMES_BETWEEN_EXTRACTED: usize = 30;
81
82/// Duration in seconds for each video segment when splitting videos
83/// for parallel processing. Default is 5 seconds per segment.
84const SEGMENT_DURATION_SECONDS: f64 = 5.0;
85
86/// File naming pattern for ffmpeg segment output files using printf-style
87/// formatting %09d creates zero-padded 9-digit numbers (e.g.,
88/// `output_000000001.mp4`)
89const SEGMENT_OUTPUT_PATTERN: &str = "segments/output_%09d.mp4";
90
91/// Glob pattern to match all PNG frame images in the frames directory
92/// Used for cleanup operations and file enumeration
93const FRAME_FILES_PATTERN: &str = "frames/*.png";
94
95/// Glob pattern to match all MP4 segment files in the segments directory
96/// Used for finding and cleaning up temporary segment files after processing
97const SEGMENTED_FILES_PATTERN: &str = "segments/*.mp4";
98
99/// Finds all files matching the given glob pattern and returns their paths.
100///
101/// This function wraps the glob crate functionality with proper error handling
102/// and converts the results to `AsRef<Path>` instances. It's used throughout
103/// the application for discovering video segments, frame images, and other
104/// generated files.
105///
106/// # Arguments
107/// * `path` - A path pattern supporting glob wildcards (*, ?, \[abc\], etc.)
108///
109/// # Returns
110/// * `Ok(Vec<impl AsRef<Path>>)` - Vector of matching file paths
111/// * `Err` - If the pattern is invalid UTF-8 or glob matching fails
112///
113/// # Examples
114/// ```
115/// let paths = get_files("frames/*.png")?; // Find all PNG frames
116/// let segments = get_files("segments/*.mp4")?; // Find all MP4 segments
117/// ```
118fn get_files(path: impl AsRef<Path>) -> Result<Vec<impl AsRef<Path>>> {
119 let pattern_str = path
120 .as_ref()
121 .to_str()
122 .ok_or_else(|| anyhow!("Invalid UTF-8 path for glob pattern: {}", path.as_ref().display()))?;
123
124 let paths = glob(pattern_str)
125 .with_context(|| format!("Failed to read glob pattern '{pattern_str}'"))?
126 .filter_map(Result::ok)
127 .collect();
128
129 Ok(paths)
130}
131
132/// Attempts to remove all files in the specified slice with batch error
133/// handling.
134///
135/// Unlike `std::fs::remove_file` which stops at the first error, this function
136/// attempts to remove all specified files and collects all encountered errors.
137/// This is useful for cleanup operations where partial success is acceptable.
138///
139/// # Arguments
140/// * `paths` - Slice of file paths to attempt removal on
141///
142/// # Returns
143/// * `Ok(())` if all files were successfully removed or if the input was empty
144/// * `Err(anyhow::Error)` containing an error encountered during removal
145/// attempt
146///
147/// # Logging
148/// * Logs successful removals at debug level
149/// * Logs individual file removal errors at error level
150fn remove_files(paths: &[impl AsRef<Path>]) -> Result<(), Error> {
151 let errors: Vec<_> = paths
152 .iter()
153 .filter_map(|path| {
154 let path = path.as_ref();
155 if let Err(err) = remove_file(path) {
156 error!("Failed to remove file {}: {err}", path.display());
157 Some(err)
158 } else {
159 debug!("Successfully removed file: {}", path.display());
160 None
161 }
162 })
163 .collect();
164
165 if !errors.is_empty() {
166 return Err(anyhow!("Failed to remove files, enable logging to see them"));
167 }
168
169 Ok(())
170}
171
172/// Cleans up the working directories by removing all PNG images in the `frames`
173/// folder and all MP4 segments in the `segments` folder. Logs the result.
174fn cleanup_temporary_files() -> Result<(), Error> {
175 let paths: Vec<_> = [FRAME_FILES_PATTERN, SEGMENTED_FILES_PATTERN]
176 .iter()
177 .filter_map(|pattern| get_files(pattern).ok())
178 .flatten()
179 .collect();
180
181 remove_files(&paths)
182}
183
184/// Removes a directory and all its contents recursively.
185///
186/// This function wraps `std::fs::remove_dir_all` with additional error context
187/// to provide meaningful error messages when directory removal fails.
188///
189/// # Arguments
190/// * `path` - Path to the directory to remove
191///
192/// # Returns
193/// * `Ok(())` if directory was successfully removed
194/// * `Err` with context if removal failed
195///
196/// # Examples
197/// ```
198/// remove_folder(Path::new("temporary_files"))?;
199/// ```
200fn remove_folder(path: impl AsRef<Path>) -> Result<()> {
201 let path = path.as_ref();
202 remove_dir_all(path).with_context(|| format!("Failed to remove folder '{}'", path.display()))
203}
204
205/// Uses ffmpeg to split the source video file into several segments.
206///
207/// This function performs stream copying (not re-encoding) to split a large
208/// video into smaller time-based segments. It's designed for parallel
209/// processing scenarios where multiple cores can work on different segments
210/// simultaneously. The segmentation preserves video quality while enabling
211/// parallel frame extraction.
212///
213/// # Arguments
214/// * `path` - Path to the source video file to be segmented
215/// * `segment_output_pattern` - ffmpeg formatting pattern for output filenames
216/// (e.g., "output_%09d.mp4" creates `output_000000001.mp4`,
217/// `output_000000002.mp4`, etc.)
218/// * `segmented_files_pattern` - glob pattern to find the created segment files
219///
220/// # Returns
221/// * `Ok(Vec<PathBuf>)` - Paths to all generated segment files
222/// * `Err` - If ffmpeg fails or file discovery encounters errors
223///
224/// # Examples
225/// ```
226/// let segments = split_into_segments(
227/// Path::new("input.mp4"),
228/// "segments/output_%09d.mp4",
229/// "segments/*.mp4",
230/// )?;
231/// assert!(!segments.is_empty());
232/// ```
233///
234/// # ffmpeg Parameters Explained
235/// * `-v quiet` - Suppress most ffmpeg output
236/// * `-c copy` - Stream copy (no re-encoding, very fast)
237/// * `-map 0` - Copy all streams from input
238/// * `-segment_time` - Target duration of each segment
239/// * `-f segment` - Use segment muxer for splitting
240/// * `-reset_timestamps 1` - Reset timestamps for each segment
241fn split_into_segments(
242 path: impl AsRef<Path>,
243 segment_output_pattern: &str,
244 segmented_files_path: impl AsRef<Path>,
245) -> Result<Vec<impl AsRef<Path>>> {
246 info!("Starting ffmpeg process in the background...");
247
248 let mut child_process = Command::new("ffmpeg")
249 .arg("-v")
250 .arg("quiet")
251 .arg("-i")
252 .arg(path.as_ref())
253 .arg("-c")
254 .arg("copy")
255 .arg("-map")
256 .arg("0")
257 .arg("-segment_time")
258 .arg(SEGMENT_DURATION_SECONDS.to_string())
259 .arg("-f")
260 .arg("segment")
261 .arg("-reset_timestamps")
262 .arg("1")
263 .arg(segment_output_pattern)
264 .stdout(Stdio::piped())
265 .stderr(Stdio::piped())
266 .spawn()
267 .context("Failed to start ffmpeg process")?;
268
269 let status = child_process.wait().context("Failed to wait for ffmpeg process")?;
270
271 if !status.success() {
272 bail!("ffmpeg failed with exit code: {}", status.code().unwrap_or(-1));
273 }
274
275 get_files(segmented_files_path)
276}
277
278/// Decodes video frames by dropping frames according to
279/// `FRAMES_BETWEEN_EXTRACTED` constant.
280///
281/// This function implements the basic frame extraction method that processes
282/// videos sequentially. It's memory-efficient and works well for smaller
283/// videos or single-core processing. The `FRAMES_BETWEEN_EXTRACTED` constant
284/// determines which frames are extracted (e.g., every 30th frame for 30fps
285/// video = 1fps output).
286///
287/// # Arguments
288/// * `frame_prefix` - String prefix for output PNG filenames (e.g., "segment-1"
289/// creates "segment-1_0.png")
290/// * `video_path` - Source video file to decode
291/// * `frames_path` - Directory where PNG frame images will be saved
292///
293/// # Performance Notes
294/// * Frames are processed in decode order without seeking (faster)
295/// * Memory usage scales with `FRAMES_BETWEEN_EXTRACTED` value (lower = more
296/// memory)
297/// * Single-threaded operation unless called within parallel context
298fn decode_frames_dropping(
299 frame_prefix: &str,
300 video_path: impl AsRef<Path>,
301 frames_path: impl AsRef<Path>,
302) -> Result<()> {
303 let video_path = video_path.as_ref();
304 let frames_path = frames_path.as_ref();
305
306 if !video_path.exists() {
307 bail!("Input video path does not exist: {video_path:?}");
308 }
309 if !frames_path.exists() {
310 bail!("Output frames path does not exist: {frames_path:?}");
311 }
312
313 let start = Instant::now();
314
315 let mut decoder = Decoder::new(video_path).context("failed to create decoder")?;
316
317 let (width, height) = decoder.size();
318 let fps = f64::from(decoder.frame_rate());
319
320 debug!("Width: {width}, height: {height}");
321 debug!("FPS: {fps}");
322
323 for (n, frame_result) in decoder.decode_iter().enumerate().step_by(FRAMES_BETWEEN_EXTRACTED) {
324 match frame_result {
325 Ok((ts, frame)) => {
326 let frame_time = ts.as_secs_f64();
327 debug!("Frame time: {frame_time}");
328
329 if let Some(rgb) = frame.as_slice() {
330 let path = frames_path.join(format!("{frame_prefix}_{n}.png"));
331 save_rgb_to_image(rgb, width, height, &path)?;
332 } else {
333 error!("Failed to get frame buffer as slice for frame {n}");
334 }
335 },
336 Err(e) => {
337 if let DecodeExhausted = e {
338 info!("Decoding finished, stream exhausted");
339 break;
340 }
341 error!("Decoding failed: {e:?}");
342 },
343 }
344 }
345
346 info!("Elapsed frame {frame_prefix}: {:.2?}", start.elapsed());
347
348 Ok(())
349}
350
351/// Decodes one frame per second by seeking to specific timestamps.
352///
353/// This experimental function uses precise seeking to extract exactly one
354/// frame per second of video. It's more accurate for consistent temporal
355/// sampling but significantly slower due to seek overhead. Uses rayon for
356/// parallel PNG saving to improve performance.
357///
358/// # Approach
359/// 1. Calculate video duration and determine target timestamps (1s, 2s, 3s,
360/// ...)
361/// 2. Seek to each timestamp and decode one frame
362/// 3. Save all frames in parallel using rayon
363///
364/// # Limitations
365/// * Seek accuracy depends on video keyframe spacing
366/// * May skip frames in areas with sparse keyframes
367/// * Higher CPU usage due to seeking overhead
368fn decode_frames_seeking(video_path: impl AsRef<Path>) -> Result<()> {
369 let start = Instant::now();
370 let video_path = video_path.as_ref();
371
372 let mut decoder = Decoder::new(video_path).context("failed to create decoder")?;
373
374 let fps = f64::from(decoder.frame_rate());
375 let duration_time = decoder.duration().expect("failed to get duration");
376 let duration_seconds = duration_time.as_secs_f64();
377 let (width, height) = decoder.size();
378
379 debug!("Width: {width}, height: {height}");
380
381 if duration_seconds < 0.0 {
382 let n_frames = decoder.frames().expect("failed to get number of frames");
383 let frame_count = cast(n_frames).unwrap_or(0);
384 let duration_seconds = if fps > 0.0 { f64::from(frame_count) / fps } else { 0.0 };
385
386 info!("Frame count: {frame_count}");
387 info!("Estimated duration (from frames): {duration_seconds} seconds");
388 } else {
389 info!("Duration: {duration_seconds} seconds");
390 }
391
392 info!("FPS: {fps}");
393
394 let mut frames_decoded = Vec::new();
395
396 let duration_in_seconds = cast(duration_seconds.ceil()).unwrap_or(0);
397 let fps_c = cast(fps.ceil()).unwrap_or(0);
398
399 for second in 0..duration_in_seconds {
400 let frame_number = second * fps_c;
401
402 match decoder.seek_to_frame(frame_number) {
403 Ok(()) => {
404 debug!("Successfully sought to frame near index {frame_number}.");
405
406 match decoder.decode() {
407 Ok((ts, frame)) => {
408 let frame_time = ts.as_secs_f64();
409 debug!("Frame time: {frame_time}");
410
411 if let Some(rgb) = frame.as_slice() {
412 frames_decoded.push(rgb.to_vec());
413 } else {
414 error!("Failed to get frame buffer as slice for frame");
415 }
416 },
417 Err(e) => {
418 error!("Error decoding frame: {e}");
419 },
420 }
421 },
422 Err(e) => {
423 error!("Error seeking to frame: {e}");
424 },
425 }
426 }
427
428 info!("Elapsed: {:.2?}", start.elapsed());
429 info!("Frames decoded: {}", frames_decoded.len());
430
431 let start = Instant::now();
432 frames_decoded.par_iter().enumerate().for_each(|(n, rgb)| {
433 let path = PathBuf::from(format!("frames/{n}.png"));
434
435 if let Err(e) = save_rgb_to_image(rgb, width, height, path.as_path()) {
436 error!("Error saving image {n}: {e:?}");
437 }
438 });
439 info!("Elapsed saving: {:.2?}", start.elapsed());
440
441 Ok(())
442}
443
444/// Saves raw RGB pixel data as a PNG image at the specified path.
445///
446/// Takes a slice of raw RGB pixel data and creates a PNG image file with
447/// the specified dimensions. The function handles the conversion from raw
448/// bytes to image format and saves the result to disk.
449///
450/// # Arguments
451/// * `raw_pixels` - A slice of raw RGB pixel data (3 bytes per pixel)
452/// * `width` - The width of the image in pixels
453/// * `height` - The height of the image in pixels
454/// * `path` - The destination file path where the image will be saved
455///
456/// # Returns
457/// * `Ok(())` if image was successfully saved
458/// * `Err` if conversion or saving failed
459///
460/// # Panics
461/// This function does not panic but returns errors for invalid inputs.
462///
463/// # Examples
464/// ```
465/// let red_pixel = [255u8, 0, 0];
466/// let pixels = red_pixel.repeat(4); // 2x2 image
467/// save_rgb_to_image(&pixels, 2, 2, Path::new("red_square.png"))?;
468/// ```
469fn save_rgb_to_image(raw_pixels: &[u8], width: u32, height: u32, path: impl AsRef<Path>) -> Result<()> {
470 let img_buffer: RgbImage =
471 RgbImage::from_raw(width, height, raw_pixels.to_vec()).context("Could not create RgbImage from raw data.")?;
472
473 img_buffer.save(path).context("Error saving image")?;
474
475 Ok(())
476}
477
478/// Main entry point for the frame extraction application.
479///
480/// Parses command line arguments, initializes dependencies, and executes
481/// the frame extraction process based on user selections. Supports
482/// sequential processing, seek-based extraction, and parallel segment
483/// processing.
484///
485/// # Processing Modes
486/// * Standard (default) - Sequential frame dropping with
487/// `FRAMES_BETWEEN_EXTRACTED` interval
488/// * Seek-based (--use-seek) - Extract one frame per second using seeking
489/// * Parallel (--multicore) - Process video segments in parallel
490///
491/// # Workflow
492/// 1. Initialize logging and video processing libraries
493/// 2. Create frames/ and segments/ directories
494/// 3. Clean up previous files
495/// 4. Process video based on selected mode
496/// 5. Remove temporary segment files
497///
498/// # Arguments
499/// See Args struct for detailed command line options.
500///
501/// # Returns
502/// * `Ok(())` on successful completion
503/// * `Err` with error details if processing fails
504///
505/// # Example Usage
506/// ```bash
507/// # Basic frame extraction (every 30th frame)
508/// cargo run -- --file input.mp4
509///
510/// # Extract one frame per second
511/// cargo run -- --file input.mp4 --use-seek
512///
513/// # Parallel processing for large videos
514/// cargo run -- --file input.mp4 --multicore
515/// ```
516fn main() -> Result<(), anyhow::Error> {
517 let args = Args::parse();
518
519 tracing_subscriber::fmt::init();
520 video_rs::init().expect("video-rs failed to initialize");
521
522 create_dir_all("frames").context("failed to create frames directory")?;
523 create_dir_all("segments").context("failed to create segments directory")?;
524
525 cleanup_temporary_files()?;
526
527 let path = env::current_dir().context("failed to get current path")?;
528 let frames_path = path.join("frames");
529
530 if args.multicore {
531 let segments = split_into_segments(&args.file, SEGMENT_OUTPUT_PATTERN, SEGMENTED_FILES_PATTERN)?;
532
533 info!("Segments: {}", segments.len());
534
535 let start = Instant::now();
536 segments.par_iter().enumerate().for_each(|(n, path)| {
537 let prefix = format!("segment-{n}");
538
539 if let Err(e) = decode_frames_dropping(&prefix, path, &frames_path) {
540 error!("Error processing segment {n}: {e:?}");
541 }
542 });
543
544 info!("Elapsed total: {:.2?}", start.elapsed());
545 } else if args.use_seek {
546 // FIXME: The seek-based method is experimental and may not produce correct
547 // output.
548 decode_frames_seeking(&args.file)?;
549 } else {
550 decode_frames_dropping("full", &args.file, &frames_path)?;
551 }
552
553 let segments_dir = Path::new("segments");
554 remove_folder(segments_dir)?;
555
556 Ok(())
557}