Skip to main content

mediaway_container/
webm.rs

1//! Mediaway-typed `WebM` mux + demux surface over [`ebml_webm`].
2//!
3//! `ebml_webm::TrackInfo::codec_id` is a raw `WebM` string (e.g. `"V_VP9"`,
4//! `"V_VP8"`); [`Demuxer::streams`] maps every codec [`CodecKind`] has a
5//! `WebM` `CodecID` for (`Vp8`, `Vp9`, `Av1`, `Opus`, `Aac`, `Vorbis` —
6//! `CodecKind::Vp8` closed the last gap here), and [`Muxer::add_track`]
7//! rejects any other codec rather than writing a `CodecID` this facade can't
8//! round-trip. See `adr/0003` (this crate) and `ebml-webm/adr/0003` for mux.
9
10#![forbid(unsafe_code)]
11
12#[cfg(feature = "demux")]
13use crate::Demux;
14#[cfg(feature = "mux")]
15use crate::{Mux, MuxOpen};
16#[cfg(feature = "demux")]
17use ebml_webm::Demuxer as CoreDemuxer;
18use ebml_webm::TrackInfo as CoreTrackInfo;
19pub use ebml_webm::{CuePoint, SeekEntry};
20#[cfg(feature = "mux")]
21use ebml_webm::{Muxer as CoreMuxer, mux::Live as CoreLive, mux::Open as CoreOpen};
22use mediaway_common::{
23    Bytes, CodecKind, Packet, Rational as MwRational, StreamInfo, VideoGeometry,
24};
25
26/// Demuxer wrapping [`ebml_webm::Demuxer`] with a Mediaway stream cache.
27#[cfg(feature = "demux")]
28#[derive(Debug, Default)]
29pub struct Demuxer {
30    inner: CoreDemuxer,
31    streams: Vec<StreamInfo>,
32}
33
34#[cfg(feature = "demux")]
35impl Demuxer {
36    /// Empty demuxer.
37    #[must_use]
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Feed bytes.
43    pub fn push_bytes(&mut self, chunk: &[u8]) {
44        self.inner.push_bytes(chunk);
45        self.sync_streams();
46    }
47
48    /// Tracks whose `WebM` `CodecID` maps to a Mediaway [`CodecKind`] (see
49    /// module docs for the current mapping gap).
50    #[must_use]
51    pub fn streams(&self) -> &[StreamInfo] {
52        &self.streams
53    }
54
55    /// `Segment\Cues\CuePoint` entries seen so far — informational seek
56    /// index; this crate does no seeking itself (sans-io: I/O and
57    /// seek-driven re-reads are the adapter's job). Passed through from
58    /// [`ebml_webm::Demuxer::cues`] unchanged (plain offsets, nothing
59    /// codec-specific to convert).
60    #[must_use]
61    pub fn cues(&self) -> &[CuePoint] {
62        self.inner.cues()
63    }
64
65    /// `Segment\SeekHead\Seek` entries seen so far — same "informational
66    /// only" status as [`cues`](Self::cues).
67    #[must_use]
68    pub fn seek_head(&self) -> &[SeekEntry] {
69        self.inner.seek_head()
70    }
71
72    /// Next demuxed packet. Frames from tracks with no `CodecKind` mapping
73    /// are skipped (not returned as `Err` — the `Demux` trait has no error
74    /// channel here; see module docs).
75    pub fn poll_packet(&mut self) -> Option<Packet> {
76        loop {
77            let frame = self.inner.poll_frame()?;
78            let id = track_id(frame.track_number);
79            if !self.streams.iter().any(|s| s.id() == id) {
80                continue;
81            }
82            return Some(Packet {
83                stream_id: id,
84                pts: frame.timecode,
85                // `WebM` `SimpleBlock` carries no separate decode timestamp;
86                // v1 has no B-frame reorder support, so pts == dts.
87                dts: frame.timecode,
88                // `BlockGroup`'s `BlockDuration` is in the same tick unit as
89                // `timecode` (both scaled by `TimecodeScale`); `SimpleBlock`
90                // frames carry no duration, so default to 0.
91                duration: frame.duration_ticks.unwrap_or(0),
92                is_keyframe: frame.is_keyframe,
93                is_discard: false,
94                payload: frame.payload,
95            });
96        }
97    }
98
99    fn sync_streams(&mut self) {
100        let tb = self.inner.time_base();
101        let time_base = MwRational::new(tb.num, tb.den);
102        self.streams = self
103            .inner
104            .streams()
105            .iter()
106            .filter_map(|t| to_stream_info(t, time_base))
107            .collect();
108    }
109}
110
111#[cfg(feature = "demux")]
112#[allow(clippy::use_self)]
113impl Demux for Demuxer {
114    fn push_bytes(&mut self, chunk: &[u8]) {
115        Demuxer::push_bytes(self, chunk);
116    }
117
118    fn streams(&self) -> &[StreamInfo] {
119        Demuxer::streams(self)
120    }
121
122    fn poll_packet(&mut self) -> Option<Packet> {
123        Demuxer::poll_packet(self)
124    }
125}
126
127/// `WebM` `TrackNumber` <-> [`StreamInfo`]/[`Packet`]'s `u32` id — a 1:1,
128/// lossless mapping for every `track_number` either side of this facade
129/// itself ever produces (mux assigns small sequential numbers; demux
130/// saturates an out-of-`u32`-range `TrackNumber` to `u32::MAX`, a real `WebM`
131/// file this facade round-trips would never hit in practice).
132fn track_id(track_number: u64) -> u32 {
133    u32::try_from(track_number).unwrap_or(u32::MAX)
134}
135
136#[cfg(feature = "demux")]
137fn to_stream_info(t: &CoreTrackInfo, time_base: MwRational) -> Option<StreamInfo> {
138    let codec = codec_kind(&t.codec_id)?;
139    let id = track_id(t.track_number);
140    if t.is_video() {
141        Some(StreamInfo::Video {
142            id,
143            codec,
144            time_base,
145            geometry: VideoGeometry {
146                width: t.width,
147                height: t.height,
148            },
149            extra_data: t.codec_private.clone().unwrap_or_default(),
150        })
151    } else {
152        Some(StreamInfo::Audio {
153            id,
154            codec,
155            time_base,
156            extra_data: Bytes::new(),
157            sample_rate: sample_rate_u32(t.sample_rate),
158            channels: channels_u16(t.channels),
159        })
160    }
161}
162
163/// `ebml_webm::TrackInfo::sample_rate` is an EBML Float (`f64`); MP4/StreamInfo
164/// use `u32` Hz. Clamps in-range finite values rather than wrapping; NaN and
165/// +-infinity (malformed input either way) map to `0` ("unknown"), not a
166/// saturated bound.
167#[cfg(feature = "demux")]
168#[allow(
169    clippy::cast_possible_truncation,
170    clippy::cast_sign_loss,
171    reason = "saturating conversion from EBML Float sample rate to Hz"
172)]
173fn sample_rate_u32(sample_rate: f64) -> u32 {
174    if sample_rate.is_finite() {
175        sample_rate.clamp(0.0, f64::from(u32::MAX)) as u32
176    } else {
177        0
178    }
179}
180
181#[cfg(feature = "demux")]
182#[allow(
183    clippy::cast_possible_truncation,
184    reason = "saturating conversion from EBML uint channel count to StreamInfo's u16"
185)]
186const fn channels_u16(channels: u32) -> u16 {
187    if channels > 0xFFFF {
188        u16::MAX
189    } else {
190        channels as u16
191    }
192}
193
194/// Map a `WebM` `CodecID` string to [`CodecKind`]. `None` for codecs
195/// `CodecKind` has no variant for yet (notably VP8, Vorbis).
196#[cfg(feature = "demux")]
197fn codec_kind(codec_id: &str) -> Option<CodecKind> {
198    match codec_id {
199        "V_VP8" => Some(CodecKind::Vp8),
200        "V_VP9" => Some(CodecKind::Vp9),
201        "V_AV1" => Some(CodecKind::Av1),
202        "A_OPUS" => Some(CodecKind::Opus),
203        "A_VORBIS" => Some(CodecKind::Vorbis),
204        id if id.starts_with("A_AAC") => Some(CodecKind::Aac),
205        _ => None,
206    }
207}
208
209/// Map a [`CodecKind`] to the `WebM` `CodecID` string [`Muxer::add_track`]
210/// writes — the exact reverse of [`codec_kind`] (kept as two separate small
211/// functions rather than one bidirectional table: the sets aren't quite
212/// symmetric, e.g. demux's `starts_with("A_AAC")` accepts profile-suffixed
213/// IDs a real encoder never needs to write). `None` for any [`CodecKind`]
214/// `WebM` doesn't natively carry (H.264, HEVC, MP3, …) — [`Muxer::add_track`]
215/// turns that into [`Error::UnsupportedCodec`].
216#[cfg(feature = "mux")]
217const fn webm_codec_id(codec: CodecKind) -> Option<&'static str> {
218    match codec {
219        CodecKind::Vp8 => Some("V_VP8"),
220        CodecKind::Vp9 => Some("V_VP9"),
221        CodecKind::Av1 => Some("V_AV1"),
222        CodecKind::Opus => Some("A_OPUS"),
223        CodecKind::Vorbis => Some("A_VORBIS"),
224        CodecKind::Aac => Some("A_AAC"),
225        _ => None,
226    }
227}
228
229/// [`Muxer`] error.
230#[cfg(feature = "mux")]
231#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
232#[non_exhaustive]
233pub enum Error {
234    /// Wraps [`ebml_webm::MuxError`] (bad `TrackNumber`, unregistered track).
235    #[error(transparent)]
236    Mux(#[from] ebml_webm::MuxError),
237    /// [`Muxer::add_track`]: `WebM` has no `CodecID` for this [`CodecKind`]
238    /// (see [`webm_codec_id`]).
239    #[error("WebM mux has no CodecID mapping for {0:?}")]
240    UnsupportedCodec(CodecKind),
241    /// A `Mux::push_packet` call with no track registered for `stream_id`.
242    #[error("push_packet references unregistered stream_id {0}")]
243    UnknownStream(u32),
244}
245
246/// Track registration phase — call [`Muxer::begin`] before pushing packets.
247#[cfg(feature = "mux")]
248#[derive(Debug, Clone, Copy, Default)]
249pub struct Open;
250
251/// Streaming phase — `push_packet`/`poll_bytes`.
252#[cfg(feature = "mux")]
253#[derive(Debug, Clone, Copy, Default)]
254pub struct Live;
255
256/// Muxer wrapping [`ebml_webm::Muxer`] — typestate mirrors [`mp4::Muxer`](crate::mp4::Muxer).
257#[cfg(feature = "mux")]
258#[derive(Debug)]
259pub struct Muxer<S = Open> {
260    open: Option<CoreMuxer<CoreOpen>>,
261    live: Option<CoreMuxer<CoreLive>>,
262    tracks_cache: Vec<StreamInfo>,
263    _state: core::marker::PhantomData<S>,
264}
265
266#[cfg(feature = "mux")]
267impl Muxer<Open> {
268    /// Empty muxer.
269    #[must_use]
270    pub fn new() -> Self {
271        Self {
272            open: Some(CoreMuxer::new()),
273            live: None,
274            tracks_cache: Vec::new(),
275            _state: core::marker::PhantomData,
276        }
277    }
278
279    /// Registered tracks so far.
280    #[must_use]
281    pub fn tracks(&self) -> &[StreamInfo] {
282        &self.tracks_cache
283    }
284
285    /// Register a track.
286    ///
287    /// # Errors
288    ///
289    /// Register a track (video or audio) for muxing, returning its track
290    /// number. The stream's `extra_data` (e.g. `OpusHead`) becomes the
291    /// `CodecPrivate` element of the track entry.
292    ///
293    /// # Errors
294    ///
295    /// [`Error::UnsupportedCodec`] if `WebM` has no `CodecID` for this
296    /// track's codec; [`Error::Mux`] for an invalid/duplicate track number.
297    pub fn add_track(&mut self, track: StreamInfo) -> Result<u32, Error> {
298        /// `CodecPrivate` payload for a track (`OpusHead` etc.), when the
299        /// caller supplied one via `extra_data`.
300        fn track_extra(track: &StreamInfo) -> Option<Bytes> {
301            match track {
302                StreamInfo::Video { extra_data, .. } | StreamInfo::Audio { extra_data, .. }
303                    if !extra_data.is_empty() =>
304                {
305                    Some(extra_data.clone())
306                }
307                _ => None,
308            }
309        }
310        let codec_id =
311            webm_codec_id(track.codec()).ok_or_else(|| Error::UnsupportedCodec(track.codec()))?;
312        let id = track.id();
313        let core_track = match &track {
314            StreamInfo::Video {
315                geometry,
316                extra_data,
317                ..
318            } => CoreTrackInfo {
319                track_number: u64::from(id),
320                track_type: 1,
321                codec_id: codec_id.to_string(),
322                codec_private: (!extra_data.is_empty()).then(|| extra_data.clone()),
323                width: geometry.width,
324                height: geometry.height,
325                sample_rate: 8000.0,
326                channels: 1,
327            },
328            StreamInfo::Audio {
329                sample_rate,
330                channels,
331                ..
332            } => CoreTrackInfo {
333                track_number: u64::from(id),
334                track_type: 2,
335                codec_id: codec_id.to_string(),
336                codec_private: track_extra(&track),
337                width: 0,
338                height: 0,
339                sample_rate: f64::from(*sample_rate),
340                channels: u32::from(*channels),
341            },
342            // `StreamInfo` is `#[non_exhaustive]` — a future non-Video/Audio
343            // variant has no WebM track shape to map to yet.
344            _ => return Err(Error::UnsupportedCodec(track.codec())),
345        };
346        let open = self.open.as_mut().ok_or(Error::UnknownStream(id))?;
347        open.add_track(core_track)?;
348        self.tracks_cache.push(track);
349        Ok(id)
350    }
351
352    /// Lock tracks and enter the live streaming state.
353    #[must_use]
354    pub fn begin(mut self) -> Muxer<Live> {
355        let open = self.open.take().unwrap_or_default();
356        Muxer {
357            open: None,
358            live: Some(open.begin()),
359            tracks_cache: self.tracks_cache,
360            _state: core::marker::PhantomData,
361        }
362    }
363}
364
365#[cfg(feature = "mux")]
366impl Default for Muxer<Open> {
367    fn default() -> Self {
368        Self::new()
369    }
370}
371
372#[cfg(feature = "mux")]
373impl Muxer<Live> {
374    /// Registered tracks.
375    #[must_use]
376    pub fn tracks(&self) -> &[StreamInfo] {
377        &self.tracks_cache
378    }
379
380    /// Push one packet.
381    ///
382    /// # Errors
383    ///
384    /// [`Error::UnknownStream`] if `packet.stream_id` was never registered
385    /// via `add_track`; [`Error::Mux`] on an underlying mux error.
386    pub fn push_packet(&mut self, packet: &Packet) -> Result<(), Error> {
387        if !self.tracks_cache.iter().any(|t| t.id() == packet.stream_id) {
388            return Err(Error::UnknownStream(packet.stream_id));
389        }
390        let live = self
391            .live
392            .as_mut()
393            .ok_or(Error::UnknownStream(packet.stream_id))?;
394        live.push_frame(
395            u64::from(packet.stream_id),
396            packet.pts,
397            packet.is_keyframe,
398            &packet.payload,
399        )?;
400        Ok(())
401    }
402
403    /// Force the open `Cluster` to close.
404    pub fn flush(&mut self) {
405        if let Some(live) = self.live.as_mut() {
406            live.flush();
407        }
408    }
409
410    /// Poll container bytes.
411    pub fn poll_bytes(&mut self, out: &mut Vec<u8>) -> usize {
412        self.live.as_mut().map_or(0, |m| m.poll_bytes(out))
413    }
414}
415
416#[cfg(feature = "mux")]
417#[allow(clippy::use_self)]
418impl Mux for Muxer<Live> {
419    type Error = Error;
420
421    fn push_packet(&mut self, packet: &Packet) -> Result<(), Self::Error> {
422        Muxer::<Live>::push_packet(self, packet)
423    }
424
425    fn flush(&mut self) {
426        Muxer::<Live>::flush(self);
427    }
428
429    fn poll_bytes(&mut self, out: &mut Vec<u8>) -> usize {
430        Muxer::<Live>::poll_bytes(self, out)
431    }
432
433    // `set_track_extra_data` is deliberately left at its no-op default: `CodecPrivate`
434    // is written into `Tracks` in the EBML header at `begin()`, so there is nothing to
435    // revise afterwards. See the trait method's own note.
436}
437
438#[cfg(feature = "mux")]
439#[allow(clippy::use_self)]
440impl MuxOpen for Muxer<Open> {
441    type Live = Muxer<Live>;
442    type Error = Error;
443
444    /// Matroska reserves `TrackNumber` `0` — see [`ebml_webm::MuxError::InvalidTrackNumber`].
445    const FIRST_TRACK_ID: u32 = 1;
446
447    fn add_track(&mut self, track: StreamInfo) -> Result<u32, Self::Error> {
448        Muxer::<Open>::add_track(self, track)
449    }
450
451    fn begin(self) -> Self::Live {
452        Muxer::<Open>::begin(self)
453    }
454}
455
456#[cfg(test)]
457#[path = "webm_tests.rs"]
458mod tests;