Skip to content

Commit

Permalink
Add JPEG XL support to image processing.
Browse files Browse the repository at this point in the history
As discussed in #2421.
  • Loading branch information
veluca93 committed May 14, 2024
1 parent a4f123d commit 3eda012
Show file tree
Hide file tree
Showing 9 changed files with 210 additions and 12 deletions.
115 changes: 115 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions components/imageproc/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ const DEFAULT_Q_JPG: u8 = 75;
/// Thumbnail image format
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
/// JPEG, The `u8` argument is JPEG quality (in percent).
/// JPEG, The `u8` argument is JPEG quality (1..100).
Jpeg(u8),
/// PNG
Png,
/// WebP, The `u8` argument is WebP quality (in percent), None meaning lossless.
/// WebP, The `u8` argument is WebP quality (1..100), None meaning lossless.
WebP(Option<u8>),
/// JPEG XL, The `u8` argument is quality (1..100), None meaning lossless.
JXL(Option<u8>),
}

impl Format {
Expand All @@ -32,6 +34,7 @@ impl Format {
"jpeg" | "jpg" => Ok(Jpeg(jpg_quality)),
"png" => Ok(Png),
"webp" => Ok(WebP(quality)),
"jxl" => Ok(JXL(quality)),
_ => Err(anyhow!("Invalid image format: {}", format)),
}
}
Expand All @@ -44,6 +47,7 @@ impl Format {
Png => "png",
Jpeg(_) => "jpg",
WebP(_) => "webp",
JXL(_) => "jxl",
}
}
}
Expand All @@ -58,6 +62,8 @@ impl Hash for Format {
Jpeg(q) => 1001 + q as u16,
WebP(None) => 2000,
WebP(Some(q)) => 2001 + q as u16,
JXL(None) => 3000,
JXL(Some(q)) => 3001 + q as u16,
};

hasher.write_u16(q);
Expand Down
31 changes: 25 additions & 6 deletions components/imageproc/src/meta.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use errors::{anyhow, Context, Result};
use libs::image::io::Reader as ImgReader;
use libs::image::{ImageFormat, ImageResult};
use libs::image::ImageFormat;
use libs::jpegxl_rs::decoder_builder;
use libs::svg_metadata::Metadata as SvgMetadata;
use serde::Serialize;
use std::ffi::OsStr;
Expand All @@ -15,12 +16,23 @@ pub struct ImageMeta {
}

impl ImageMeta {
pub fn read(path: &Path) -> ImageResult<Self> {
let reader = ImgReader::open(path).and_then(ImgReader::with_guessed_format)?;
let format = reader.format();
let size = reader.into_dimensions()?;
pub fn read(path: &Path) -> Result<Self> {
if path.extension().is_some_and(|ext| ext == "jxl") {
Self::read_jxl(path)
} else {
let reader = ImgReader::open(path).and_then(ImgReader::with_guessed_format)?;
let format = reader.format();
let size = reader.into_dimensions()?;

Ok(Self { size, format })
Ok(Self { size, format })
}
}

fn read_jxl(path: &Path) -> Result<Self> {
let input = std::fs::read(path)?;
let decoder = decoder_builder().build()?;
let (meta, _) = decoder.decode(&input)?;
Ok(ImageMeta { size: (meta.width, meta.height), format: None })
}

pub fn is_lossy(&self) -> bool {
Expand All @@ -44,6 +56,9 @@ impl ImageMetaResponse {
pub fn new_svg(width: u32, height: u32) -> Self {
Self { width, height, format: Some("svg"), mime: Some("text/svg+xml") }
}
pub fn new_jxl(width: u32, height: u32) -> Self {
Self { width, height, format: Some("jxl"), mime: Some("image/jxl") }
}
}

impl From<ImageMeta> for ImageMetaResponse {
Expand Down Expand Up @@ -75,6 +90,10 @@ pub fn read_image_metadata<P: AsRef<Path>>(path: P) -> Result<ImageMetaResponse>
// this is not a typo, this returns the correct values for width and height.
.map(|(h, w)| ImageMetaResponse::new_svg(w as u32, h as u32))
}
"jxl" => {
let meta = ImageMeta::read(path)?;
Ok(ImageMetaResponse::new_jxl(meta.size.0, meta.size.1))
}
_ => ImageMeta::read(path).map(ImageMetaResponse::from).with_context(err_context),
}
}
53 changes: 49 additions & 4 deletions components/imageproc/src/processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};

use config::Config;
use errors::{anyhow, Context, Result};
use errors::{anyhow, bail, Context, Result};
use libs::ahash::{HashMap, HashSet};
use libs::image::codecs::jpeg::JpegEncoder;
use libs::image::imageops::FilterType;
use libs::image::{EncodableLayout, ImageFormat};
use libs::image::{ColorType, EncodableLayout, ImageFormat};
use libs::jpegxl_rs::decoder_builder;
use libs::jpegxl_rs::encode::EncoderFrame;
use libs::jpegxl_rs::image::ToDynamic;
use libs::rayon::prelude::*;
use libs::{image, webp};
use libs::{image, jpegxl_rs, webp};
use serde::{Deserialize, Serialize};
use utils::fs as ufs;

Expand Down Expand Up @@ -39,7 +42,15 @@ impl ImageOp {
return Ok(());
}

let img = image::open(&self.input_path)?;
let img = if self.input_path.extension().is_some_and(|ext| ext == "jxl") {
let input = std::fs::read(&self.input_path)?;
let decoder = decoder_builder().build()?;
decoder
.decode_to_image(&input)?
.context("jxl image could not be represented in an Image")?
} else {
image::open(&self.input_path)?
};
let mut img = fix_orientation(&img, &self.input_path).unwrap_or(img);

let img = match self.instr.crop_instruction {
Expand Down Expand Up @@ -71,6 +82,40 @@ impl ImageOp {
};
buffered_f.write_all(memory.as_bytes())?;
}
Format::JXL(q) => {
let mut encoder = jpegxl_rs::encoder_builder();
if let Some(q) = q {
if q == 100 {
encoder.uses_original_profile(true);
encoder.lossless(true);
} else {
encoder.set_jpeg_quality(q as f32);
}
} else {
encoder.uses_original_profile(true);
encoder.lossless(true);
}
let frame = EncoderFrame::new(img.as_bytes());
let frame = match img.color() {
ColorType::L8 => frame.num_channels(1),
ColorType::La8 => {
encoder.has_alpha(true);
frame.num_channels(2)
}
ColorType::Rgb8 => frame.num_channels(3),
ColorType::Rgba8 => {
encoder.has_alpha(true);
frame.num_channels(4)
}
_ => {
bail!("Unsupported pixel type {:?}", img.color());
}
};
let mut encoder = encoder.build()?;
buffered_f.write_all(
&encoder.encode_frame::<u8, u8>(&frame, img.width(), img.height())?.data,
)?;
}
}

Ok(())
Expand Down
10 changes: 10 additions & 0 deletions components/imageproc/tests/resize_image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,16 @@ fn resize_image_webp_jpg() {
image_op_test("webp.webp", "scale", Some(150), Some(150), "auto", "jpg", 150, 150, 300, 380);
}

#[test]
fn resize_image_png_jxl() {
image_op_test("png.png", "scale", Some(150), Some(150), "jxl", "jxl", 150, 150, 300, 380);
}

#[test]
fn resize_image_jxl_png() {
image_op_test("jxl.jxl", "scale", Some(150), Some(150), "png", "png", 150, 150, 300, 380);
}

#[test]
fn read_image_metadata_jpg() {
assert_eq!(
Expand Down
Binary file added components/imageproc/tests/test_imgs/jxl.jxl
Binary file not shown.
1 change: 1 addition & 0 deletions components/libs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ unicode-segmentation = "1.2"
url = "2"
walkdir = "2"
webp = "0.3"
jpegxl-rs = { version = "0.10.3", features = ["vendored"] }


[features]
Expand Down
1 change: 1 addition & 0 deletions components/libs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub use glob;
pub use globset;
pub use grass;
pub use image;
pub use jpegxl_rs;
pub use lexical_sort;
pub use minify_html;
pub use nom_bibtex;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ resize_image(path, width, height, op, format, quality)
- `"jpg"`
- `"png"`
- `"webp"`
- `"jxl"`

The default is `"auto"`, this means that the format is chosen based on input image format.
JPEG is chosen for JPEGs and other lossy formats, and PNG is chosen for PNGs and other lossless formats.
Expand Down

0 comments on commit 3eda012

Please sign in to comment.