mirror of
https://github.com/stoatchat/stoatchat.git
synced 2026-05-06 08:36:53 -04:00
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:
@@ -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 },
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 { .. })
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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/") {
|
||||
|
||||
Reference in New Issue
Block a user