Files
deckcraft/crates/media/src/probe.rs
T
Dylan M. Taylorandaiongg 92a0437ab6 Media: check an MP4's sample tables where Symphonia will start reading it (#1737)
* media: check an MP4 for oversized sample tables where symphonia will start reading it

* address review: run MP4 table guard only for MP4 or unknown containers

* Apply review fixes

- crates/media/src/probe.rs
- crates/media/src/mp4guard.rs
- crates/media/src/audio.rs

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-11 03:28:15 -05:00

262 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Media probing: container, duration, audio and video stream parameters — without decoding
//! (except for audio files whose container doesn't state a length).
use crate::video::{codec_name, mkv_codec_name};
use crate::{ASF_HEADER, Bytes, Container, MediaError, Result};
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AudioInfo {
pub codec: String,
pub sample_rate: u32,
pub channels: u16,
/// DeckCraft can decode it.
pub decodable: bool,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct VideoInfo {
pub codec: String,
pub width: u32,
pub height: u32,
pub fps: f64,
pub frames: u64,
pub decodable: bool,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MediaInfo {
pub container: &'static str,
/// Duration in milliseconds (0 when unknown).
pub duration_ms: u64,
pub audio: Option<AudioInfo>,
pub video: Option<VideoInfo>,
}
impl MediaInfo {
pub fn duration(&self) -> f64 {
self.duration_ms as f64 / 1000.0
}
/// Something in it can be played.
pub fn playable(&self) -> bool {
self.audio.as_ref().is_some_and(|a| a.decodable) || self.video.as_ref().is_some_and(|v| v.decodable)
}
}
fn ms(secs: f64) -> u64 {
if secs.is_finite() && secs > 0.0 { (secs * 1000.0).round() as u64 } else { 0 }
}
/// Probe `bytes`.
pub fn probe(bytes: &Bytes) -> Result<MediaInfo> {
let mut info = probe_headers(bytes)?;
if info.duration_ms == 0 && info.audio.as_ref().is_some_and(|a| a.decodable) && info.video.is_none() {
// No length in the header (raw MP3 / ADTS without an index): decode to measure.
if let Ok(pcm) = crate::audio::decode(bytes) {
info.duration_ms = ms(pcm.duration());
}
}
Ok(info)
}
/// [`probe`] from the headers alone, without decoding: a raw MP3 or ADTS stream without an
/// index reports a `duration_ms` of 0.
pub fn probe_headers(bytes: &Bytes) -> Result<MediaInfo> {
let c = Container::sniff(bytes);
let mut info = match c {
Container::Mp4 => probe_mp4(bytes)?,
Container::Matroska => probe_mkv(bytes)?,
Container::Asf => probe_asf(bytes)?,
Container::Ogg => probe_ogg(bytes).unwrap_or_else(|| MediaInfo { container: "ogg", ..Default::default() }),
_ => MediaInfo { container: c.name(), ..Default::default() },
};
if info.audio.is_none() && info.video.is_none() || info.audio.as_ref().is_some_and(|a| a.sample_rate == 0) {
// Plain audio files (and anything our demuxers didn't describe): symphonia's probe.
// Symphonia opens an MP4 wherever its probe finds an `ftyp` (after leading ID3v2 tags too); other known containers (a WAV's audio bytes can spell an `ftyp`) skip the check.
if matches!(c, Container::Mp4 | Container::Unknown | Container::Mp3) && !crate::mp4guard::probe_fits(bytes) {
return Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into()));
}
let (codec, rate, channels, frames) = std::panic::catch_unwind(|| crate::audio::probe_symphonia(bytes, c.name()))
.ok()
.flatten()
.ok_or_else(|| MediaError::Unsupported("this kind of media file".into()))?;
info.audio = Some(AudioInfo { codec: codec_label(&codec), sample_rate: rate, channels, decodable: true });
if info.duration_ms == 0
&& let Some(n) = frames
&& rate > 0
{
info.duration_ms = ms(n as f64 / rate as f64);
}
}
Ok(info)
}
fn codec_label(short: &str) -> String {
match short {
"mp3" => "MP3".into(),
"mp2" => "MP2".into(),
"mp1" => "MP1".into(),
"aac" => "AAC".into(),
"alac" => "ALAC".into(),
"flac" => "FLAC".into(),
"vorbis" => "Vorbis".into(),
s if s.starts_with("pcm") => "PCM".into(),
s if s.starts_with("adpcm") => "ADPCM".into(),
s => s.to_string(),
}
}
fn probe_mp4(bytes: &Bytes) -> Result<MediaInfo> {
use deckcraft_isobmff::{CodecConfig, TrackKind};
let src: &[u8] = bytes;
let f = deckcraft_isobmff::open(src).map_err(|e| MediaError::Corrupt(e.to_string()))?;
let mut info = MediaInfo { container: if f.is_quicktime { "mov" } else { "mp4" }, ..Default::default() };
let mut dur = if f.timescale > 0 { f.duration as f64 / f.timescale as f64 } else { 0.0 };
if let Some(ti) = f.track_of_kind(TrackKind::Video) {
let t = &f.tracks[ti];
let secs = if t.timescale > 0 { t.duration as f64 / t.timescale as f64 } else { 0.0 };
if let Some(e) = t.entries.first() {
let (w, h) = e.video.as_ref().map(|v| (v.width as u32, v.height as u32)).unwrap_or((t.width, t.height));
let decodable = matches!(e.codec, CodecConfig::Avc(_) | CodecConfig::Hevc(_) | CodecConfig::Vp9(_) | CodecConfig::Av1(_));
let frames = t.samples.len() as u64;
info.video = Some(VideoInfo {
codec: codec_name(&e.codec),
width: w,
height: h,
fps: if secs > 0.0 { frames as f64 / secs } else { 0.0 },
frames,
decodable,
});
}
dur = dur.max(secs);
}
if let Some(ti) = f.track_of_kind(TrackKind::Audio) {
let t = &f.tracks[ti];
if let Some(e) = t.entries.first() {
let (rate, channels) = e.audio.as_ref().map(|a| (a.sample_rate.round() as u32, a.channels as u16)).unwrap_or((0, 0));
let decodable =
matches!(e.codec, CodecConfig::Aac(_) | CodecConfig::Mp3 | CodecConfig::Pcm(_) | CodecConfig::Alac { .. } | CodecConfig::Flac(_));
info.audio = Some(AudioInfo { codec: codec_name(&e.codec), sample_rate: rate, channels, decodable });
}
if t.timescale > 0 {
dur = dur.max(t.duration as f64 / t.timescale as f64);
}
}
info.duration_ms = ms(dur);
Ok(info)
}
fn probe_mkv(bytes: &Bytes) -> Result<MediaInfo> {
use deckcraft_matroska::{Codec, TrackKind};
let src: &[u8] = bytes;
let f = deckcraft_matroska::open(src).map_err(|e| MediaError::Corrupt(e.to_string()))?;
let mut info = MediaInfo { container: if f.is_webm() { "webm" } else { "matroska" }, ..Default::default() };
let dur = f.duration_ns().map(|n| n as f64 / 1e9).unwrap_or(0.0);
info.duration_ms = ms(dur);
if let Some(t) = f.tracks.iter().find(|t| t.kind == TrackKind::Video) {
let (w, h) = t.video.as_ref().map(|v| (v.pixel_width, v.pixel_height)).unwrap_or((0, 0));
let frames = t.samples.len() as u64;
let fps = match t.default_duration_ns {
Some(d) if d > 0 => 1e9 / d as f64,
_ if dur > 0.0 => frames as f64 / dur,
_ => 0.0,
};
let decodable = matches!(t.codec, Codec::Avc { .. } | Codec::Hevc { .. } | Codec::Vp9 { .. } | Codec::Av1 { .. });
info.video = Some(VideoInfo { codec: mkv_codec_name(&t.codec), width: w, height: h, fps, frames, decodable });
}
if let Some(t) = f.tracks.iter().find(|t| t.kind == TrackKind::Audio) {
let (rate, channels) = t
.audio
.as_ref()
.map(|a| (a.output_sampling_frequency.unwrap_or(a.sampling_frequency).round() as u32, a.channels as u16))
.unwrap_or((0, 0));
let rate = if matches!(t.codec, Codec::Opus { .. }) { 48_000 } else { rate };
let decodable =
matches!(t.codec, Codec::Opus { .. } | Codec::Vorbis { .. } | Codec::Aac { .. } | Codec::Flac { .. } | Codec::Mp3 | Codec::Pcm { .. });
info.audio = Some(AudioInfo { codec: mkv_codec_name(&t.codec), sample_rate: rate, channels, decodable });
}
Ok(info)
}
fn probe_ogg(bytes: &Bytes) -> Option<MediaInfo> {
let src: &[u8] = bytes;
let f = deckcraft_ogg::open(src).ok()?;
let s = f.stream_of(deckcraft_ogg::Codec::Opus)?;
let st = f.streams.get(s)?;
let head = deckcraft_opus::OpusHead::parse(st.headers.first()?).ok()?;
let timing = deckcraft_ogg::OpusTiming::of(st, head.pre_skip as u32).ok()?;
Some(MediaInfo {
container: "ogg",
duration_ms: ms(timing.total as f64 / 48_000.0),
audio: Some(AudioInfo { codec: "Opus".into(), sample_rate: 48_000, channels: head.channels as u16, decodable: true }),
video: None,
})
}
/// Windows Media (ASF): enough of the header to describe the file. We have no WMA/WMV decoder.
fn probe_asf(b: &[u8]) -> Result<MediaInfo> {
const FILE_PROPS: [u8; 16] = [0xA1, 0xDC, 0xAB, 0x8C, 0x47, 0xA9, 0xCF, 0x11, 0x8E, 0xE4, 0x00, 0xC0, 0x0C, 0x20, 0x53, 0x65];
const STREAM_PROPS: [u8; 16] = [0x91, 0x07, 0xDC, 0xB7, 0xB7, 0xA9, 0xCF, 0x11, 0x8E, 0xE6, 0x00, 0xC0, 0x0C, 0x20, 0x53, 0x65];
const AUDIO_MEDIA: [u8; 16] = [0x40, 0x9E, 0x69, 0xF8, 0x4D, 0x5B, 0xCF, 0x11, 0xA8, 0xFD, 0x00, 0x80, 0x5F, 0x5C, 0x44, 0x2B];
const VIDEO_MEDIA: [u8; 16] = [0xC0, 0xEF, 0x19, 0xBC, 0x4D, 0x5B, 0xCF, 0x11, 0xA8, 0xFD, 0x00, 0x80, 0x5F, 0x5C, 0x44, 0x2B];
let u16le = |o: usize| b.get(o..o + 2).map(|s| u16::from_le_bytes([s[0], s[1]])).unwrap_or(0);
let u32le = |o: usize| b.get(o..o + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])).unwrap_or(0);
let u64le = |o: usize| b.get(o..o + 8).and_then(|s| s.try_into().ok()).map(u64::from_le_bytes).unwrap_or(0);
if b.get(..16) != Some(&ASF_HEADER[..]) {
return Err(MediaError::Corrupt("not an ASF file".into()));
}
let header_end = (u64le(16) as usize).min(b.len());
let mut info = MediaInfo { container: "asf", ..Default::default() };
let mut pos: usize = 30;
while pos.checked_add(24).is_some_and(|end| end <= header_end) {
let guid = &b[pos..pos + 16];
let size = u64le(pos + 16) as usize;
if size < 24 {
break;
}
let d = pos + 24;
if guid == FILE_PROPS {
// file id 16, file size 8, creation 8, packets 8, play duration 8 (100 ns), send 8, preroll 8 (ms)
let play = u64le(d + 40) as f64 / 1e7;
let preroll = u64le(d + 56) as f64 / 1e3;
info.duration_ms = ms(play - preroll);
} else if guid == STREAM_PROPS {
let kind = b.get(d..d + 16);
// type 16, error correction type 16, time offset 8, type data len 4, ec len 4, flags 2, reserved 4
let td = d + 54;
if kind == Some(&AUDIO_MEDIA[..]) {
let tag = u16le(td);
let codec = match tag {
0x0160 => "WMA 1",
0x0161 => "WMA 2",
0x0162 => "WMA Pro",
0x0163 => "WMA Lossless",
0x000A => "WMA Voice",
_ => "WMA",
};
info.audio = Some(AudioInfo { codec: codec.into(), sample_rate: u32le(td + 4), channels: u16le(td + 2), decodable: false });
} else if kind == Some(&VIDEO_MEDIA[..]) {
info.video = Some(VideoInfo { codec: "WMV".into(), width: u32le(td), height: u32le(td + 4), decodable: false, ..Default::default() });
}
}
// A hostile size must not wrap the walk (or walk off the end): an object that
// overruns the header ends it, which is also the correct ASF semantics.
let Some(next) = pos.checked_add(size).filter(|&p| p <= header_end) else { break };
pos = next;
}
Ok(info)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostile_asf_object_sizes_end_the_walk() {
// The header object GUID followed by 48 × 0xFF: the walk reads usize::MAX back as
// every object size and must end instead of wrapping `pos` (and panicking).
let mut b = crate::ASF_HEADER.to_vec();
b.extend_from_slice(&[0xFF; 48]);
assert!(matches!(probe_asf(&b), Ok(info) if info.container == "asf"));
}
}