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 {
|
Image {
|
||||||
width: isize,
|
width: isize,
|
||||||
height: 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
|
/// File is a video with specific dimensions
|
||||||
Video { width: isize, height: isize },
|
Video { width: isize, height: isize },
|
||||||
|
|||||||
@@ -406,9 +406,14 @@ impl From<crate::Metadata> for Metadata {
|
|||||||
match value {
|
match value {
|
||||||
crate::Metadata::File => Metadata::File,
|
crate::Metadata::File => Metadata::File,
|
||||||
crate::Metadata::Text => Metadata::Text,
|
crate::Metadata::Text => Metadata::Text,
|
||||||
crate::Metadata::Image { width, height } => Metadata::Image {
|
crate::Metadata::Image {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
animated,
|
||||||
|
} => Metadata::Image {
|
||||||
width: width as usize,
|
width: width as usize,
|
||||||
height: height as usize,
|
height: height as usize,
|
||||||
|
animated: animated as bool,
|
||||||
},
|
},
|
||||||
crate::Metadata::Video { width, height } => Metadata::Video {
|
crate::Metadata::Video { width, height } => Metadata::Video {
|
||||||
width: width as usize,
|
width: width as usize,
|
||||||
@@ -424,9 +429,14 @@ impl From<Metadata> for crate::Metadata {
|
|||||||
match value {
|
match value {
|
||||||
Metadata::File => crate::Metadata::File,
|
Metadata::File => crate::Metadata::File,
|
||||||
Metadata::Text => crate::Metadata::Text,
|
Metadata::Text => crate::Metadata::Text,
|
||||||
Metadata::Image { width, height } => crate::Metadata::Image {
|
Metadata::Image {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
animated,
|
||||||
|
} => crate::Metadata::Image {
|
||||||
width: width as isize,
|
width: width as isize,
|
||||||
height: height as isize,
|
height: height as isize,
|
||||||
|
animated,
|
||||||
},
|
},
|
||||||
Metadata::Video { width, height } => crate::Metadata::Video {
|
Metadata::Video { width, height } => crate::Metadata::Video {
|
||||||
width: width as isize,
|
width: width as isize,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use image::{DynamicImage, ImageBuffer, ImageReader};
|
use image::{AnimationDecoder, DynamicImage, ImageBuffer, ImageReader};
|
||||||
use jxl_oxide::integration::JxlDecoder;
|
use jxl_oxide::integration::JxlDecoder;
|
||||||
use revolt_config::report_internal_error;
|
use revolt_config::report_internal_error;
|
||||||
use std::io::{BufRead, Read, Seek};
|
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)> {
|
fn image_size_vec(&self, v: &[u8], mime: &str) -> Option<(usize, usize)> {
|
||||||
match mime {
|
match mime {
|
||||||
"image/svg+xml" => {
|
"image/svg+xml" => {
|
||||||
@@ -171,9 +196,9 @@ impl MediaRepository for MediaImpl {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::io::Cursor;
|
|
||||||
|
|
||||||
use crate::{MediaImpl, MediaRepository};
|
use crate::{MediaImpl, MediaRepository};
|
||||||
|
use std::io::{Cursor, Write};
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn asset_test_jpeg() {
|
async fn asset_test_jpeg() {
|
||||||
@@ -186,6 +211,15 @@ mod tests {
|
|||||||
media.create_thumbnail(image, "attachments");
|
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]
|
#[tokio::test]
|
||||||
async fn asset_test_jpeg_extra_bytes() {
|
async fn asset_test_jpeg_extra_bytes() {
|
||||||
let media = MediaImpl::from_config().await;
|
let media = MediaImpl::from_config().await;
|
||||||
@@ -212,6 +246,15 @@ mod tests {
|
|||||||
media.create_thumbnail(image, "emojis");
|
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]
|
#[tokio::test]
|
||||||
async fn asset_test_png_extra_bytes() {
|
async fn asset_test_png_extra_bytes() {
|
||||||
let media = MediaImpl::from_config().await;
|
let media = MediaImpl::from_config().await;
|
||||||
@@ -259,6 +302,15 @@ mod tests {
|
|||||||
media.create_thumbnail(image, "attachments");
|
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]
|
#[tokio::test]
|
||||||
async fn asset_test_jxl() {
|
async fn asset_test_jxl() {
|
||||||
let media = MediaImpl::from_config().await;
|
let media = MediaImpl::from_config().await;
|
||||||
@@ -292,6 +344,15 @@ mod tests {
|
|||||||
media.create_thumbnail(image, "attachments");
|
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]
|
#[tokio::test]
|
||||||
async fn asset_test_animated_webp() {
|
async fn asset_test_animated_webp() {
|
||||||
let media = MediaImpl::from_config().await;
|
let media = MediaImpl::from_config().await;
|
||||||
@@ -303,6 +364,15 @@ mod tests {
|
|||||||
media.create_thumbnail(image, "attachments");
|
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]
|
#[tokio::test]
|
||||||
async fn asset_test_animated_gif() {
|
async fn asset_test_animated_gif() {
|
||||||
let media = MediaImpl::from_config().await;
|
let media = MediaImpl::from_config().await;
|
||||||
@@ -313,4 +383,13 @@ mod tests {
|
|||||||
let image = media.decode_image(&mut reader, "image/gif").unwrap();
|
let image = media.decode_image(&mut reader, "image/gif").unwrap();
|
||||||
media.create_thumbnail(image, "attachments");
|
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)
|
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
|
/// Determine size of video at temp file
|
||||||
pub fn video_size(f: &NamedTempFile) -> Option<(i64, i64)> {
|
pub fn video_size(f: &NamedTempFile) -> Option<(i64, i64)> {
|
||||||
let media = MediaImpl::new(Files {
|
let media = MediaImpl::new(Files {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ use thiserror::Error;
|
|||||||
|
|
||||||
pub trait MediaRepository: Send + Sync + 'static {
|
pub trait MediaRepository: Send + Sync + 'static {
|
||||||
fn image_size(&self, f: &NamedTempFile) -> Option<(usize, usize)>;
|
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 image_size_vec(&self, v: &[u8], mime: &str) -> Option<(usize, usize)>;
|
||||||
|
|
||||||
fn decode_image<R: Read + BufRead + Seek>(
|
fn decode_image<R: Read + BufRead + Seek>(
|
||||||
|
|||||||
@@ -46,8 +46,12 @@ auto_derived!(
|
|||||||
File,
|
File,
|
||||||
/// File contains textual data and should be displayed as such
|
/// File contains textual data and should be displayed as such
|
||||||
Text,
|
Text,
|
||||||
/// File is an image with specific dimensions
|
/// File is an image with specific dimensions, and may be animated
|
||||||
Image { width: usize, height: usize },
|
Image {
|
||||||
|
width: usize,
|
||||||
|
height: usize,
|
||||||
|
animated: bool,
|
||||||
|
},
|
||||||
/// File is a video with specific dimensions
|
/// File is a video with specific dimensions
|
||||||
Video { width: usize, height: usize },
|
Video { width: usize, height: usize },
|
||||||
/// File is audio
|
/// File is audio
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ async fn fetch_preview(
|
|||||||
|
|
||||||
let hash = file.as_hash(&db).await?;
|
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
|
// Only process image files and don't process GIFs if not avatar or icon
|
||||||
if !matches!(hash.metadata, Metadata::Image { .. })
|
if !matches!(hash.metadata, Metadata::Image { .. })
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ pub async fn strip_metadata(
|
|||||||
mime: &str,
|
mime: &str,
|
||||||
) -> Result<(Vec<u8>, Metadata)> {
|
) -> Result<(Vec<u8>, Metadata)> {
|
||||||
match &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
|
// // little_exif does not appear to parse JPEGs correctly? had 2/2 files fail
|
||||||
// "image/jpeg" | "image/png" => {
|
// "image/jpeg" | "image/png" => {
|
||||||
// // use little_exif to strip metadata except for orientation and colour profile
|
// // use little_exif to strip metadata except for orientation and colour profile
|
||||||
@@ -93,7 +97,14 @@ pub async fn strip_metadata(
|
|||||||
_ => (*width, *height),
|
_ => (*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
|
// JXLs store EXIF data but we don't have the ability to write them
|
||||||
"image/jxl" => Ok((buf, metadata)),
|
"image/jxl" => Ok((buf, metadata)),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
use revolt_database::Metadata;
|
use revolt_database::Metadata;
|
||||||
use revolt_files::{image_size, video_size};
|
use revolt_files::{image_size, is_animated, video_size};
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
/// Intersection of what infer can detect and what image-rs supports
|
/// 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 {
|
.map(|(width, height)| Metadata::Image {
|
||||||
width: width as isize,
|
width: width as isize,
|
||||||
height: height as isize,
|
height: height as isize,
|
||||||
|
animated: is_animated(f, mime_type).unwrap_or(false),
|
||||||
})
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
} else if mime_type.starts_with("video/") {
|
} else if mime_type.starts_with("video/") {
|
||||||
|
|||||||
Reference in New Issue
Block a user