use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::{AudioCodec, VideoCodec};
#[derive(Copy, Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Container {
Mp4,
Mov,
Mkv,
Webm,
}
impl Container {
pub const ALL: [Self; 4] = [Self::Mp4, Self::Mov, Self::Mkv, Self::Webm];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Mp4 => "mp4",
Self::Mov => "mov",
Self::Mkv => "mkv",
Self::Webm => "webm",
}
}
#[must_use]
pub const fn extensions(self) -> &'static [&'static str] {
match self {
Self::Mp4 => &["mp4", "m4v"],
Self::Mov => &["mov"],
Self::Mkv => &["mkv"],
Self::Webm => &["webm"],
}
}
#[must_use]
pub const fn mime_type(self) -> &'static str {
match self {
Self::Mp4 => "video/mp4",
Self::Mov => "video/quicktime",
Self::Mkv => "video/x-matroska",
Self::Webm => "video/webm",
}
}
#[must_use]
pub const fn video_codecs(self) -> &'static [VideoCodec] {
match self {
Self::Mp4 | Self::Mkv => &[
VideoCodec::H264,
VideoCodec::Hevc,
VideoCodec::Vp9,
VideoCodec::Av1,
VideoCodec::Mpeg4Part2,
],
Self::Mov => &[VideoCodec::H264, VideoCodec::Hevc, VideoCodec::Mpeg4Part2],
Self::Webm => &[VideoCodec::Vp9, VideoCodec::Av1],
}
}
#[must_use]
pub const fn audio_codecs(self) -> &'static [AudioCodec] {
match self {
Self::Mp4 | Self::Mkv => &[AudioCodec::Aac, AudioCodec::Opus],
Self::Mov => &[AudioCodec::Aac],
Self::Webm => &[AudioCodec::Opus],
}
}
#[must_use]
pub const fn default_video_codec(self) -> VideoCodec {
self.video_codecs()[0]
}
#[must_use]
pub const fn default_audio_codec(self) -> AudioCodec {
self.audio_codecs()[0]
}
#[must_use]
pub fn offers_video(self, codec: VideoCodec) -> bool {
self.video_codecs().contains(&codec)
}
#[must_use]
pub fn offers_audio(self, codec: AudioCodec) -> bool {
self.audio_codecs().contains(&codec)
}
#[must_use]
pub fn from_extension(extension: &str) -> Option<Self> {
Self::ALL.into_iter().find(|container| {
container
.extensions()
.iter()
.any(|known| known.eq_ignore_ascii_case(extension))
})
}
}
impl fmt::Display for Container {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for Container {
type Err = ContainerError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::ALL
.into_iter()
.find(|container| container.as_str() == value)
.ok_or_else(|| ContainerError::Unknown(value.to_owned()))
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ContainerError {
Unknown(String),
UnknownExtension(Option<String>),
ExtensionMismatch {
container: Container,
extension: String,
},
VideoCodecNotOffered {
container: Container,
codec: VideoCodec,
},
AudioCodecNotOffered {
container: Container,
codec: AudioCodec,
},
}
fn dotted(extensions: &[&str]) -> String {
let dotted: Vec<String> = extensions.iter().map(|ext| format!(".{ext}")).collect();
dotted.join(", ")
}
fn listed<T>(items: &[T], name: impl Fn(&T) -> &'static str) -> String {
items.iter().map(name).collect::<Vec<_>>().join(", ")
}
impl fmt::Display for ContainerError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unknown(name) => write!(
formatter,
"unknown container \"{name}\"; expected one of {}",
listed(&Container::ALL, |container| container.as_str())
),
Self::UnknownExtension(extension) => {
let every: Vec<&str> = Container::ALL
.iter()
.flat_map(|container| container.extensions().iter().copied())
.collect();
match extension {
Some(extension) => write!(
formatter,
"the output extension \".{extension}\" names no container; use one of {}",
dotted(&every)
),
None => write!(
formatter,
"the output has no extension to name its container; use one of {}",
dotted(&every)
),
}
}
Self::ExtensionMismatch {
container,
extension,
} => {
let found = if extension.is_empty() {
"no extension".to_owned()
} else {
format!("\".{extension}\"")
};
write!(
formatter,
"a {container} output must end in {}, not {found}",
dotted(container.extensions())
)
}
Self::VideoCodecNotOffered { container, codec } => write!(
formatter,
"{container} does not offer video codec {}; it offers {}",
codec.as_str(),
listed(container.video_codecs(), |codec| codec.as_str())
),
Self::AudioCodecNotOffered { container, codec } => write!(
formatter,
"{container} does not offer audio codec {}; it offers {}",
codec.as_str(),
listed(container.audio_codecs(), |codec| codec.as_str())
),
}
}
}
impl std::error::Error for ContainerError {}
pub fn resolve_export_format(
container: Option<Container>,
output_extension: Option<&str>,
video: Option<VideoCodec>,
audio: Option<AudioCodec>,
) -> Result<(Container, VideoCodec, AudioCodec), ContainerError> {
let container = match (container, output_extension) {
(Some(container), Some(extension)) => {
if !container
.extensions()
.iter()
.any(|known| known.eq_ignore_ascii_case(extension))
{
return Err(ContainerError::ExtensionMismatch {
container,
extension: extension.to_owned(),
});
}
container
}
(Some(container), None) => container,
(None, Some(extension)) => Container::from_extension(extension).ok_or_else(|| {
ContainerError::UnknownExtension((!extension.is_empty()).then(|| extension.to_owned()))
})?,
(None, None) => Container::Mp4,
};
let video = video.unwrap_or(container.default_video_codec());
if !container.offers_video(video) {
return Err(ContainerError::VideoCodecNotOffered {
container,
codec: video,
});
}
let audio = audio.unwrap_or(container.default_audio_codec());
if !container.offers_audio(audio) {
return Err(ContainerError::AudioCodecNotOffered {
container,
codec: audio,
});
}
Ok((container, video, audio))
}
#[cfg(test)]
#[path = "container_tests.rs"]
mod tests;