diff --git a/Cargo.lock b/Cargo.lock index 4ca17b7..eddb816 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -521,12 +521,24 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +[[package]] +name = "bytemuck" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b37c88a63ffd85d15b406896cc343916d7cf57838a847b3a6f2ca5d39a5695a" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.9.0" @@ -643,6 +655,12 @@ dependencies = [ "tracing-error", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.3" @@ -1016,6 +1034,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "flagset" version = "0.4.6" @@ -1184,6 +1211,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.28.1" @@ -1610,6 +1647,33 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e031e8e3d94711a9ccb5d6ea357439ef3dcbed361798bd4071dc4d9793fbe22f" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "impl-more" version = "0.1.8" @@ -1864,6 +1928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -2168,6 +2233,7 @@ dependencies = [ "diesel-derive-enum", "futures-core", "hex", + "image", "md-5", "metrics", "metrics-exporter-prometheus", @@ -2244,6 +2310,19 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "png" +version = "0.17.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67582bd5b65bdff614270e2ea89a1cf15bef71245cc1e5f7ea126977144211d" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.0", +] + [[package]] name = "portable-atomic" version = "1.10.0" @@ -2368,6 +2447,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.36.2" @@ -2982,6 +3067,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "siphasher" version = "0.3.11" @@ -3876,6 +3967,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + [[package]] name = "whoami" version = "1.5.2" @@ -4190,3 +4287,18 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16099418600b4d8f028622f73ff6e3deaabdff330fb9a2a131dea781ee8b0768" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index b8eb104..d716117 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,7 @@ url = { version = "2.5.2", features = ["serde"] } uuid = { version = "1.10.0", features = ["serde", "std", "v4", "v7"] } # pinned to rustls webpki-roots = "0.26.3" +image = { version = "0.25.5", default-features = false, features = ["gif", "jpeg", "png", "webp"] } [dependencies.tracing-actix-web] version = "0.7.15" diff --git a/src/blurhash.rs b/src/blurhash.rs index d3e1344..5f6246c 100644 --- a/src/blurhash.rs +++ b/src/blurhash.rs @@ -1,11 +1,16 @@ -use std::ffi::{OsStr, OsString}; +use std::{ + ffi::{OsStr, OsString}, + time::Duration, +}; use tokio::io::AsyncReadExt; use crate::{ + bytes_stream::BytesStream, details::Details, error::{Error, UploadError}, formats::ProcessableFormat, + future::{WithMetrics, WithTimeout}, magick::{MagickError, MAGICK_CONFIGURE_PATH, MAGICK_TEMPORARY_PATH}, process::Process, repo::Hash, @@ -18,6 +23,27 @@ pub(crate) async fn generate( hash: Hash, original_details: &Details, ) -> Result +where + S: Store + 'static, +{ + let permit = crate::process_semaphore().acquire().await?; + + let blurhash = do_generate(state, hash, original_details) + .with_timeout(Duration::from_secs(state.config.media.process_timeout * 4)) + .with_metrics(crate::init_metrics::GENERATE_BLURHASH) + .await + .map_err(|_| UploadError::ProcessTimeout)??; + + drop(permit); + + Ok(blurhash) +} + +pub(crate) async fn do_generate( + state: &State, + hash: Hash, + original_details: &Details, +) -> Result where S: Store + 'static, { @@ -35,38 +61,78 @@ where let stream = state.store.to_stream(&identifier, None, None).await?; - let blurhash = read_rgba_command( - state, - input_details - .internal_format() - .processable_format() - .expect("not a video"), - ) - .await? - .drive_with_stream(stream) - .with_stdout(|mut stdout| async move { - let mut encoder = blurhash_update::Encoder::auto(blurhash_update::ImageBounds { - width: input_details.width() as _, - height: input_details.height() as _, - }); + match input_details.internal_format().image_rs_format() { + // supported pure-rust image decoders + Some( + format @ image::ImageFormat::Gif + | format @ image::ImageFormat::Jpeg + | format @ image::ImageFormat::Png + | format @ image::ImageFormat::WebP, + ) => { + let bytes_stream = BytesStream::try_from_stream(stream).await?; - let mut buf = [0u8; 1024 * 8]; + let blurhash = crate::sync::spawn_blocking("image-blurhash", move || { + let mut vec = Vec::with_capacity(bytes_stream.len()); - loop { - let n = stdout.read(&mut buf).await?; + for bytes in bytes_stream { + vec.extend(bytes); + } - if n == 0 { - break; - } + let raw_image = image::ImageReader::with_format(std::io::Cursor::new(vec), format) + .decode() + .map_err(UploadError::Decode)? + .into_rgba8(); - encoder.update(&buf[..n]); + let blurhash = blurhash_update::auto_encode( + blurhash_update::ImageBounds { + width: raw_image.width(), + height: raw_image.height(), + }, + raw_image.as_raw(), + ); + + Ok(blurhash) as Result<_, UploadError> + }) + .await + .map_err(|_| UploadError::Canceled)??; + + Ok(blurhash) } + _ => { + let blurhash = read_rgba_command( + state, + input_details + .internal_format() + .processable_format() + .expect("not a video"), + ) + .await? + .drive_with_stream(stream) + .with_stdout(|mut stdout| async move { + let mut encoder = blurhash_update::Encoder::auto(blurhash_update::ImageBounds { + width: input_details.width() as _, + height: input_details.height() as _, + }); - Ok(encoder.finalize()) as std::io::Result - }) - .await??; + let mut buf = [0u8; 1024 * 8]; - Ok(blurhash) + loop { + let n = stdout.read(&mut buf).await?; + + if n == 0 { + break; + } + + encoder.update(&buf[..n]); + } + + Ok(encoder.finalize()) as std::io::Result + }) + .await??; + + Ok(blurhash) + } + } } async fn read_rgba_command( diff --git a/src/error.rs b/src/error.rs index d5731c9..b040126 100644 --- a/src/error.rs +++ b/src/error.rs @@ -174,6 +174,9 @@ pub(crate) enum UploadError { #[error("Failed external validation")] FailedExternalValidation, + #[error("Failed to decode image")] + Decode(#[source] image::error::ImageError), + #[cfg(feature = "random-errors")] #[error("Randomly generated error for testing purposes")] RandomError, @@ -217,6 +220,7 @@ impl UploadError { Self::InvalidJob(_, _) => ErrorCode::INVALID_JOB, Self::InvalidQuery(_) => ErrorCode::INVALID_QUERY, Self::InvalidJson(_) => ErrorCode::INVALID_JSON, + Self::Decode(_) => ErrorCode::DECODE_IMAGE, #[cfg(feature = "random-errors")] Self::RandomError => ErrorCode::RANDOM_ERROR, } diff --git a/src/error_code.rs b/src/error_code.rs index f11b919..21d5a52 100644 --- a/src/error_code.rs +++ b/src/error_code.rs @@ -153,6 +153,9 @@ impl ErrorCode { pub(crate) const INVALID_JSON: ErrorCode = ErrorCode { code: "invalid-json", }; + pub(crate) const DECODE_IMAGE: ErrorCode = ErrorCode { + code: "decode-image", + }; #[cfg(feature = "random-errors")] pub(crate) const RANDOM_ERROR: ErrorCode = ErrorCode { code: "random-error", diff --git a/src/formats.rs b/src/formats.rs index 3759e8b..cb55594 100644 --- a/src/formats.rs +++ b/src/formats.rs @@ -138,6 +138,14 @@ impl InternalFormat { Self::Video(_) => None, } } + + pub(crate) const fn image_rs_format(self) -> Option<::image::ImageFormat> { + match self { + Self::Image(format) => format.image_rs_format(), + Self::Animation(format) => Some(format.image_rs_format()), + Self::Video(_) => None, + } + } } impl ProcessableFormat { diff --git a/src/formats/animation.rs b/src/formats/animation.rs index e184b8e..eb8d1a8 100644 --- a/src/formats/animation.rs +++ b/src/formats/animation.rs @@ -81,4 +81,13 @@ impl AnimationFormat { Self::Webp => super::mimes::image_webp(), } } + + pub(super) const fn image_rs_format(self) -> image::ImageFormat { + match self { + Self::Apng => image::ImageFormat::Png, + Self::Avif => image::ImageFormat::Avif, + Self::Gif => image::ImageFormat::Gif, + Self::Webp => image::ImageFormat::WebP, + } + } } diff --git a/src/formats/image.rs b/src/formats/image.rs index 6af5421..3f53c6b 100644 --- a/src/formats/image.rs +++ b/src/formats/image.rs @@ -99,4 +99,14 @@ impl ImageFormat { Self::Webp => super::mimes::image_webp(), } } + + pub(super) const fn image_rs_format(self) -> Option { + match self { + Self::Avif => Some(image::ImageFormat::Avif), + Self::Jpeg => Some(image::ImageFormat::Jpeg), + Self::Jxl => None, + Self::Png => Some(image::ImageFormat::Png), + Self::Webp => Some(image::ImageFormat::WebP), + } + } } diff --git a/src/init_metrics.rs b/src/init_metrics.rs index e98bd96..22671d7 100644 --- a/src/init_metrics.rs +++ b/src/init_metrics.rs @@ -558,12 +558,17 @@ fn describe_generate() { GENERATE_PROCESS, "Timings for processing media or waiting for media to be processed" ); + metrics::describe_histogram!( + GENERATE_BLURHASH, + "Timings for computing blurhashes for media" + ); } pub(crate) const GENERATE_START: &str = "pict-rs.generate.start"; pub(crate) const GENERATE_DURATION: &str = "pict-rs.generate.duration"; pub(crate) const GENERATE_END: &str = "pict-rs.generate.end"; pub(crate) const GENERATE_PROCESS: &str = "pict-rs.generate.process"; +pub(crate) const GENERATE_BLURHASH: &str = "pict-rs.generate.blurhash"; fn describe_object_storage() { metrics::describe_histogram!(