diff --git a/crates/core/database/src/models/file_hashes/model.rs b/crates/core/database/src/models/file_hashes/model.rs index cec7a39d..756ce3d5 100644 --- a/crates/core/database/src/models/file_hashes/model.rs +++ b/crates/core/database/src/models/file_hashes/model.rs @@ -45,7 +45,8 @@ auto_derived!( Image { width: isize, height: isize, - // animated: bool // TODO: https://docs.rs/image/latest/image/trait.AnimationDecoder.html for APNG support + #[serde(default)] + animated: bool, }, /// File is a video with specific dimensions Video { width: isize, height: isize }, diff --git a/crates/core/database/src/util/bridge/v0.rs b/crates/core/database/src/util/bridge/v0.rs index aafb2313..0b2a55ef 100644 --- a/crates/core/database/src/util/bridge/v0.rs +++ b/crates/core/database/src/util/bridge/v0.rs @@ -406,9 +406,14 @@ impl From for Metadata { match value { crate::Metadata::File => Metadata::File, crate::Metadata::Text => Metadata::Text, - crate::Metadata::Image { width, height } => Metadata::Image { + crate::Metadata::Image { + width, + height, + animated, + } => Metadata::Image { width: width as usize, height: height as usize, + animated: animated as bool, }, crate::Metadata::Video { width, height } => Metadata::Video { width: width as usize, @@ -424,9 +429,14 @@ impl From for crate::Metadata { match value { Metadata::File => crate::Metadata::File, Metadata::Text => crate::Metadata::Text, - Metadata::Image { width, height } => crate::Metadata::Image { + Metadata::Image { + width, + height, + animated, + } => crate::Metadata::Image { width: width as isize, height: height as isize, + animated, }, Metadata::Video { width, height } => crate::Metadata::Video { width: width as isize, diff --git a/crates/core/files/src/implementation/media_impl.rs b/crates/core/files/src/implementation/media_impl.rs index 0f13be1c..7a7fad84 100644 --- a/crates/core/files/src/implementation/media_impl.rs +++ b/crates/core/files/src/implementation/media_impl.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use image::{DynamicImage, ImageBuffer, ImageReader}; +use image::{AnimationDecoder, DynamicImage, ImageBuffer, ImageReader}; use jxl_oxide::integration::JxlDecoder; use revolt_config::report_internal_error; use std::io::{BufRead, Read, Seek}; @@ -35,6 +35,31 @@ impl MediaRepository for MediaImpl { } } + fn is_animated(&self, f: &NamedTempFile, mime: &str) -> Option { + match mime { + // Current behaviour is to assume GIFs are animated, this checks for at least 2 frames + "image/gif" => { + let file = std::fs::File::open(f.path()).ok()?; + let reader = std::io::BufReader::new(file); + let decoder = image::codecs::gif::GifDecoder::new(reader).ok()?; + Some(decoder.into_frames().take(2).count() > 1) + } + "image/png" => { + let file = std::fs::File::open(f.path()).ok()?; + let reader = std::io::BufReader::new(file); + let decoder = image::codecs::png::PngDecoder::new(reader).ok()?; + decoder.is_apng().ok() + } + "image/webp" => { + let file = std::fs::File::open(f.path()).ok()?; + let reader = std::io::BufReader::new(file); + let decoder = image::codecs::webp::WebPDecoder::new(reader).ok()?; + Some(decoder.has_animation()) + } + _ => Some(false), + } + } + fn image_size_vec(&self, v: &[u8], mime: &str) -> Option<(usize, usize)> { match mime { "image/svg+xml" => { @@ -171,9 +196,9 @@ impl MediaRepository for MediaImpl { #[cfg(test)] mod tests { - use std::io::Cursor; - use crate::{MediaImpl, MediaRepository}; + use std::io::{Cursor, Write}; + use tempfile::NamedTempFile; #[tokio::test] async fn asset_test_jpeg() { @@ -186,6 +211,15 @@ mod tests { media.create_thumbnail(image, "attachments"); } + #[tokio::test] + async fn asset_test_jpeg_is_not_animated() { + let media = MediaImpl::from_config().await; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(include_bytes!("../../tests/assets/test.jpeg")) + .unwrap(); + assert_eq!(media.is_animated(&f, "image/jpeg"), Some(false)); + } + #[tokio::test] async fn asset_test_jpeg_extra_bytes() { let media = MediaImpl::from_config().await; @@ -212,6 +246,15 @@ mod tests { media.create_thumbnail(image, "emojis"); } + #[tokio::test] + async fn asset_test_png_is_not_animated() { + let media = MediaImpl::from_config().await; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(include_bytes!("../../tests/assets/test.png")) + .unwrap(); + assert_eq!(media.is_animated(&f, "image/png"), Some(false)); + } + #[tokio::test] async fn asset_test_png_extra_bytes() { let media = MediaImpl::from_config().await; @@ -259,6 +302,15 @@ mod tests { media.create_thumbnail(image, "attachments"); } + #[tokio::test] + async fn asset_test_animated_png_is_animated() { + let media = MediaImpl::from_config().await; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(include_bytes!("../../tests/assets/anim-icos.apng")) + .unwrap(); + assert_eq!(media.is_animated(&f, "image/png"), Some(true)); + } + #[tokio::test] async fn asset_test_jxl() { let media = MediaImpl::from_config().await; @@ -292,6 +344,15 @@ mod tests { media.create_thumbnail(image, "attachments"); } + #[tokio::test] + async fn asset_test_webp_is_not_animated() { + let media = MediaImpl::from_config().await; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(include_bytes!("../../tests/assets/dice.webp")) + .unwrap(); + assert_eq!(media.is_animated(&f, "image/webp"), Some(false)); + } + #[tokio::test] async fn asset_test_animated_webp() { let media = MediaImpl::from_config().await; @@ -303,6 +364,15 @@ mod tests { media.create_thumbnail(image, "attachments"); } + #[tokio::test] + async fn asset_test_animated_webp_is_animated() { + let media = MediaImpl::from_config().await; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(include_bytes!("../../tests/assets/anim-icos.webp")) + .unwrap(); + assert_eq!(media.is_animated(&f, "image/webp"), Some(true)); + } + #[tokio::test] async fn asset_test_animated_gif() { let media = MediaImpl::from_config().await; @@ -313,4 +383,13 @@ mod tests { let image = media.decode_image(&mut reader, "image/gif").unwrap(); media.create_thumbnail(image, "attachments"); } + + #[tokio::test] + async fn asset_test_animated_gif_is_animated() { + let media = MediaImpl::from_config().await; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(include_bytes!("../../tests/assets/anim-icos.gif")) + .unwrap(); + assert_eq!(media.is_animated(&f, "image/gif"), Some(true)); + } } diff --git a/crates/core/files/src/lib.rs b/crates/core/files/src/lib.rs index b1c0982d..0f71f095 100644 --- a/crates/core/files/src/lib.rs +++ b/crates/core/files/src/lib.rs @@ -91,6 +91,34 @@ pub fn image_size_vec(v: &[u8], mime: &str) -> Option<(usize, usize)> { media.image_size_vec(v, mime) } +/// Check whether an image file contains animation data +pub fn is_animated(f: &NamedTempFile, mime: &str) -> Option { + let media = MediaImpl::new(Files { + blocked_mime_types: Default::default(), + clamd_host: Default::default(), + encryption_key: Default::default(), + limit: FilesLimit { + max_mega_pixels: 0, + max_pixel_side: 0, + min_file_size: 0, + min_resolution: [0, 0], + }, + preview: Default::default(), + s3: FilesS3 { + access_key_id: Default::default(), + default_bucket: Default::default(), + endpoint: Default::default(), + path_style_buckets: Default::default(), + region: Default::default(), + secret_access_key: Default::default(), + }, + scan_mime_types: Default::default(), + webp_quality: Default::default(), + }); + + media.is_animated(f, mime) +} + /// Determine size of video at temp file pub fn video_size(f: &NamedTempFile) -> Option<(i64, i64)> { let media = MediaImpl::new(Files { diff --git a/crates/core/files/src/repositories/media_repository.rs b/crates/core/files/src/repositories/media_repository.rs index 4cc4feca..6c489d9b 100644 --- a/crates/core/files/src/repositories/media_repository.rs +++ b/crates/core/files/src/repositories/media_repository.rs @@ -6,6 +6,9 @@ use thiserror::Error; pub trait MediaRepository: Send + Sync + 'static { fn image_size(&self, f: &NamedTempFile) -> Option<(usize, usize)>; + + fn is_animated(&self, f: &NamedTempFile, mime: &str) -> Option; + fn image_size_vec(&self, v: &[u8], mime: &str) -> Option<(usize, usize)>; fn decode_image( diff --git a/crates/core/models/src/v0/files.rs b/crates/core/models/src/v0/files.rs index b996483c..7ed1e20e 100644 --- a/crates/core/models/src/v0/files.rs +++ b/crates/core/models/src/v0/files.rs @@ -46,8 +46,12 @@ auto_derived!( File, /// File contains textual data and should be displayed as such Text, - /// File is an image with specific dimensions - Image { width: usize, height: usize }, + /// File is an image with specific dimensions, and may be animated + Image { + width: usize, + height: usize, + animated: bool, + }, /// File is a video with specific dimensions Video { width: usize, height: usize }, /// File is audio diff --git a/crates/services/autumn/src/api.rs b/crates/services/autumn/src/api.rs index d711e533..4018e72c 100644 --- a/crates/services/autumn/src/api.rs +++ b/crates/services/autumn/src/api.rs @@ -380,7 +380,7 @@ async fn fetch_preview( let hash = file.as_hash(&db).await?; - let is_animated = hash.content_type == "image/gif"; // TODO: extract this data from files + let is_animated = matches!(hash.metadata, Metadata::Image { animated: true, .. }); // Only process image files and don't process GIFs if not avatar or icon if !matches!(hash.metadata, Metadata::Image { .. }) diff --git a/crates/services/autumn/src/exif.rs b/crates/services/autumn/src/exif.rs index 23be9ac7..562f975e 100644 --- a/crates/services/autumn/src/exif.rs +++ b/crates/services/autumn/src/exif.rs @@ -16,7 +16,11 @@ pub async fn strip_metadata( mime: &str, ) -> Result<(Vec, Metadata)> { match &metadata { - Metadata::Image { width, height } => match mime { + Metadata::Image { + width, + height, + animated, + } => match mime { // // little_exif does not appear to parse JPEGs correctly? had 2/2 files fail // "image/jpeg" | "image/png" => { // // use little_exif to strip metadata except for orientation and colour profile @@ -93,7 +97,14 @@ pub async fn strip_metadata( _ => (*width, *height), }; - Ok((bytes, Metadata::Image { width, height })) + Ok(( + bytes, + Metadata::Image { + width, + height, + animated: *animated, + }, + )) } // JXLs store EXIF data but we don't have the ability to write them "image/jxl" => Ok((buf, metadata)), diff --git a/crates/services/autumn/src/metadata.rs b/crates/services/autumn/src/metadata.rs index 72b610e9..3c69cf84 100644 --- a/crates/services/autumn/src/metadata.rs +++ b/crates/services/autumn/src/metadata.rs @@ -1,7 +1,7 @@ use std::io::Cursor; use revolt_database::Metadata; -use revolt_files::{image_size, video_size}; +use revolt_files::{image_size, is_animated, video_size}; use tempfile::NamedTempFile; /// Intersection of what infer can detect and what image-rs supports @@ -26,6 +26,7 @@ pub fn generate_metadata(f: &NamedTempFile, mime_type: &str) -> Metadata { .map(|(width, height)| Metadata::Image { width: width as isize, height: height as isize, + animated: is_animated(f, mime_type).unwrap_or(false), }) .unwrap_or_default() } else if mime_type.starts_with("video/") {