Skip to main content

Merge pull request #4 from alexwlchan/animated-gif-tests

ID
a972121
date
2024-08-20 09:03:56+00:00
author
Alex Chan <alex@alexwlchan.net>
parents
e037a34, 72785e1
message
Merge pull request #4 from alexwlchan/animated-gif-tests

Successfully create thumbnails of animated GIFs
changed files
7 files, 241 additions, 33 deletions

Changed files

.github/workflows/test.yml (340) → .github/workflows/test.yml (417)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a208df6..c77aaa7 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -15,6 +15,9 @@ jobs:
         with:
           toolchain: stable
 
+      - name: Install dependencies
+        run: sudo apt-get install ffmpeg
+
       - run: cargo build
       - run: cargo test
       - run: cargo fmt --check

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"

README.md (527) → README.md (507)

diff --git a/README.md b/README.md
index ba4d74d..4ac423f 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,6 @@ focusing on a small piece of code makes it better
     -> height only
 
 * happy path:
-    -> animated GIF
     -> static GIF
     -> PNG
     -> JPEG

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();