feat: Detect animation in image files for fetch_preview (#574)

* Implement animated metadata TODOs for database and thumbnailing.

Signed-off-by: Assisting <erik@eriklabine.com>

* Run linter for code changes

Signed-off-by: Assisting <erik@eriklabine.com>

---------

Signed-off-by: Assisting <erik@eriklabine.com>
This commit is contained in:
Erik LaBine
2026-03-23 01:07:22 -04:00
committed by GitHub
parent 5191bd16b2
commit 3fa0abf47f
9 changed files with 149 additions and 12 deletions
@@ -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 },
+12 -2
View File
@@ -406,9 +406,14 @@ impl From<crate::Metadata> 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<Metadata> 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,
@@ -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<bool> {
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));
}
}
+28
View File
@@ -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<bool> {
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 {
@@ -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<bool>;
fn image_size_vec(&self, v: &[u8], mime: &str) -> Option<(usize, usize)>;
fn decode_image<R: Read + BufRead + Seek>(
+6 -2
View File
@@ -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
+1 -1
View File
@@ -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 { .. })
+13 -2
View File
@@ -16,7 +16,11 @@ pub async fn strip_metadata(
mime: &str,
) -> Result<(Vec<u8>, 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)),
+2 -1
View File
@@ -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/") {