use std::fs::File;
use std::io::Read;
use std::path::Path;
use crate::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MediaFormat {
StaticImage(ImageFormat),
AnimatedGif,
AnimatedPng,
Svg,
Video(VideoCodec),
Unknown,
}
impl std::fmt::Display for MediaFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::StaticImage(img) => write!(f, "static image ({})", img),
Self::AnimatedGif => write!(f, "animated GIF"),
Self::AnimatedPng => write!(f, "animated PNG (APNG)"),
Self::Svg => write!(f, "SVG vector graphics"),
Self::Video(codec) => write!(f, "video ({})", codec),
Self::Unknown => write!(f, "unknown format"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageFormat {
Png,
Jpeg,
Gif,
Bmp,
WebP,
Tiff,
}
impl std::fmt::Display for ImageFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Png => write!(f, "PNG"),
Self::Jpeg => write!(f, "JPEG"),
Self::Gif => write!(f, "GIF"),
Self::Bmp => write!(f, "BMP"),
Self::WebP => write!(f, "WebP"),
Self::Tiff => write!(f, "TIFF"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoCodec {
H264,
H265,
Vp9,
Av1,
Other,
}
impl std::fmt::Display for VideoCodec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::H264 => write!(f, "H.264"),
Self::H265 => write!(f, "H.265"),
Self::Vp9 => write!(f, "VP9"),
Self::Av1 => write!(f, "AV1"),
Self::Other => write!(f, "unknown codec"),
}
}
}
const MAGIC_BYTES_SIZE: usize = 16;
pub fn detect_format(path: impl AsRef<Path>) -> Result<MediaFormat> {
let path = path.as_ref();
let mut file = File::open(path)?;
let mut buffer = [0u8; MAGIC_BYTES_SIZE];
let bytes_read = file.read(&mut buffer)?;
let format = detect_format_from_bytes(&buffer[..bytes_read]);
if format == MediaFormat::Unknown {
return Ok(detect_from_extension(path));
}
#[cfg(feature = "image")]
if matches!(format, MediaFormat::StaticImage(ImageFormat::Gif)) && is_animated_gif(path)? {
return Ok(MediaFormat::AnimatedGif);
}
#[cfg(feature = "image")]
if matches!(format, MediaFormat::StaticImage(ImageFormat::Png)) && is_animated_png(path)? {
return Ok(MediaFormat::AnimatedPng);
}
Ok(format)
}
#[must_use]
pub fn detect_format_from_bytes(bytes: &[u8]) -> MediaFormat {
if bytes.len() >= 8
&& bytes[0] == 0x89
&& bytes[1] == 0x50
&& bytes[2] == 0x4E
&& bytes[3] == 0x47
&& bytes[4] == 0x0D
&& bytes[5] == 0x0A
&& bytes[6] == 0x1A
&& bytes[7] == 0x0A
{
return MediaFormat::StaticImage(ImageFormat::Png);
}
if bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF {
return MediaFormat::StaticImage(ImageFormat::Jpeg);
}
if bytes.len() >= 4
&& bytes[0] == 0x47
&& bytes[1] == 0x49
&& bytes[2] == 0x46
&& bytes[3] == 0x38
{
return MediaFormat::StaticImage(ImageFormat::Gif);
}
if bytes.len() >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4D {
return MediaFormat::StaticImage(ImageFormat::Bmp);
}
if bytes.len() >= 12
&& bytes[0] == 0x52
&& bytes[1] == 0x49
&& bytes[2] == 0x46
&& bytes[3] == 0x46
&& bytes[8] == 0x57
&& bytes[9] == 0x45
&& bytes[10] == 0x42
&& bytes[11] == 0x50
{
return MediaFormat::StaticImage(ImageFormat::WebP);
}
if bytes.len() >= 4
&& bytes[0] == 0x49
&& bytes[1] == 0x49
&& bytes[2] == 0x2A
&& bytes[3] == 0x00
{
return MediaFormat::StaticImage(ImageFormat::Tiff);
}
if bytes.len() >= 4
&& bytes[0] == 0x4D
&& bytes[1] == 0x4D
&& bytes[2] == 0x00
&& bytes[3] == 0x2A
{
return MediaFormat::StaticImage(ImageFormat::Tiff);
}
if bytes.len() >= 5
&& bytes[0] == 0x3C
&& bytes[1] == 0x3F
&& bytes[2] == 0x78
&& bytes[3] == 0x6D
&& bytes[4] == 0x6C
{
return MediaFormat::Svg;
}
if bytes.len() >= 4
&& bytes[0] == 0x3C
&& bytes[1] == 0x73
&& bytes[2] == 0x76
&& bytes[3] == 0x67
{
return MediaFormat::Svg;
}
if let Ok(text) = std::str::from_utf8(bytes) {
let text_lower = text.to_lowercase();
if text_lower.contains("<svg") || text_lower.contains("<?xml") {
return MediaFormat::Svg;
}
}
if bytes.len() >= 8
&& bytes[4] == 0x66
&& bytes[5] == 0x74
&& bytes[6] == 0x79
&& bytes[7] == 0x70
{
return MediaFormat::Video(VideoCodec::H264);
}
if bytes.len() >= 4
&& bytes[0] == 0x1A
&& bytes[1] == 0x45
&& bytes[2] == 0xDF
&& bytes[3] == 0xA3
{
return MediaFormat::Video(VideoCodec::Vp9);
}
if bytes.len() >= 12
&& bytes[0] == 0x52
&& bytes[1] == 0x49
&& bytes[2] == 0x46
&& bytes[3] == 0x46
&& bytes[8] == 0x41
&& bytes[9] == 0x56
&& bytes[10] == 0x49
&& bytes[11] == 0x20
{
return MediaFormat::Video(VideoCodec::Other);
}
MediaFormat::Unknown
}
fn detect_from_extension(path: &Path) -> MediaFormat {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_lowercase);
match ext.as_deref() {
Some("png") => MediaFormat::StaticImage(ImageFormat::Png),
Some("jpg" | "jpeg") => MediaFormat::StaticImage(ImageFormat::Jpeg),
Some("gif") => MediaFormat::StaticImage(ImageFormat::Gif),
Some("bmp") => MediaFormat::StaticImage(ImageFormat::Bmp),
Some("webp") => MediaFormat::StaticImage(ImageFormat::WebP),
Some("tif" | "tiff") => MediaFormat::StaticImage(ImageFormat::Tiff),
Some("svg") => MediaFormat::Svg,
Some("mp4" | "m4v" | "mov") => MediaFormat::Video(VideoCodec::H264),
Some("mkv" | "avi") => MediaFormat::Video(VideoCodec::Other),
Some("webm") => MediaFormat::Video(VideoCodec::Vp9),
_ => MediaFormat::Unknown,
}
}
#[cfg(feature = "image")]
pub fn is_animated_gif(path: impl AsRef<Path>) -> Result<bool> {
use gif::DecodeOptions;
let path = path.as_ref();
let file = File::open(path)?;
let mut options = DecodeOptions::new();
options.set_color_output(gif::ColorOutput::Indexed);
let mut decoder = match options.read_info(file) {
Ok(d) => d,
Err(e) => {
tracing::debug!("GIF decode error for {:?}: {:?}, treating as static", path, e);
return Ok(false);
}
};
let mut frame_count = 0;
while decoder.read_next_frame().ok().flatten().is_some() {
frame_count += 1;
if frame_count > 1 {
return Ok(true); }
}
Ok(false) }
#[cfg(feature = "image")]
#[must_use]
pub fn is_animated_gif_from_bytes(bytes: &[u8]) -> bool {
use gif::DecodeOptions;
use std::io::Cursor;
let cursor = Cursor::new(bytes);
let mut options = DecodeOptions::new();
options.set_color_output(gif::ColorOutput::Indexed);
let Ok(mut decoder) = options.read_info(cursor) else {
return false;
};
let mut frame_count = 0;
while decoder.read_next_frame().ok().flatten().is_some() {
frame_count += 1;
if frame_count > 1 {
return true;
}
}
false
}
#[cfg(feature = "image")]
pub fn is_animated_png(path: impl AsRef<Path>) -> Result<bool> {
use std::io::BufReader;
let path = path.as_ref();
let file = File::open(path)?;
let reader = BufReader::new(file);
let decoder = png::Decoder::new(reader);
let png_reader = match decoder.read_info() {
Ok(r) => r,
Err(e) => {
tracing::debug!("PNG decode error for {:?}: {:?}, treating as static", path, e);
return Ok(false);
}
};
Ok(png_reader.info().animation_control().is_some())
}
#[cfg(feature = "image")]
#[must_use]
pub fn is_animated_png_from_bytes(bytes: &[u8]) -> bool {
use std::io::Cursor;
let cursor = Cursor::new(bytes);
let decoder = png::Decoder::new(cursor);
let Ok(png_reader) = decoder.read_info() else {
return false;
};
png_reader.info().animation_control().is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_png_magic() {
let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00];
assert_eq!(
detect_format_from_bytes(&png),
MediaFormat::StaticImage(ImageFormat::Png)
);
}
#[test]
fn test_detect_jpeg_magic() {
let jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
assert_eq!(
detect_format_from_bytes(&jpeg),
MediaFormat::StaticImage(ImageFormat::Jpeg)
);
}
#[test]
fn test_detect_jpeg_exif_magic() {
let jpeg_exif = [0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x10];
assert_eq!(
detect_format_from_bytes(&jpeg_exif),
MediaFormat::StaticImage(ImageFormat::Jpeg)
);
}
#[test]
fn test_detect_gif87a_magic() {
let gif87 = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
assert_eq!(
detect_format_from_bytes(&gif87),
MediaFormat::StaticImage(ImageFormat::Gif)
);
}
#[test]
fn test_detect_gif89a_magic() {
let gif89 = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
assert_eq!(
detect_format_from_bytes(&gif89),
MediaFormat::StaticImage(ImageFormat::Gif)
);
}
#[test]
fn test_detect_bmp_magic() {
let bmp = [0x42, 0x4D, 0x00, 0x00];
assert_eq!(
detect_format_from_bytes(&bmp),
MediaFormat::StaticImage(ImageFormat::Bmp)
);
}
#[test]
fn test_detect_webp_magic() {
let webp = [
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, ];
assert_eq!(
detect_format_from_bytes(&webp),
MediaFormat::StaticImage(ImageFormat::WebP)
);
}
#[test]
fn test_detect_tiff_little_endian_magic() {
let tiff_le = [0x49, 0x49, 0x2A, 0x00];
assert_eq!(
detect_format_from_bytes(&tiff_le),
MediaFormat::StaticImage(ImageFormat::Tiff)
);
}
#[test]
fn test_detect_tiff_big_endian_magic() {
let tiff_be = [0x4D, 0x4D, 0x00, 0x2A];
assert_eq!(
detect_format_from_bytes(&tiff_be),
MediaFormat::StaticImage(ImageFormat::Tiff)
);
}
#[test]
fn test_detect_svg_xml_declaration() {
let svg = b"<?xml version=\"1.0\"?>";
assert_eq!(detect_format_from_bytes(svg), MediaFormat::Svg);
}
#[test]
fn test_detect_svg_direct() {
let svg = b"<svg xmlns=\"http";
assert_eq!(detect_format_from_bytes(svg), MediaFormat::Svg);
}
#[test]
fn test_detect_mp4_magic() {
let mp4 = [
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D, ];
assert_eq!(
detect_format_from_bytes(&mp4),
MediaFormat::Video(VideoCodec::H264)
);
}
#[test]
fn test_detect_mkv_webm_magic() {
let mkv = [0x1A, 0x45, 0xDF, 0xA3];
assert_eq!(
detect_format_from_bytes(&mkv),
MediaFormat::Video(VideoCodec::Vp9)
);
}
#[test]
fn test_detect_avi_magic() {
let avi = [
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x41, 0x56, 0x49, 0x20, ];
assert_eq!(
detect_format_from_bytes(&avi),
MediaFormat::Video(VideoCodec::Other)
);
}
#[test]
fn test_extension_fallback_png() {
let format = detect_from_extension(Path::new("image.png"));
assert_eq!(format, MediaFormat::StaticImage(ImageFormat::Png));
}
#[test]
fn test_extension_fallback_jpeg_variants() {
assert_eq!(
detect_from_extension(Path::new("image.jpg")),
MediaFormat::StaticImage(ImageFormat::Jpeg)
);
assert_eq!(
detect_from_extension(Path::new("image.jpeg")),
MediaFormat::StaticImage(ImageFormat::Jpeg)
);
}
#[test]
fn test_extension_fallback_case_insensitive() {
assert_eq!(
detect_from_extension(Path::new("IMAGE.PNG")),
MediaFormat::StaticImage(ImageFormat::Png)
);
assert_eq!(
detect_from_extension(Path::new("video.MP4")),
MediaFormat::Video(VideoCodec::H264)
);
}
#[test]
fn test_extension_fallback_unknown() {
assert_eq!(
detect_from_extension(Path::new("file.xyz")),
MediaFormat::Unknown
);
}
#[test]
fn test_extension_fallback_no_extension() {
assert_eq!(
detect_from_extension(Path::new("noextension")),
MediaFormat::Unknown
);
}
#[test]
fn test_detect_unknown_bytes() {
let unknown = [0x00, 0x00, 0x00, 0x00];
assert_eq!(detect_format_from_bytes(&unknown), MediaFormat::Unknown);
}
#[test]
fn test_detect_empty_bytes() {
let empty: &[u8] = &[];
assert_eq!(detect_format_from_bytes(empty), MediaFormat::Unknown);
}
#[test]
fn test_media_format_display() {
assert_eq!(
format!("{}", MediaFormat::StaticImage(ImageFormat::Png)),
"static image (PNG)"
);
assert_eq!(format!("{}", MediaFormat::AnimatedGif), "animated GIF");
assert_eq!(
format!("{}", MediaFormat::Video(VideoCodec::H264)),
"video (H.264)"
);
assert_eq!(format!("{}", MediaFormat::Unknown), "unknown format");
}
#[test]
fn test_image_format_display() {
assert_eq!(format!("{}", ImageFormat::Png), "PNG");
assert_eq!(format!("{}", ImageFormat::Jpeg), "JPEG");
assert_eq!(format!("{}", ImageFormat::WebP), "WebP");
}
#[test]
fn test_video_codec_display() {
assert_eq!(format!("{}", VideoCodec::H264), "H.264");
assert_eq!(format!("{}", VideoCodec::Vp9), "VP9");
assert_eq!(format!("{}", VideoCodec::Other), "unknown codec");
}
#[cfg(feature = "image")]
mod animated_gif_tests {
use super::*;
use std::path::Path;
#[test]
fn test_is_animated_gif_static() {
let path = Path::new("tests/fixtures/media/static.gif");
if path.exists() {
let result = is_animated_gif(path).unwrap();
assert!(!result, "Static GIF should return false");
}
}
#[test]
fn test_is_animated_gif_animated() {
let path = Path::new("tests/fixtures/media/animated.gif");
if path.exists() {
let result = is_animated_gif(path).unwrap();
assert!(result, "Animated GIF should return true");
}
}
#[test]
fn test_is_animated_gif_nonexistent() {
let result = is_animated_gif("nonexistent.gif");
assert!(result.is_err(), "Nonexistent file should return error");
}
#[test]
fn test_is_animated_gif_from_bytes_static() {
let static_gif = include_bytes!("../../tests/fixtures/media/static.gif");
assert!(
!is_animated_gif_from_bytes(static_gif),
"Static GIF bytes should return false"
);
}
#[test]
fn test_is_animated_gif_from_bytes_animated() {
let animated_gif = include_bytes!("../../tests/fixtures/media/animated.gif");
assert!(
is_animated_gif_from_bytes(animated_gif),
"Animated GIF bytes should return true"
);
}
#[test]
fn test_is_animated_gif_from_bytes_invalid() {
let invalid = &[0x00, 0x01, 0x02, 0x03];
assert!(
!is_animated_gif_from_bytes(invalid),
"Invalid data should return false"
);
}
#[test]
fn test_detect_format_static_gif() {
let path = Path::new("tests/fixtures/media/static.gif");
if path.exists() {
let format = detect_format(path).unwrap();
assert_eq!(
format,
MediaFormat::StaticImage(ImageFormat::Gif),
"Static GIF should be detected as StaticImage(Gif)"
);
}
}
#[test]
fn test_detect_format_animated_gif() {
let path = Path::new("tests/fixtures/media/animated.gif");
if path.exists() {
let format = detect_format(path).unwrap();
assert_eq!(
format,
MediaFormat::AnimatedGif,
"Animated GIF should be detected as AnimatedGif"
);
}
}
}
#[cfg(feature = "image")]
mod animated_png_tests {
use super::*;
use std::path::Path;
#[test]
fn test_is_animated_png_static() {
let path = Path::new("tests/fixtures/media/static_png.png");
if path.exists() {
let result = is_animated_png(path).unwrap();
assert!(!result, "Static PNG should return false");
}
}
#[test]
fn test_is_animated_png_animated() {
let path = Path::new("tests/fixtures/media/animated.png");
if path.exists() {
let result = is_animated_png(path).unwrap();
assert!(result, "Animated PNG should return true");
}
}
#[test]
fn test_is_animated_png_nonexistent() {
let result = is_animated_png("nonexistent.png");
assert!(result.is_err(), "Nonexistent file should return error");
}
#[test]
fn test_is_animated_png_from_bytes_static() {
let static_png = include_bytes!("../../tests/fixtures/media/static_png.png");
assert!(
!is_animated_png_from_bytes(static_png),
"Static PNG bytes should return false"
);
}
#[test]
fn test_is_animated_png_from_bytes_animated() {
let animated_png = include_bytes!("../../tests/fixtures/media/animated.png");
assert!(
is_animated_png_from_bytes(animated_png),
"Animated PNG bytes should return true"
);
}
#[test]
fn test_is_animated_png_from_bytes_invalid() {
let invalid = &[0x00, 0x01, 0x02, 0x03];
assert!(
!is_animated_png_from_bytes(invalid),
"Invalid data should return false"
);
}
#[test]
fn test_detect_format_static_png() {
let path = Path::new("tests/fixtures/media/static_png.png");
if path.exists() {
let format = detect_format(path).unwrap();
assert_eq!(
format,
MediaFormat::StaticImage(ImageFormat::Png),
"Static PNG should be detected as StaticImage(Png)"
);
}
}
#[test]
fn test_detect_format_animated_png() {
let path = Path::new("tests/fixtures/media/animated.png");
if path.exists() {
let format = detect_format(path).unwrap();
assert_eq!(
format,
MediaFormat::AnimatedPng,
"Animated PNG should be detected as AnimatedPng"
);
}
}
}
}