use std::fmt;
use std::path::Path;
use crate::error::{Error, Result};
pub const PROBE_BYTES: usize = 4096;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Container {
Mp4,
Matroska,
WebM,
Avi,
Ogg,
Avif,
Heif,
Png,
Jpeg,
Gif,
WebP,
Bmp,
Tiff,
}
impl Container {
pub fn is_image(self) -> bool {
matches!(
self,
Container::Png
| Container::Jpeg
| Container::Gif
| Container::WebP
| Container::Bmp
| Container::Tiff
)
}
pub fn is_demuxable(self) -> bool {
matches!(
self,
Container::Mp4
| Container::Matroska
| Container::WebM
| Container::Avif
| Container::Heif
)
}
pub fn extension(self) -> &'static str {
match self {
Container::Mp4 => "mp4",
Container::Matroska => "mkv",
Container::WebM => "webm",
Container::Avi => "avi",
Container::Ogg => "ogv",
Container::Avif => "avif",
Container::Heif => "heic",
Container::Png => "png",
Container::Jpeg => "jpg",
Container::Gif => "gif",
Container::WebP => "webp",
Container::Bmp => "bmp",
Container::Tiff => "tiff",
}
}
}
impl fmt::Display for Container {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
Container::Mp4 => "MP4",
Container::Matroska => "Matroska",
Container::WebM => "WebM",
Container::Avi => "AVI",
Container::Ogg => "Ogg",
Container::Avif => "AVIF",
Container::Heif => "HEIF",
Container::Png => "PNG",
Container::Jpeg => "JPEG",
Container::Gif => "GIF",
Container::WebP => "WebP",
Container::Bmp => "BMP",
Container::Tiff => "TIFF",
};
formatter.write_str(name)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Encoding {
H264,
H265,
Av1,
Vp9,
Vp8,
Theora,
ProRes,
Mpeg4Part2,
MotionJpeg,
Still,
}
impl Encoding {
pub fn is_royalty_free(self) -> bool {
matches!(
self,
Encoding::Av1 | Encoding::Vp9 | Encoding::Vp8 | Encoding::Theora | Encoding::Still
)
}
pub fn is_encodable(self) -> bool {
matches!(self, Encoding::Av1 | Encoding::Vp9)
}
pub fn fourcc(self) -> &'static str {
match self {
Encoding::H264 => "avc1",
Encoding::H265 => "hvc1",
Encoding::Av1 => "av01",
Encoding::Vp9 => "vp09",
Encoding::Vp8 => "vp08",
Encoding::Theora => "theo",
Encoding::ProRes => "apcn",
Encoding::Mpeg4Part2 => "mp4v",
Encoding::MotionJpeg => "mjpg",
Encoding::Still => "still",
}
}
pub fn from_codec_id(id: &str) -> Option<Self> {
let id = id.to_ascii_lowercase();
let id = id.trim();
Some(match id {
"avc1" | "avc3" | "h264" | "v_mpeg4/iso/avc" => Encoding::H264,
"hvc1" | "hev1" | "h265" | "hevc" | "v_mpegh/iso/hevc" => Encoding::H265,
"av01" | "av1" | "v_av1" => Encoding::Av1,
"vp09" | "vp9" | "v_vp9" => Encoding::Vp9,
"vp08" | "vp8" | "v_vp8" => Encoding::Vp8,
"theo" | "v_theora" => Encoding::Theora,
"mp4v" | "v_mpeg4/iso/asp" => Encoding::Mpeg4Part2,
"mjpg" | "mjpeg" | "v_mjpeg" => Encoding::MotionJpeg,
_ if id.starts_with("apc") || id == "ap4h" => Encoding::ProRes,
_ => return None,
})
}
}
impl fmt::Display for Encoding {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
Encoding::H264 => "H.264",
Encoding::H265 => "HEVC",
Encoding::Av1 => "AV1",
Encoding::Vp9 => "VP9",
Encoding::Vp8 => "VP8",
Encoding::Theora => "Theora",
Encoding::ProRes => "ProRes",
Encoding::Mpeg4Part2 => "MPEG-4 Part 2",
Encoding::MotionJpeg => "Motion JPEG",
Encoding::Still => "still image",
};
formatter.write_str(name)
}
}
pub fn identify_bytes(bytes: &[u8]) -> Option<Container> {
if bytes.len() < 4 {
return None;
}
if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" {
let brand = &bytes[8..12];
return Some(match brand {
b"avif" | b"avis" => Container::Avif,
b"heic" | b"heix" | b"heim" | b"heis" | b"hevc" | b"mif1" | b"msf1" => Container::Heif,
_ => Container::Mp4,
});
}
if bytes.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) {
let header = &bytes[..bytes.len().min(PROBE_BYTES)];
return Some(if contains(header, b"webm") {
Container::WebM
} else {
Container::Matroska
});
}
if bytes.len() >= 12 && bytes.starts_with(b"RIFF") {
return match &bytes[8..12] {
b"AVI " => Some(Container::Avi),
b"WEBP" => Some(Container::WebP),
_ => None,
};
}
if bytes.starts_with(b"OggS") {
return Some(Container::Ogg);
}
if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
return Some(Container::Png);
}
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Some(Container::Jpeg);
}
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
return Some(Container::Gif);
}
if bytes.starts_with(b"BM") {
return Some(Container::Bmp);
}
if bytes.starts_with(&[0x49, 0x49, 0x2A, 0x00]) || bytes.starts_with(&[0x4D, 0x4D, 0x00, 0x2A])
{
return Some(Container::Tiff);
}
None
}
pub fn identify_path(path: impl AsRef<Path>) -> Result<Container> {
use std::io::Read;
let path = path.as_ref();
let mut file = std::fs::File::open(path).map_err(|error| Error::io(path, error))?;
let mut probe = vec![0_u8; PROBE_BYTES];
let read = read_up_to(&mut file, &mut probe).map_err(|error| Error::io(path, error))?;
probe.truncate(read);
let _ = &mut file as &mut dyn Read;
identify_bytes(&probe).ok_or_else(|| Error::UnknownContainer {
path: path.to_path_buf(),
found: describe_unknown(&probe),
})
}
fn read_up_to(mut reader: impl std::io::Read, buffer: &mut [u8]) -> std::io::Result<usize> {
let mut filled = 0;
while filled < buffer.len() {
match reader.read(&mut buffer[filled..]) {
Ok(0) => break,
Ok(count) => filled += count,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
}
}
Ok(filled)
}
fn describe_unknown(bytes: &[u8]) -> String {
if bytes.is_empty() {
return "empty file".to_string();
}
let head: Vec<String> = bytes
.iter()
.take(8)
.map(|byte| format!("{byte:02x}"))
.collect();
format!("starts {}", head.join(" "))
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() {
return false;
}
haystack
.windows(needle.len())
.any(|window| window == needle)
}
#[cfg(test)]
mod tests {
use super::*;
fn ftyp(brand: &[u8; 4]) -> Vec<u8> {
let mut bytes = vec![0, 0, 0, 0x20];
bytes.extend_from_slice(b"ftyp");
bytes.extend_from_slice(brand);
bytes.extend_from_slice(&[0; 16]);
bytes
}
#[test]
fn isobmff_is_split_by_its_brand() {
assert_eq!(identify_bytes(&ftyp(b"isom")), Some(Container::Mp4));
assert_eq!(identify_bytes(&ftyp(b"mp42")), Some(Container::Mp4));
assert_eq!(identify_bytes(&ftyp(b"qt ")), Some(Container::Mp4));
assert_eq!(identify_bytes(&ftyp(b"avif")), Some(Container::Avif));
assert_eq!(identify_bytes(&ftyp(b"heic")), Some(Container::Heif));
}
#[test]
fn matroska_and_webm_are_told_apart_by_doctype() {
let mut mkv = vec![0x1A, 0x45, 0xDF, 0xA3];
mkv.extend_from_slice(&[0x01; 20]);
mkv.extend_from_slice(b"matroska");
let mut webm = vec![0x1A, 0x45, 0xDF, 0xA3];
webm.extend_from_slice(&[0x01; 20]);
webm.extend_from_slice(b"webm");
assert_eq!(identify_bytes(&mkv), Some(Container::Matroska));
assert_eq!(identify_bytes(&webm), Some(Container::WebM));
}
#[test]
fn riff_needs_its_form_type() {
let mut avi = b"RIFF".to_vec();
avi.extend_from_slice(&[0; 4]);
avi.extend_from_slice(b"AVI ");
let mut webp = b"RIFF".to_vec();
webp.extend_from_slice(&[0; 4]);
webp.extend_from_slice(b"WEBP");
let mut wav = b"RIFF".to_vec();
wav.extend_from_slice(&[0; 4]);
wav.extend_from_slice(b"WAVE");
assert_eq!(identify_bytes(&avi), Some(Container::Avi));
assert_eq!(identify_bytes(&webp), Some(Container::WebP));
assert_eq!(identify_bytes(&wav), None, "audio is not vtome's business");
}
#[test]
fn the_still_formats_identify() {
let cases: &[(&[u8], Container)] = &[
(
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
Container::Png,
),
(&[0xFF, 0xD8, 0xFF, 0xE0], Container::Jpeg),
(b"GIF89a ", Container::Gif),
(b"BM ", Container::Bmp),
(&[0x49, 0x49, 0x2A, 0x00], Container::Tiff),
(&[0x4D, 0x4D, 0x00, 0x2A], Container::Tiff),
];
for (bytes, expected) in cases {
assert_eq!(identify_bytes(bytes), Some(*expected), "{expected}");
assert!(expected.is_image(), "{expected} should be an image");
}
}
#[test]
fn nothing_recognises_nothing() {
assert_eq!(identify_bytes(b""), None);
assert_eq!(identify_bytes(b"not a media file at all"), None);
}
#[test]
fn a_file_is_identified_from_its_bytes_not_its_name() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("misleading.mp4");
let mut bytes = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
bytes.extend_from_slice(&[0; 64]);
std::fs::write(&path, &bytes).unwrap();
assert_eq!(identify_path(&path).unwrap(), Container::Png);
}
#[test]
fn an_unidentifiable_file_says_what_it_starts_with() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("mystery.bin");
std::fs::write(&path, b"\x01\x02\x03\x04nothing").unwrap();
let Err(Error::UnknownContainer { found, .. }) = identify_path(&path) else {
panic!("that is not a container");
};
assert!(found.contains("01 02 03 04"), "{found}");
}
#[test]
fn codec_ids_map_from_both_worlds() {
assert_eq!(Encoding::from_codec_id("avc1"), Some(Encoding::H264));
assert_eq!(
Encoding::from_codec_id("V_MPEG4/ISO/AVC"),
Some(Encoding::H264)
);
assert_eq!(Encoding::from_codec_id("hev1"), Some(Encoding::H265));
assert_eq!(Encoding::from_codec_id("V_AV1"), Some(Encoding::Av1));
assert_eq!(Encoding::from_codec_id("vp09"), Some(Encoding::Vp9));
assert_eq!(Encoding::from_codec_id("nonsense"), None);
}
#[test]
fn only_royalty_free_encodings_are_encodable() {
for encoding in [Encoding::H264, Encoding::H265, Encoding::ProRes] {
assert!(!encoding.is_royalty_free(), "{encoding}");
assert!(!encoding.is_encodable(), "{encoding} must never be written");
}
for encoding in [Encoding::Av1, Encoding::Vp9] {
assert!(
encoding.is_royalty_free() && encoding.is_encodable(),
"{encoding}"
);
}
}
}