extract_frames/
main.rs

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