2use std::process::Command;
5use image::imageops::FilterType;
6use image::{DynamicImage, ImageDecoder, ImageReader};
8use crate::create_parent_directory::create_parent_directory;
9use crate::errors::ThumbnailError;
10use crate::get_thumbnail_dimensions::{get_thumbnail_dimensions, TargetDimension};
11use crate::is_animated_gif::is_animated_gif;
13/// Create a thumbnail for the image, and return the relative path of
14/// the thumbnail within the collection folder.
15pub fn create_thumbnail(
18 target: TargetDimension,
19) -> Result<PathBuf, ThumbnailError> {
20 let file_name = path.file_name().ok_or(ThumbnailError::MissingFileName)?;
21 let thumbnail_path = out_dir.join(file_name);
22 create_parent_directory(&thumbnail_path)?;
24 // Make sure we don't overwrite the original image with a thumbnail
25 if *path == thumbnail_path {
26 return Err(ThumbnailError::SameInputOutputPath);
29 let (new_width, new_height) = get_thumbnail_dimensions(&path, target)?;
31 if is_animated_gif(path)? {
32 create_animated_gif_thumbnail(path, out_dir, new_width, new_height)
34 create_static_thumbnail(path, out_dir, new_width, new_height)
39mod test_create_thumbnail {
40 use std::path::PathBuf;
42 use super::create_thumbnail;
43 use crate::get_thumbnail_dimensions::TargetDimension;
44 use crate::test_utils::{get_dimensions, test_dir};
47 fn creates_an_animated_gif_thumbnail() {
48 let gif_path = PathBuf::from("src/tests/animated_squares.gif");
49 let out_dir = test_dir();
50 let target = TargetDimension::MaxWidth(16);
52 let thumbnail_path = create_thumbnail(&gif_path, &out_dir, target).unwrap();
54 assert_eq!(thumbnail_path, out_dir.join("animated_squares.mp4"));
55 assert!(thumbnail_path.exists());
59 fn creates_an_animated_gif_thumbnail_with_odd_width() {
60 let gif_path = PathBuf::from("src/tests/animated_squares.gif");
61 let out_dir = test_dir();
62 let target = TargetDimension::MaxWidth(15);
64 let thumbnail_path = create_thumbnail(&gif_path, &out_dir, target).unwrap();
66 assert_eq!(thumbnail_path, out_dir.join("animated_squares.mp4"));
67 assert!(thumbnail_path.exists());
71 fn creates_a_static_gif_thumbnail() {
72 let img_path = PathBuf::from("src/tests/yellow.gif");
73 let out_dir = test_dir();
74 let target = TargetDimension::MaxWidth(16);
76 let thumbnail_path = create_thumbnail(&img_path, &out_dir, target).unwrap();
78 assert_eq!(thumbnail_path, out_dir.join("yellow.gif"));
79 assert!(thumbnail_path.exists());
80 assert_eq!(get_dimensions(&thumbnail_path), (16, 8));
84 fn creates_a_png_thumbnail() {
85 let img_path = PathBuf::from("src/tests/red.png");
86 let out_dir = test_dir();
87 let target = TargetDimension::MaxWidth(16);
89 let thumbnail_path = create_thumbnail(&img_path, &out_dir, target).unwrap();
91 assert_eq!(thumbnail_path, out_dir.join("red.png"));
92 assert!(thumbnail_path.exists());
93 assert_eq!(get_dimensions(&thumbnail_path), (16, 32));
97 fn creates_a_jpeg_thumbnail() {
98 let img_path = PathBuf::from("src/tests/noise.jpg");
99 let out_dir = test_dir();
100 let target = TargetDimension::MaxWidth(16);
102 let thumbnail_path = create_thumbnail(&img_path, &out_dir, target).unwrap();
104 assert_eq!(thumbnail_path, out_dir.join("noise.jpg"));
105 assert!(thumbnail_path.exists());
106 assert_eq!(get_dimensions(&thumbnail_path), (16, 32));
110 fn creates_a_tif_thumbnail() {
111 let img_path = PathBuf::from("src/tests/green.tiff");
112 let out_dir = test_dir();
113 let target = TargetDimension::MaxHeight(16);
115 let thumbnail_path = create_thumbnail(&img_path, &out_dir, target).unwrap();
117 assert_eq!(thumbnail_path, out_dir.join("green.tiff"));
118 assert!(thumbnail_path.exists());
119 assert_eq!(get_dimensions(&thumbnail_path), (16, 16));
123 fn creates_a_webp_thumbnail() {
124 let img_path = PathBuf::from("src/tests/purple.webp");
125 let out_dir = test_dir();
126 let target = TargetDimension::MaxWidth(16);
128 let thumbnail_path = create_thumbnail(&img_path, &out_dir, target).unwrap();
130 assert_eq!(thumbnail_path, out_dir.join("purple.webp"));
131 assert!(thumbnail_path.exists());
132 assert_eq!(get_dimensions(&thumbnail_path), (16, 16));
136 fn it_creates_an_equal_size_thumbnail_if_dimension_larger_than_original() {
137 let img_path = PathBuf::from("src/tests/noise.jpg");
138 let out_dir = test_dir();
139 let target = TargetDimension::MaxWidth(500);
141 let thumbnail_path = create_thumbnail(&img_path, &out_dir, target).unwrap();
143 assert_eq!(thumbnail_path, out_dir.join("noise.jpg"));
144 assert!(thumbnail_path.exists());
145 assert_eq!(get_dimensions(&thumbnail_path), (128, 256));
149 fn it_applies_exif_orientation() {
150 // This source image comes from Dave Perrett's exif-orientation-examples
151 // repo, and is used under MIT.
152 // See https://github.com/recurser/exif-orientation-examples
153 let img_path = PathBuf::from("src/tests/Landscape_5.jpg");
154 let out_dir = test_dir();
155 let target = TargetDimension::MaxWidth(180);
157 let thumbnail_path = create_thumbnail(&img_path, &out_dir, target).unwrap();
159 assert_eq!(thumbnail_path, out_dir.join("Landscape_5.jpg"));
160 assert!(thumbnail_path.exists());
161 assert_eq!(get_dimensions(&thumbnail_path), (180, 120));
165/// Return this value if it's even, or the closest value which is even.
166fn ensure_even(x: u32) -> u32 {
174/// Create a thumbnail for an animated GIF.
176/// This will use `ffmpeg` to create an MP4 file of the desired dimensions
177/// which plays the GIF on a loop. This is typically much smaller and more
178/// space-efficient than creating a resized GIF.
180/// This function assumes that the original GIF file definitely exists.
182/// TODO: It would be nice to have a test for the case where `ffmpeg` isn't
183/// installed, but I'm not sure how to simulate that.
185pub fn create_animated_gif_thumbnail(
190) -> Result<PathBuf, ThumbnailError> {
191 let file_name = gif_path
193 .ok_or(ThumbnailError::MissingFileName)?;
195 let thumbnail_path = out_dir.join(file_name).with_extension("mp4");
197 let gif_path_str = gif_path
199 .ok_or(ThumbnailError::PathConversionError)?;
200 let thumbnail_path_str = thumbnail_path
202 .ok_or(ThumbnailError::PathConversionError)?;
204 // There's a subtlety here with ffmpeg I don't understand fully -- if
205 // the width/height aren't even, it doesn't create the MP4, instead
206 // failing with the error:
208 // width not divisible by 2
210 // I don't usually need these files to be pixel-perfect width, so
211 // fudging by a single pixel or two is fine.
212 let dimension_str = format!("scale={}:{}", ensure_even(width), ensure_even(height));
214 let output = Command::new("ffmpeg")
227 .map_err(|e| ThumbnailError::CommandFailed(format!("Failed to run ffmpeg: {}", e)))?;
229 if output.status.success() {
232 let stderr = str::from_utf8(&output.stderr)?;
233 Err(ThumbnailError::CommandFailed(stderr.to_string()))
237/// Create a thumbnail for a static (non-animated) image.
239/// This function assumes that the original image file definitely exists.
241pub fn create_static_thumbnail(
242 image_path: &PathBuf,
246) -> Result<PathBuf, ThumbnailError> {
247 let file_name = image_path
249 .ok_or(ThumbnailError::MissingFileName)?;
251 let thumbnail_path = out_dir.join(file_name);
253 let mut decoder = ImageReader::open(image_path)?.into_decoder()?;
254 let orientation = decoder.orientation()?;
255 let mut img = DynamicImage::from_decoder(decoder)?;
256 img.apply_orientation(orientation);
258 img.resize(width, height, FilterType::Lanczos3)
259 .save(&thumbnail_path)
260 .map_err(ThumbnailError::ImageSaveError)?;