Avoid shelling out to imagemagick for some blurhash operations

This commit is contained in:
asonix 2024-12-10 18:45:16 -06:00
parent 31e17b4d62
commit 7ba67cff22
9 changed files with 244 additions and 26 deletions

112
Cargo.lock generated
View file

@ -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",
]

View file

@ -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"

View file

@ -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<S>(
hash: Hash,
original_details: &Details,
) -> Result<String, Error>
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<S>(
state: &State<S>,
hash: Hash,
original_details: &Details,
) -> Result<String, Error>
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<String>
})
.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<String>
})
.await??;
Ok(blurhash)
}
}
}
async fn read_rgba_command<S>(

View file

@ -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,
}

View file

@ -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",

View file

@ -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 {

View file

@ -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,
}
}
}

View file

@ -99,4 +99,14 @@ impl ImageFormat {
Self::Webp => super::mimes::image_webp(),
}
}
pub(super) const fn image_rs_format(self) -> Option<image::ImageFormat> {
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),
}
}
}

View file

@ -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!(