Skip to content

Commit 006ce68

Browse files
authored
Merge pull request #60 from alexwlchan/animated-webp
Add supported for animated WebP images
2 parents 6ee6e34 + b46b850 commit 006ce68

9 files changed

+147
-38
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## v1.3.0 - 2024-09-04
4+
5+
* Add support for animated WebP images.
6+
* Improve the error messages, especially when dealing with malformed images.
7+
38
## v1.2.0 - 2024-05-12
49

510
Two new features:

Cargo.lock

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "dominant_colours"
3-
version = "1.2.0"
3+
version = "1.3.0"
44
edition = "2018"
55

66
[dependencies]

src/get_image_colors.rs

+74-30
Original file line numberDiff line numberDiff line change
@@ -6,40 +6,95 @@
66
//
77
// It returns a Vec<Lab>, which can be passed to the k-means process.
88

9-
use std::ffi::OsStr;
9+
use std::fmt::Display;
1010
use std::fs::File;
1111
use std::io::BufReader;
1212
use std::path::PathBuf;
1313

1414
use image::codecs::gif::GifDecoder;
15+
use image::codecs::webp::WebPDecoder;
1516
use image::imageops::FilterType;
16-
use image::{AnimationDecoder, DynamicImage, Frame};
17+
use image::{AnimationDecoder, DynamicImage, Frame, ImageFormat};
1718
use palette::cast::from_component_slice;
1819
use palette::{IntoColor, Lab, Srgba};
1920

20-
pub fn get_image_colors(path: &PathBuf) -> Vec<Lab> {
21-
let image_bytes = match path.extension().and_then(OsStr::to_str) {
22-
Some(ext) if ext.to_lowercase() == "gif" => get_bytes_for_gif(&path),
23-
_ => get_bytes_for_non_gif(&path),
21+
pub fn get_image_colors(path: &PathBuf) -> Result<Vec<Lab>, GetImageColorsErr> {
22+
let format = get_format(path)?;
23+
24+
let f = File::open(path)?;
25+
let reader = BufReader::new(f);
26+
27+
let image_bytes = match format {
28+
ImageFormat::Gif => {
29+
let decoder = GifDecoder::new(reader)?;
30+
get_bytes_for_animated_image(decoder)
31+
}
32+
33+
ImageFormat::WebP => {
34+
let decoder = WebPDecoder::new(reader)?;
35+
get_bytes_for_animated_image(decoder)
36+
}
37+
38+
format => {
39+
let decoder = image::load(reader, format)?;
40+
get_bytes_for_static_image(decoder)
41+
}
2442
};
2543

2644
let lab: Vec<Lab> = from_component_slice::<Srgba<u8>>(&image_bytes)
2745
.iter()
2846
.map(|x| x.into_format::<_, f32>().into_color())
2947
.collect();
3048

31-
lab
49+
Ok(lab)
3250
}
3351

34-
fn get_bytes_for_non_gif(path: &PathBuf) -> Vec<u8> {
35-
let img = match image::open(&path) {
36-
Ok(im) => im,
37-
Err(e) => {
38-
eprintln!("{}", e);
39-
std::process::exit(1);
52+
pub enum GetImageColorsErr {
53+
IoError(std::io::Error),
54+
ImageError(image::ImageError),
55+
GetFormatError(String),
56+
}
57+
58+
impl Display for GetImageColorsErr {
59+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60+
match self {
61+
GetImageColorsErr::IoError(io_error) => write!(f, "{}", io_error),
62+
GetImageColorsErr::ImageError(image_error) => write!(f, "{}", image_error),
63+
GetImageColorsErr::GetFormatError(format_error) => write!(f, "{}", format_error),
4064
}
65+
}
66+
}
67+
68+
impl From<std::io::Error> for GetImageColorsErr {
69+
fn from(e: std::io::Error) -> GetImageColorsErr {
70+
return GetImageColorsErr::IoError(e);
71+
}
72+
}
73+
74+
impl From<image::ImageError> for GetImageColorsErr {
75+
fn from(e: image::ImageError) -> GetImageColorsErr {
76+
return GetImageColorsErr::ImageError(e);
77+
}
78+
}
79+
80+
fn get_format(path: &PathBuf) -> Result<ImageFormat, GetImageColorsErr> {
81+
let format = match path.extension() {
82+
Some(ext) => Ok(image::ImageFormat::from_extension(ext)),
83+
None => Err(GetImageColorsErr::GetFormatError(
84+
"Path has no file extension, so could not determine image format".to_string(),
85+
)),
4186
};
4287

88+
match format {
89+
Ok(Some(format)) => Ok(format),
90+
Ok(None) => Err(GetImageColorsErr::GetFormatError(
91+
"Unable to determine image format from file extension".to_string(),
92+
)),
93+
Err(e) => Err(e),
94+
}
95+
}
96+
97+
fn get_bytes_for_static_image(img: DynamicImage) -> Vec<u8> {
4398
// Resize the image after we open it. For this tool I'd rather get a good answer
4499
// quickly than a great answer slower.
45100
//
@@ -60,20 +115,10 @@ fn get_bytes_for_non_gif(path: &PathBuf) -> Vec<u8> {
60115
resized_img.into_rgba8().into_raw()
61116
}
62117

63-
fn get_bytes_for_gif(path: &PathBuf) -> Vec<u8> {
64-
let f = match File::open(path) {
65-
Ok(im) => im,
66-
Err(e) => {
67-
eprintln!("{}", e);
68-
std::process::exit(1);
69-
}
70-
};
71-
72-
let f = BufReader::new(f);
73-
74-
let decoder = GifDecoder::new(f).ok().unwrap();
118+
fn get_bytes_for_animated_image<'a>(decoder: impl AnimationDecoder<'a>) -> Vec<u8> {
119+
let frames: Vec<Frame> = decoder.into_frames().collect_frames().unwrap();
75120

76-
// If the GIF is animated, we want to make sure we look at multiple
121+
// If the image is animated, we want to make sure we look at multiple
77122
// frames when choosing the dominant colour.
78123
//
79124
// We don't want to pass all the frames to the k-means analysis, because
@@ -82,8 +127,7 @@ fn get_bytes_for_gif(path: &PathBuf) -> Vec<u8> {
82127
//
83128
// For that reason, we select a sample of up to 50 frames and use those
84129
// as the basis for analysis.
85-
let frames: Vec<Frame> = decoder.into_frames().collect_frames().unwrap();
86-
130+
//
87131
// How this works: it tells us we should be looking at the nth frame.
88132
// Examples:
89133
//
@@ -144,11 +188,11 @@ mod test {
144188
// processed correctly.
145189
#[test]
146190
fn it_gets_colors_for_mri_fruit() {
147-
get_image_colors(&PathBuf::from("./src/tests/garlic.gif"));
191+
assert!(get_image_colors(&PathBuf::from("./src/tests/garlic.gif")).is_ok());
148192
}
149193

150194
#[test]
151195
fn get_colors_for_webp() {
152-
get_image_colors(&PathBuf::from("./src/tests/purple.webp"));
196+
assert!(get_image_colors(&PathBuf::from("./src/tests/purple.webp")).is_ok());
153197
}
154198
}

src/main.rs

+64-6
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ struct Cli {
3131
fn main() {
3232
let cli = Cli::parse();
3333

34-
let lab: Vec<Lab> = get_image_colors::get_image_colors(&cli.path);
34+
let lab: Vec<Lab> = match get_image_colors::get_image_colors(&cli.path) {
35+
Ok(lab) => lab,
36+
Err(e) => {
37+
eprintln!("{}", e);
38+
std::process::exit(1);
39+
}
40+
};
3541

3642
let dominant_colors = find_dominant_colors::find_dominant_colors(&lab, cli.max_colours);
3743

@@ -148,11 +154,10 @@ mod tests {
148154

149155
#[test]
150156
fn it_looks_at_multiple_frames_in_an_animated_gif() {
151-
let output = get_success(&["./src/tests/animated_squares.gif"]);
157+
let output = get_success(&["./src/tests/animated_squares.gif", "--no-palette"]);
152158

153159
assert_eq!(
154-
output.stdout.matches("\n").count(),
155-
2,
160+
output.stdout, "#0200ff\n#ff0000\n",
156161
"stdout = {:?}",
157162
output.stdout
158163
);
@@ -170,6 +175,17 @@ mod tests {
170175
);
171176
}
172177

178+
#[test]
179+
fn it_looks_at_multiple_frames_in_an_animated_webp() {
180+
let output = get_success(&["./src/tests/animated_squares.webp", "--no-palette"]);
181+
182+
assert_eq!(
183+
output.stdout, "#0200ff\n#ff0100\n#ff0002\n",
184+
"stdout = {:?}",
185+
output.stdout
186+
);
187+
}
188+
173189
#[test]
174190
fn it_fails_if_you_pass_an_invalid_max_colours() {
175191
let output = get_failure(&["./src/tests/red.png", "--max-colours=NaN"]);
@@ -206,7 +222,10 @@ mod tests {
206222

207223
assert_eq!(output.exit_code, 1);
208224
assert_eq!(output.stdout, "");
209-
assert_eq!(output.stderr, "The image format could not be determined\n");
225+
assert_eq!(
226+
output.stderr,
227+
"Unable to determine image format from file extension\n"
228+
);
210229
}
211230

212231
#[test]
@@ -215,7 +234,10 @@ mod tests {
215234

216235
assert_eq!(output.exit_code, 1);
217236
assert_eq!(output.stdout, "");
218-
assert_eq!(output.stderr, "The image format could not be determined\n");
237+
assert_eq!(
238+
output.stderr,
239+
"Unable to determine image format from file extension\n"
240+
);
219241
}
220242

221243
#[test]
@@ -230,6 +252,42 @@ mod tests {
230252
);
231253
}
232254

255+
#[test]
256+
fn it_fails_if_you_pass_a_malformed_gif() {
257+
let output = get_failure(&["./src/tests/malformed.txt.gif"]);
258+
259+
assert_eq!(output.exit_code, 1);
260+
assert_eq!(output.stdout, "");
261+
assert_eq!(
262+
output.stderr,
263+
"Format error decoding Gif: malformed GIF header\n"
264+
);
265+
}
266+
267+
#[test]
268+
fn it_fails_if_you_pass_a_malformed_webp() {
269+
let output = get_failure(&["./src/tests/malformed.txt.webp"]);
270+
271+
assert_eq!(output.exit_code, 1);
272+
assert_eq!(output.stdout, "");
273+
assert_eq!(
274+
output.stderr,
275+
"Format error decoding WebP: Invalid Chunk header: [82, 73, 70, 70]\n"
276+
);
277+
}
278+
279+
#[test]
280+
fn it_fails_if_you_pass_a_path_without_a_file_extension() {
281+
let output = get_failure(&["./src/tests/noextension"]);
282+
283+
assert_eq!(output.exit_code, 1);
284+
assert_eq!(output.stdout, "");
285+
assert_eq!(
286+
output.stderr,
287+
"Path has no file extension, so could not determine image format\n"
288+
);
289+
}
290+
233291
#[test]
234292
fn it_chooses_the_right_color_for_a_dark_background() {
235293
let output = get_success(&[

src/tests/animated_squares.webp

804 Bytes
Binary file not shown.

src/tests/malformed.txt.gif

+1
Loading

src/tests/malformed.txt.webp

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Ceci n'est pas une png

src/tests/noextension

Whitespace-only changes.

0 commit comments

Comments
 (0)