diff --git a/Cargo.lock b/Cargo.lock index 549a0be..be8838d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -810,6 +810,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "governor" version = "0.7.0" @@ -1281,6 +1287,16 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" +[[package]] +name = "libwebp-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733" +dependencies = [ + "cc", + "glob", +] + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -2880,6 +2896,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webp" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f53152f51fb5af0c08484c33d16cca96175881d1f3dec068c23b31a158c2d99" +dependencies = [ + "image", + "libwebp-sys", +] + [[package]] name = "weezl" version = "0.1.8" @@ -3174,6 +3200,7 @@ dependencies = [ "toml", "tower-service", "wasm-bindgen", + "webp", "worker", "worker-macros", ] diff --git a/Cargo.toml b/Cargo.toml index 4a84dda..eee089f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ env-local = ["axum/http1", "axum/http2", "governor", "clap", "toml", "image/ico", + "lossy-webp", "svg-text", "resvg/system-fonts", "resvg/raster-images", "fontdb/fontconfig" ] reuse-port = [] @@ -48,6 +49,7 @@ tokio = ["dep:tokio", "axum/tokio"] env_logger = ["dep:env_logger"] governor = ["dep:governor"] axum-server = ["dep:axum-server"] +lossy-webp = ["dep:webp"] [dependencies] worker = { version="0.4.2", features=['http', 'axum'], optional = true } @@ -74,6 +76,7 @@ wasm-bindgen = { version = "0.2" } libc = { version = "0.2.162", optional = true } axum-server = { version = "0.7.1", optional = true } fontdb = { version = "0.23", optional = true } +webp = { version = "0.3.0", optional = true } [patch.crates-io] # licensing and webp dependencies diff --git a/README.md b/README.md index 377d10c..975ad65 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Work in progress! Currently to do: - [X] Rate-limiting on local deployment (untested) - [X] Read config from Cloudflare - [X] Timing and Rate-limiting headers (some not available on Cloudflare Workers) +- [ ] Lossy WebP on CF Workers - [ ] Cache Results on Cloudflare KV. - [ ] Handle all possible panics reported by Clippy - [X] Sandboxing the image rendering diff --git a/src/lib.rs b/src/lib.rs index 1e9dcb9..5418b59 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,7 @@ use governor::{ clock::SystemClock, middleware::StateInformationMiddleware, state::keyed::DashMapStateStore, RateLimiter, }; -use post_process::MediaResponse; +use post_process::{CompressionLevel, MediaResponse}; use sandbox::Sandboxing; use serde::Deserialize; @@ -337,6 +337,31 @@ pub struct ImageOptions { } impl ImageOptions { + /// Convert options to compression level + #[must_use] + pub fn compression_level(&self) -> CompressionLevel { + if self.badge == Some(true) { + return CompressionLevel::Med; + } + + if self.avatar == Some(true) { + return CompressionLevel::High; + } + + if self.emoji == Some(true) { + return CompressionLevel::Max; + } + + if self.preview == Some(true) { + return CompressionLevel::Med; + } + + if self.static_ == Some(true) { + return CompressionLevel::Low; + } + + CompressionLevel::None + } /// Whether resizing is requested #[must_use] pub fn requested_resize(&self) -> bool { diff --git a/src/post_process/mod.rs b/src/post_process/mod.rs index 1e5a846..f192159 100644 --- a/src/post_process/mod.rs +++ b/src/post_process/mod.rs @@ -14,7 +14,10 @@ use axum::{ }; use futures::StreamExt; use image::{ - codecs::gif::{GifEncoder, Repeat}, + codecs::{ + gif::{GifEncoder, Repeat}, + jpeg::JpegEncoder, + }, Frames, ImageFormat, }; use sniff::SniffingStream; @@ -180,6 +183,7 @@ where Ok(MediaResponse::ProcessedStaticImage(StaticImage { data: img, format: ImageFormat::WebP, + compression: options.compression_level(), is_https, }) .with_timing_info(TIME_TO_FIRST_BYTE_KEY, ttfb) @@ -285,6 +289,7 @@ where Ok(MediaResponse::ProcessedStaticImage(StaticImage { data: img, format: output_static_format, + compression: options.compression_level(), is_https, }) .with_timing_info(TIME_TO_FIRST_BYTE_KEY, ttfb) @@ -312,6 +317,7 @@ where Ok(MediaResponse::ProcessedStaticImage(StaticImage { data: img, format: output_static_format, + compression: options.compression_level(), is_https, }) .with_timing_info(TIME_TO_FIRST_BYTE_KEY, ttfb) @@ -337,6 +343,7 @@ where Ok(MediaResponse::ProcessedStaticImage(StaticImage { data: result, format: output_static_format, + compression: options.compression_level(), is_https, }) .with_timing_info(TIME_TO_FIRST_BYTE_KEY, ttfb) @@ -527,11 +534,28 @@ impl IntoResponse for PassThru { } } +/// Compression level for static images of possible +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompressionLevel { + /// No compression + None, + /// Low compression + Low, + /// Medium compression + Med, + /// High compression + High, + /// Maximum compression + Max, +} + /// Processed static image pub struct StaticImage { data: image::DynamicImage, format: ImageFormat, + compression: CompressionLevel, + is_https: bool, } @@ -541,7 +565,48 @@ impl IntoResponse for StaticImage { let encoding_begin = crate::timing::Instant::now(); let mut buf = BufWriter::new(Cursor::new(Vec::new())); - self.data.write_to(&mut buf, self.format).unwrap(); + + if self.compression == CompressionLevel::None { + self.data.write_to(&mut buf, self.format).unwrap(); + } else { + match self.format { + #[cfg(feature = "lossy-webp")] + ImageFormat::WebP => { + let enc = match webp::Encoder::from_image(&self.data) { + Ok(enc) => enc, + Err(e) => { + return ErrorResponse::postprocess_failed(e.to_string().into()) + .into_response(); + } + }; + + let webp = match self.compression { + CompressionLevel::Low => enc.encode(80.), + CompressionLevel::Med => enc.encode(60.), + CompressionLevel::High => enc.encode(40.), + CompressionLevel::Max => enc.encode(25.), + _ => enc.encode(100.0), + }; + + buf.write_all(&webp).unwrap(); + } + ImageFormat::Jpeg => { + let mut enc = JpegEncoder::new_with_quality( + &mut buf, + match self.compression { + CompressionLevel::Low => 90, + CompressionLevel::Med => 70, + CompressionLevel::High => 50, + CompressionLevel::Max => 33, + _ => 100, + }, + ); + + enc.encode_image(&self.data).unwrap(); + } + _ => self.data.write_to(&mut buf, self.format).unwrap(), + } + } let mut resp = axum::http::Response::new(Body::from(buf.into_inner().unwrap().into_inner()));