Skip to main content

alpha2_media/
container.rs

1//! Export containers: the file formats an export is written in, and which
2//! codecs each one offers.
3//!
4//! This is one policy for every host. The native sinks, the host protocol and
5//! the browser exporter all validate a request against it, and the
6//! capabilities a host reports are copied from it. It is narrower than what
7//! the muxers accept: a container offers only the codecs its mainstream
8//! players decode there. `QuickTime` does not play VP9, AV1 or Opus in a MOV,
9//! and `WebM` is VP8/VP9/AV1 with Opus/Vorbis by definition. Whether a runtime
10//! can encode an offered codec is a separate question each host answers.
11
12use std::fmt;
13use std::str::FromStr;
14
15use serde::{Deserialize, Serialize};
16
17use crate::{AudioCodec, VideoCodec};
18
19/// The file format an export is written in.
20#[derive(Copy, Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum Container {
23    /// ISO base media file (MP4).
24    Mp4,
25    /// `QuickTime` movie.
26    Mov,
27    /// Matroska.
28    Mkv,
29    /// `WebM`, the Matroska subset browsers play.
30    Webm,
31}
32
33impl Container {
34    /// Every container, in the order the engine lists them.
35    pub const ALL: [Self; 4] = [Self::Mp4, Self::Mov, Self::Mkv, Self::Webm];
36
37    /// Stable identifier used by every protocol and binding.
38    #[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    /// Lowercase file extensions without the dot; the first is the one an
49    /// exporter names a file with.
50    #[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    /// The MIME type of a file in this container.
61    #[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    /// Video codecs the container offers, the default first.
72    #[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            // VP9 first: it has a fast software encoder and every browser decodes it.
84            Self::Webm => &[VideoCodec::Vp9, VideoCodec::Av1],
85        }
86    }
87
88    /// Audio codecs the container offers, the default first. A browser that
89    /// cannot encode the default tries the next one in this order.
90    #[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    /// The video codec an export in this container gets when it names none.
100    #[must_use]
101    pub const fn default_video_codec(self) -> VideoCodec {
102        self.video_codecs()[0]
103    }
104
105    /// The audio codec an export in this container gets when it names none.
106    #[must_use]
107    pub const fn default_audio_codec(self) -> AudioCodec {
108        self.audio_codecs()[0]
109    }
110
111    /// Whether the container offers `codec`.
112    #[must_use]
113    pub fn offers_video(self, codec: VideoCodec) -> bool {
114        self.video_codecs().contains(&codec)
115    }
116
117    /// Whether the container offers `codec`.
118    #[must_use]
119    pub fn offers_audio(self, codec: AudioCodec) -> bool {
120        self.audio_codecs().contains(&codec)
121    }
122
123    /// The container an output path's extension (without the dot) names,
124    /// case-insensitively.
125    #[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/// Why a container request cannot be resolved.
154#[derive(Clone, Debug, Eq, PartialEq)]
155pub enum ContainerError {
156    /// A container name outside [`Container::ALL`].
157    Unknown(String),
158    /// No container was named and the output's extension names none; `None`
159    /// when the output has no extension.
160    UnknownExtension(Option<String>),
161    /// The output's extension is not one of the named container's.
162    ExtensionMismatch {
163        /// The container the request named.
164        container: Container,
165        /// The output's extension, empty when it has none.
166        extension: String,
167    },
168    /// The container does not offer the requested video codec.
169    VideoCodecNotOffered {
170        /// The resolved container.
171        container: Container,
172        /// The requested codec.
173        codec: VideoCodec,
174    },
175    /// The container does not offer the requested audio codec.
176    AudioCodecNotOffered {
177        /// The resolved container.
178        container: Container,
179        /// The requested codec.
180        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
252/// The export's container and codecs, resolved once at a host's edge.
253///
254/// - `container` names the container. When it is absent, `output_extension`
255///   names it, and with neither (the browser, which writes no path) it is MP4.
256/// - A named container and an output extension must agree.
257/// - An absent codec is the container's default, and a named one must be one
258///   the container offers.
259///
260/// `output_extension` is the extension of the path being written, without the
261/// dot and empty when the path has none; `None` means there is no path.
262///
263/// # Errors
264/// Returns the first [`ContainerError`] the request runs into, in the order
265/// above.
266pub 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;