Successfully create thumbnails of animated GIFs
- ID
261ba28- date
2024-08-20 06:35:23+00:00- author
Alex Chan <alex@alexwlchan.net>- parent
e037a34- message
Successfully create thumbnails of animated GIFs This includes: * Creating the parent directory of the new thumbnail * Checking that `ffmpeg` runs correctly * Includes a test for the happy path- changed files
5 files, 238 additions, 32 deletions
Changed files
Cargo.lock (35077) → Cargo.lock (37401)
diff --git a/Cargo.lock b/Cargo.lock
index ec54321..6cf08e1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -301,6 +301,7 @@ dependencies = [
"clap",
"image",
"regex",
+ "tempdir",
]
[[package]]
@@ -403,6 +404,12 @@ dependencies = [
]
[[package]]
+name = "fuchsia-cprng"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
+
+[[package]]
name = "getrandom"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -807,13 +814,26 @@ dependencies = [
[[package]]
name = "rand"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293"
+dependencies = [
+ "fuchsia-cprng",
+ "libc",
+ "rand_core 0.3.1",
+ "rdrand",
+ "winapi",
+]
+
+[[package]]
+name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
- "rand_core",
+ "rand_core 0.6.4",
]
[[package]]
@@ -823,11 +843,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
- "rand_core",
+ "rand_core 0.6.4",
]
[[package]]
name = "rand_core"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b"
+dependencies = [
+ "rand_core 0.4.2",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc"
+
+[[package]]
+name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
@@ -861,7 +896,7 @@ dependencies = [
"once_cell",
"paste",
"profiling",
- "rand",
+ "rand 0.8.5",
"rand_chacha",
"simd_helpers",
"system-deps",
@@ -905,6 +940,15 @@ dependencies = [
]
[[package]]
+name = "rdrand"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2"
+dependencies = [
+ "rand_core 0.3.1",
+]
+
+[[package]]
name = "regex"
version = "1.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -934,6 +978,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
[[package]]
+name = "remove_dir_all"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
name = "rgb"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1050,6 +1103,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
+name = "tempdir"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8"
+dependencies = [
+ "rand 0.4.6",
+ "remove_dir_all",
+]
+
+[[package]]
name = "termtree"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1226,6 +1289,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082"
[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml (191) → Cargo.toml (209)
diff --git a/Cargo.toml b/Cargo.toml
index b5ced55..a472dc4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,3 +8,4 @@ assert_cmd = "2.0.14"
clap = { version = "4", features = ["derive"] }
image = "0.25.1"
regex = "1.10.5"
+tempdir = "0.3.7"
src/create_parent_directory.rs (0) → src/create_parent_directory.rs (1216)
diff --git a/src/create_parent_directory.rs b/src/create_parent_directory.rs
new file mode 100644
index 0000000..8be75e0
--- /dev/null
+++ b/src/create_parent_directory.rs
@@ -0,0 +1,44 @@
+use std::fs;
+use std::io::Result;
+use std::path::PathBuf;
+
+/// Create the parent directory of a given path.
+///
+/// Example:
+///
+/// create_parent_directory("path/to/images/index.html")
+/// ~> creates "path/to/images/"
+///
+pub fn create_parent_directory(path: &PathBuf) -> Result<()> {
+ // Quoting from the Rust docs for PathBuf.parent() [1]:
+ //
+ // Returns None if the path terminates in a root or prefix,
+ // or if it’s the empty string.
+ //
+ // This function should only ever be called on paths to files, so
+ // .parent() will never return None.
+ //
+ // [1]: https://doc.rust-lang.org/std/path/struct.PathBuf.html#method.parent
+ let parent_dir = path.parent().unwrap();
+
+ fs::create_dir_all(&parent_dir)
+}
+
+#[cfg(test)]
+mod test_create_parent_directory {
+ use super::*;
+ use crate::test_utils::test_dir;
+
+ #[test]
+ fn it_creates_a_directory() {
+ let t = test_dir();
+ assert!(!t.exists());
+
+ let path = t.join("path/to/images").join("index.html");
+ assert!(create_parent_directory(&path).is_ok());
+
+ assert!(t.exists());
+ assert!(t.join("path/to/images").exists());
+ assert!(!path.exists());
+ }
+}
src/create_thumbnail.rs (0) → src/create_thumbnail.rs (1565)
diff --git a/src/create_thumbnail.rs b/src/create_thumbnail.rs
new file mode 100644
index 0000000..28845e7
--- /dev/null
+++ b/src/create_thumbnail.rs
@@ -0,0 +1,52 @@
+use std::io;
+use std::path::PathBuf;
+use std::process::Command;
+use std::str;
+
+/// Create a thumbnail for an animated GIF.
+///
+/// This will use `ffmpeg` to create an MP4 file of the desired dimensions
+/// which plays the GIF on a loop. This is typically much smaller and more
+/// space-efficient than creating a resized GIF.
+///
+/// This function assumes that the associated GIF file already exists.
+///
+/// TODO: It would be nice to have a test for the case where `ffmpeg` isn't
+/// installed, but I'm not sure how to simulate that.
+///
+pub fn create_animated_gif_thumbnail(
+ gif_path: &PathBuf,
+ out_dir: &PathBuf,
+ width: u32,
+ height: u32,
+) -> io::Result<PathBuf> {
+ let file_name = gif_path.file_name().unwrap();
+ let thumbnail_path = out_dir.join(file_name).with_extension("mp4");
+
+ let dimension_str = format!("scale={}:{}", width, height);
+
+ let output = Command::new("ffmpeg")
+ .args([
+ "-i",
+ gif_path.to_str().unwrap(),
+ "-movflags",
+ "faststart",
+ "-pix_fmt",
+ "yuv420p",
+ "-vf",
+ &dimension_str,
+ thumbnail_path.to_str().unwrap(),
+ ])
+ .output()
+ .expect("failed to create thumbnail");
+
+ if output.status.success() {
+ Ok(thumbnail_path)
+ } else {
+ let error_message = format!(
+ "Unable to invoke ffmpeg!\nstderr from ffmpeg:\n{}",
+ str::from_utf8(&output.stderr).unwrap()
+ );
+ Err(io::Error::new(io::ErrorKind::Other, error_message))
+ }
+}
src/main.rs (5142) → src/main.rs (5891)
diff --git a/src/main.rs b/src/main.rs
index 2a949ee..bb6beaf 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,14 +2,16 @@
use std::io;
use std::path::PathBuf;
-use std::process::Command;
use clap::{ArgGroup, Parser};
use image::imageops::FilterType;
+mod create_parent_directory;
+mod create_thumbnail;
mod get_thumbnail_dimensions;
mod is_animated_gif;
+use crate::create_parent_directory::create_parent_directory;
use crate::get_thumbnail_dimensions::get_thumbnail_dimensions;
use crate::is_animated_gif::is_animated_gif;
@@ -22,6 +24,7 @@ pub fn create_thumbnail(
width: Option<u32>,
) -> io::Result<PathBuf> {
let thumbnail_path = out_dir.join(path.file_name().unwrap());
+ create_parent_directory(&thumbnail_path)?;
// TODO: Does this check do what I think?
assert!(*path != thumbnail_path);
@@ -33,24 +36,7 @@ pub fn create_thumbnail(
//
if is_animated_gif(path)? {
- let mp4_path = thumbnail_path.with_extension("mp4");
-
- Command::new("ffmpeg")
- .args([
- "-i",
- path.to_str().unwrap(),
- "-movflags",
- "faststart",
- "-pix_fmt",
- "yuv420p",
- "-vf",
- &format!("scale={}:{}", new_width, new_height),
- mp4_path.to_str().unwrap(),
- ])
- .output()
- .expect("failed to create thumbnail");
-
- Ok(mp4_path)
+ create_thumbnail::create_animated_gif_thumbnail(path, out_dir, new_width, new_height)
} else {
println!("thumbnail_path = {:?}", thumbnail_path);
let img = image::open(path).unwrap();
@@ -63,6 +49,28 @@ pub fn create_thumbnail(
}
}
+#[cfg(test)]
+mod test_create_thumbnail {
+ use std::path::PathBuf;
+
+ use super::create_thumbnail;
+ use crate::test_utils::test_dir;
+
+ #[test]
+ fn creates_an_animated_gif_thumbnail() {
+ let gif_path = PathBuf::from("src/tests/animated_squares.gif");
+ let out_dir = test_dir();
+ let target_width = Some(16);
+ let target_height = None;
+
+ let thumbnail_path =
+ create_thumbnail(&gif_path, &out_dir, target_width, target_height).unwrap();
+
+ assert_eq!(thumbnail_path, out_dir.join("animated_squares.mp4"));
+ assert!(thumbnail_path.exists());
+ }
+}
+
#[derive(Debug, Parser)]
#[clap(version, about)]
#[clap(group(
@@ -97,12 +105,10 @@ fn main() {
#[cfg(test)]
mod test_cli {
- use std::str;
-
- use assert_cmd::assert::OutputAssertExt;
- use assert_cmd::Command;
use regex::Regex;
+ use crate::test_utils::{get_failure, get_success};
+
#[test]
fn it_errors_if_you_pass_width_and_height() {
let output = get_failure(&[
@@ -155,14 +161,32 @@ mod test_cli {
assert_eq!(output.exit_code, 0);
assert_eq!(output.stderr, "");
}
+}
+
+#[cfg(test)]
+pub mod test_utils {
+ use std::path::PathBuf;
+ use std::str;
+
+ use assert_cmd::assert::OutputAssertExt;
+ use assert_cmd::Command;
+
+ /// Return a path to a temporary directory to use for testing.
+ ///
+ /// This function does *not* create the directory, just the path.
+ pub fn test_dir() -> PathBuf {
+ let tmp_dir = tempdir::TempDir::new("testing").unwrap();
+
+ tmp_dir.path().to_owned()
+ }
- struct DcOutput {
- exit_code: i32,
- stdout: String,
- stderr: String,
+ pub struct DcOutput {
+ pub exit_code: i32,
+ pub stdout: String,
+ pub stderr: String,
}
- fn get_success(args: &[&str]) -> DcOutput {
+ pub fn get_success(args: &[&str]) -> DcOutput {
let mut cmd = Command::cargo_bin("create_thumbnail").unwrap();
let output = cmd
.args(args)
@@ -179,7 +203,7 @@ mod test_cli {
}
}
- fn get_failure(args: &[&str]) -> DcOutput {
+ pub fn get_failure(args: &[&str]) -> DcOutput {
let mut cmd = Command::cargo_bin("create_thumbnail").unwrap();
let output = cmd.args(args).unwrap_err().as_output().unwrap().to_owned();