1use std::fmt;
13use std::str::FromStr;
14
15use serde::{Deserialize, Serialize};
16
17use crate::{AudioCodec, VideoCodec};
18
19#[derive(Copy, Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum Container {
23 Mp4,
25 Mov,
27 Mkv,
29 Webm,
31}
32
33impl Container {
34 pub const ALL: [Self; 4] = [Self::Mp4, Self::Mov, Self::Mkv, Self::Webm];
36
37 #[must_use]
39 pub const fn as_str(self) -> &'static str {
40 match self {
41 Self::Mp4 => "mp4",
42 Self::Mov => "mov",
43 Self::Mkv => "mkv",
44 Self::Webm => "webm",
45 }
46 }
47
48 #[must_use]
51 pub const fn extensions(self) -> &'static [&'static str] {
52 match self {
53 Self::Mp4 => &["mp4", "m4v"],
54 Self::Mov => &["mov"],
55 Self::Mkv => &["mkv"],
56 Self::Webm => &["webm"],
57 }
58 }
59
60 #[must_use]
62 pub const fn mime_type(self) -> &'static str {
63 match self {
64 Self::Mp4 => "video/mp4",
65 Self::Mov => "video/quicktime",
66 Self::Mkv => "video/x-matroska",
67 Self::Webm => "video/webm",
68 }
69 }
70
71 #[must_use]
73 pub const fn video_codecs(self) -> &'static [VideoCodec] {
74 match self {
75 Self::Mp4 | Self::Mkv => &[
76 VideoCodec::H264,
77 VideoCodec::Hevc,
78 VideoCodec::Vp9,
79 VideoCodec::Av1,
80 VideoCodec::Mpeg4Part2,
81 ],
82 Self::Mov => &[VideoCodec::H264, VideoCodec::Hevc, VideoCodec::Mpeg4Part2],
83 Self::Webm => &[VideoCodec::Vp9, VideoCodec::Av1],
85 }
86 }
87
88 #[must_use]
91 pub const fn audio_codecs(self) -> &'static [AudioCodec] {
92 match self {
93 Self::Mp4 | Self::Mkv => &[AudioCodec::Aac, AudioCodec::Opus],
94 Self::Mov => &[AudioCodec::Aac],
95 Self::Webm => &[AudioCodec::Opus],
96 }
97 }
98
99 #[must_use]
101 pub const fn default_video_codec(self) -> VideoCodec {
102 self.video_codecs()[0]
103 }
104
105 #[must_use]
107 pub const fn default_audio_codec(self) -> AudioCodec {
108 self.audio_codecs()[0]
109 }
110
111 #[must_use]
113 pub fn offers_video(self, codec: VideoCodec) -> bool {
114 self.video_codecs().contains(&codec)
115 }
116
117 #[must_use]
119 pub fn offers_audio(self, codec: AudioCodec) -> bool {
120 self.audio_codecs().contains(&codec)
121 }
122
123 #[must_use]
126 pub fn from_extension(extension: &str) -> Option<Self> {
127 Self::ALL.into_iter().find(|container| {
128 container
129 .extensions()
130 .iter()
131 .any(|known| known.eq_ignore_ascii_case(extension))
132 })
133 }
134}
135
136impl fmt::Display for Container {
137 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
138 formatter.write_str(self.as_str())
139 }
140}
141
142impl FromStr for Container {
143 type Err = ContainerError;
144
145 fn from_str(value: &str) -> Result<Self, Self::Err> {
146 Self::ALL
147 .into_iter()
148 .find(|container| container.as_str() == value)
149 .ok_or_else(|| ContainerError::Unknown(value.to_owned()))
150 }
151}
152
153#[derive(Clone, Debug, Eq, PartialEq)]
155pub enum ContainerError {
156 Unknown(String),
158 UnknownExtension(Option<String>),
161 ExtensionMismatch {
163 container: Container,
165 extension: String,
167 },
168 VideoCodecNotOffered {
170 container: Container,
172 codec: VideoCodec,
174 },
175 AudioCodecNotOffered {
177 container: Container,
179 codec: AudioCodec,
181 },
182}
183
184fn dotted(extensions: &[&str]) -> String {
185 let dotted: Vec<String> = extensions.iter().map(|ext| format!(".{ext}")).collect();
186 dotted.join(", ")
187}
188
189fn listed<T>(items: &[T], name: impl Fn(&T) -> &'static str) -> String {
190 items.iter().map(name).collect::<Vec<_>>().join(", ")
191}
192
193impl fmt::Display for ContainerError {
194 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
195 match self {
196 Self::Unknown(name) => write!(
197 formatter,
198 "unknown container \"{name}\"; expected one of {}",
199 listed(&Container::ALL, |container| container.as_str())
200 ),
201 Self::UnknownExtension(extension) => {
202 let every: Vec<&str> = Container::ALL
203 .iter()
204 .flat_map(|container| container.extensions().iter().copied())
205 .collect();
206 match extension {
207 Some(extension) => write!(
208 formatter,
209 "the output extension \".{extension}\" names no container; use one of {}",
210 dotted(&every)
211 ),
212 None => write!(
213 formatter,
214 "the output has no extension to name its container; use one of {}",
215 dotted(&every)
216 ),
217 }
218 }
219 Self::ExtensionMismatch {
220 container,
221 extension,
222 } => {
223 let found = if extension.is_empty() {
224 "no extension".to_owned()
225 } else {
226 format!("\".{extension}\"")
227 };
228 write!(
229 formatter,
230 "a {container} output must end in {}, not {found}",
231 dotted(container.extensions())
232 )
233 }
234 Self::VideoCodecNotOffered { container, codec } => write!(
235 formatter,
236 "{container} does not offer video codec {}; it offers {}",
237 codec.as_str(),
238 listed(container.video_codecs(), |codec| codec.as_str())
239 ),
240 Self::AudioCodecNotOffered { container, codec } => write!(
241 formatter,
242 "{container} does not offer audio codec {}; it offers {}",
243 codec.as_str(),
244 listed(container.audio_codecs(), |codec| codec.as_str())
245 ),
246 }
247 }
248}
249
250impl std::error::Error for ContainerError {}
251
252pub fn resolve_export_format(
267 container: Option<Container>,
268 output_extension: Option<&str>,
269 video: Option<VideoCodec>,
270 audio: Option<AudioCodec>,
271) -> Result<(Container, VideoCodec, AudioCodec), ContainerError> {
272 let container = match (container, output_extension) {
273 (Some(container), Some(extension)) => {
274 if !container
275 .extensions()
276 .iter()
277 .any(|known| known.eq_ignore_ascii_case(extension))
278 {
279 return Err(ContainerError::ExtensionMismatch {
280 container,
281 extension: extension.to_owned(),
282 });
283 }
284 container
285 }
286 (Some(container), None) => container,
287 (None, Some(extension)) => Container::from_extension(extension).ok_or_else(|| {
288 ContainerError::UnknownExtension((!extension.is_empty()).then(|| extension.to_owned()))
289 })?,
290 (None, None) => Container::Mp4,
291 };
292 let video = video.unwrap_or(container.default_video_codec());
293 if !container.offers_video(video) {
294 return Err(ContainerError::VideoCodecNotOffered {
295 container,
296 codec: video,
297 });
298 }
299 let audio = audio.unwrap_or(container.default_audio_codec());
300 if !container.offers_audio(audio) {
301 return Err(ContainerError::AudioCodecNotOffered {
302 container,
303 codec: audio,
304 });
305 }
306 Ok((container, video, audio))
307}
308
309#[cfg(test)]
310#[path = "container_tests.rs"]
311mod tests;