feat: compute thumbhash for images (#596)

* feat: compute thumbhash for images

Signed-off-by: newt <hi@newty.dev>

* Merge branch 'main' into feat/thumbhash

Signed-off-by: newt <hi@newty.dev>

* style: move comment onto newline

Signed-off-by: newt <hi@newty.dev>

* feat: make thumbhash optional

Signed-off-by: newt <hi@newty.dev>

---------

Signed-off-by: newt <hi@newty.dev>
Signed-off-by: newt (: <hi@newty.dev>
Signed-off-by: Tom <iamtomahawkx@gmail.com>
Co-authored-by: Tom <iamtomahawkx@gmail.com>
This commit is contained in:
newt (
2026-03-28 00:29:55 +00:00
committed by GitHub
parent 91783b9066
commit c2d4369e16
7 changed files with 34 additions and 5 deletions

7
Cargo.lock generated
View File

@@ -6824,6 +6824,7 @@ dependencies = [
"simdutf8", "simdutf8",
"strum_macros", "strum_macros",
"tempfile", "tempfile",
"thumbhash",
"tokio 1.49.0", "tokio 1.49.0",
"tower-http 0.5.2", "tower-http 0.5.2",
"tracing", "tracing",
@@ -8835,6 +8836,12 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "thumbhash"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b7726e0245a7331bd0c9a1fb4fd99fd695bcd478ca569f0eda2ff2cb14e7a00"
[[package]] [[package]]
name = "tiff" name = "tiff"
version = "0.10.3" version = "0.10.3"

View File

@@ -45,6 +45,7 @@ auto_derived!(
Image { Image {
width: isize, width: isize,
height: isize, height: isize,
thumbhash: Option<Vec<u8>>,
#[serde(default)] #[serde(default)]
animated: bool, animated: bool,
}, },

View File

@@ -415,11 +415,13 @@ impl From<crate::Metadata> for Metadata {
crate::Metadata::Image { crate::Metadata::Image {
width, width,
height, height,
thumbhash,
animated, animated,
} => Metadata::Image { } => Metadata::Image {
width: width as usize, width: width as usize,
height: height as usize, height: height as usize,
animated: animated as bool, thumbhash,
animated,
}, },
crate::Metadata::Video { width, height } => Metadata::Video { crate::Metadata::Video { width, height } => Metadata::Video {
width: width as usize, width: width as usize,
@@ -438,10 +440,12 @@ impl From<Metadata> for crate::Metadata {
Metadata::Image { Metadata::Image {
width, width,
height, height,
thumbhash,
animated, animated,
} => crate::Metadata::Image { } => crate::Metadata::Image {
width: width as isize, width: width as isize,
height: height as isize, height: height as isize,
thumbhash,
animated, animated,
}, },
Metadata::Video { width, height } => crate::Metadata::Video { Metadata::Video { width, height } => crate::Metadata::Video {
@@ -531,7 +535,9 @@ impl From<crate::SystemMessage> for SystemMessage {
crate::SystemMessage::UserRemove { id, by } => Self::UserRemove { id, by }, crate::SystemMessage::UserRemove { id, by } => Self::UserRemove { id, by },
crate::SystemMessage::MessagePinned { id, by } => Self::MessagePinned { id, by }, crate::SystemMessage::MessagePinned { id, by } => Self::MessagePinned { id, by },
crate::SystemMessage::MessageUnpinned { id, by } => Self::MessageUnpinned { id, by }, crate::SystemMessage::MessageUnpinned { id, by } => Self::MessageUnpinned { id, by },
crate::SystemMessage::CallStarted { by, finished_at } => Self::CallStarted { by, finished_at } crate::SystemMessage::CallStarted { by, finished_at } => {
Self::CallStarted { by, finished_at }
}
} }
} }
} }
@@ -1398,7 +1404,7 @@ impl From<FieldsMessage> for crate::FieldsMessage {
impl From<VoiceInformation> for crate::VoiceInformation { impl From<VoiceInformation> for crate::VoiceInformation {
fn from(value: VoiceInformation) -> Self { fn from(value: VoiceInformation) -> Self {
crate::VoiceInformation { crate::VoiceInformation {
max_users: value.max_users max_users: value.max_users,
} }
} }
} }
@@ -1406,7 +1412,7 @@ impl From<VoiceInformation> for crate::VoiceInformation {
impl From<crate::VoiceInformation> for VoiceInformation { impl From<crate::VoiceInformation> for VoiceInformation {
fn from(value: crate::VoiceInformation) -> Self { fn from(value: crate::VoiceInformation) -> Self {
VoiceInformation { VoiceInformation {
max_users: value.max_users max_users: value.max_users,
} }
} }
} }

View File

@@ -50,6 +50,7 @@ auto_derived!(
Image { Image {
width: usize, width: usize,
height: usize, height: usize,
thumbhash: Option<Vec<u8>>,
animated: bool, animated: bool,
}, },
/// File is a video with specific dimensions /// File is a video with specific dimensions

View File

@@ -17,6 +17,7 @@ jxl-oxide = "0.8.1"
kamadak-exif = "0.5.4" kamadak-exif = "0.5.4"
# revolt_little_exif = "0.5.1" # revolt_little_exif = "0.5.1"
image = { version = "0.25.2" } # avif encode requires dav1d system library: features = ["avif-native"] image = { version = "0.25.2" } # avif encode requires dav1d system library: features = ["avif-native"]
thumbhash = "0.1.0"
# File processing # File processing
revolt_clamav-client = { version = "0.1.5" } revolt_clamav-client = { version = "0.1.5" }

View File

@@ -19,6 +19,7 @@ pub async fn strip_metadata(
Metadata::Image { Metadata::Image {
width, width,
height, height,
thumbhash,
animated, animated,
} => match mime { } => match mime {
// // little_exif does not appear to parse JPEGs correctly? had 2/2 files fail // // little_exif does not appear to parse JPEGs correctly? had 2/2 files fail
@@ -102,6 +103,7 @@ pub async fn strip_metadata(
Metadata::Image { Metadata::Image {
width, width,
height, height,
thumbhash: thumbhash.clone(),
animated: *animated, animated: *animated,
}, },
)) ))

View File

@@ -1,5 +1,6 @@
use std::io::Cursor; use std::io::Cursor;
use image::{GenericImageView, ImageError, ImageReader};
use revolt_database::Metadata; use revolt_database::Metadata;
use revolt_files::{image_size, is_animated, video_size}; use revolt_files::{image_size, is_animated, video_size};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
@@ -26,6 +27,16 @@ pub fn generate_metadata(f: &NamedTempFile, mime_type: &str) -> Metadata {
.map(|(width, height)| Metadata::Image { .map(|(width, height)| Metadata::Image {
width: width as isize, width: width as isize,
height: height as isize, height: height as isize,
thumbhash: ImageReader::open(f)
.and_then(|r| r.with_guessed_format())
.map_err(ImageError::from)
.and_then(|r| r.decode())
.map(|img| img.thumbnail(100, 100))
.map(|img| (img.dimensions(), img.to_rgba8().into_raw()))
.map(|((width, height), rgba)| {
thumbhash::rgba_to_thumb_hash(width as usize, height as usize, &rgba)
})
.ok(),
animated: is_animated(f, mime_type).unwrap_or(false), animated: is_animated(f, mime_type).unwrap_or(false),
}) })
.unwrap_or_default() .unwrap_or_default()