use crate::error::{ConformError, ConformResult};
use crate::types::{FrameRate, Timecode};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use xxhash_rust::xxh3::Xxh3;
#[derive(Debug, Clone)]
pub struct Fingerprint {
pub md5: String,
pub xxhash: String,
}
#[derive(Debug, Clone)]
pub struct MediaInfo {
pub duration: Option<f64>,
pub width: Option<u32>,
pub height: Option<u32>,
pub fps: Option<FrameRate>,
pub timecode_start: Option<Timecode>,
}
pub fn generate_fingerprint<P: AsRef<Path>>(path: P) -> ConformResult<Fingerprint> {
let mut file = File::open(path)?;
let mut md5_context = md5::Context::new();
let mut xxh_hasher = Xxh3::new();
let mut buffer = vec![0; 8192];
loop {
let n = file.read(&mut buffer)?;
if n == 0 {
break;
}
md5_context.consume(&buffer[..n]);
xxh_hasher.update(&buffer[..n]);
}
Ok(Fingerprint {
md5: hex::encode(*md5_context.finalize()),
xxhash: format!("{:x}", xxh_hasher.digest()),
})
}
pub fn extract_media_info<P: AsRef<Path>>(path: P) -> ConformResult<MediaInfo> {
use std::fs::File;
use std::io::Read;
let path = path.as_ref();
let mut file = File::open(path)?;
let mut buf = vec![0u8; 16_384];
let n = file.read(&mut buf)?;
buf.truncate(n);
if buf.len() < 8 {
return Ok(MediaInfo {
duration: None,
width: None,
height: None,
fps: None,
timecode_start: None,
});
}
if is_mp4_or_mov(&buf) {
return parse_mp4(&buf);
}
if is_mkv(&buf) {
return parse_mkv(&buf);
}
if is_avi(&buf) {
return parse_avi(&buf);
}
if is_wav(&buf) {
return parse_wav(&buf);
}
if is_mxf(&buf) {
return parse_mxf(&buf);
}
Ok(MediaInfo {
duration: None,
width: None,
height: None,
fps: None,
timecode_start: None,
})
}
fn is_mp4_or_mov(buf: &[u8]) -> bool {
if buf.len() < 8 {
return false;
}
let box_type = &buf[4..8];
matches!(
box_type,
b"ftyp" | b"moov" | b"mdat" | b"free" | b"skip" | b"wide"
)
}
fn is_mkv(buf: &[u8]) -> bool {
buf.len() >= 4 && buf[..4] == [0x1A, 0x45, 0xDF, 0xA3]
}
fn is_avi(buf: &[u8]) -> bool {
buf.len() >= 12 && &buf[..4] == b"RIFF" && &buf[8..12] == b"AVI "
}
fn is_wav(buf: &[u8]) -> bool {
buf.len() >= 12 && &buf[..4] == b"RIFF" && &buf[8..12] == b"WAVE"
}
fn is_mxf(buf: &[u8]) -> bool {
buf.len() >= 4 && buf[..4] == [0x06, 0x0E, 0x2B, 0x34]
}
fn read_u16_be(buf: &[u8], off: usize) -> u16 {
if off + 2 > buf.len() {
return 0;
}
u16::from_be_bytes([buf[off], buf[off + 1]])
}
fn read_u32_be(buf: &[u8], off: usize) -> u32 {
if off + 4 > buf.len() {
return 0;
}
u32::from_be_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]])
}
fn read_u64_be(buf: &[u8], off: usize) -> u64 {
if off + 8 > buf.len() {
return 0;
}
u64::from_be_bytes([
buf[off],
buf[off + 1],
buf[off + 2],
buf[off + 3],
buf[off + 4],
buf[off + 5],
buf[off + 6],
buf[off + 7],
])
}
fn read_u32_le(buf: &[u8], off: usize) -> u32 {
if off + 4 > buf.len() {
return 0;
}
u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]])
}
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || needle.len() > haystack.len() {
return None;
}
haystack.windows(needle.len()).position(|w| w == needle)
}
fn iter_mp4_boxes(buf: &[u8]) -> impl Iterator<Item = ([u8; 4], usize, usize)> + '_ {
let mut pos = 0;
std::iter::from_fn(move || {
if pos + 8 > buf.len() {
return None;
}
let raw_size = read_u32_be(buf, pos) as usize;
let mut box_type = [0u8; 4];
box_type.copy_from_slice(&buf[pos + 4..pos + 8]);
let (hdr, size) = if raw_size == 1 {
if pos + 16 > buf.len() {
return None;
}
let ext = read_u64_be(buf, pos + 8) as usize;
(16, ext)
} else if raw_size == 0 {
(8, buf.len() - pos)
} else {
(8, raw_size)
};
if size < hdr || pos + size > buf.len() {
let payload_start = pos + hdr;
let payload_len = buf.len().saturating_sub(payload_start);
pos = buf.len(); return Some((box_type, payload_start, payload_len));
}
let payload_start = pos + hdr;
let payload_len = size - hdr;
pos += size;
Some((box_type, payload_start, payload_len))
})
}
fn find_mp4_box(data: &[u8], want: &[u8; 4]) -> Option<(usize, usize)> {
for (bt, pstart, plen) in iter_mp4_boxes(data) {
if &bt == want {
return Some((pstart, plen));
}
if matches!(
&bt,
b"moov"
| b"trak"
| b"mdia"
| b"minf"
| b"stbl"
| b"udta"
| b"meta"
| b"ilst"
| b"dinf"
| b"gmhd"
) {
let inner_end = (pstart + plen).min(data.len());
if let Some(r) = find_mp4_box(&data[pstart..inner_end], want) {
return Some((pstart + r.0, r.1));
}
}
}
None
}
fn parse_mp4(buf: &[u8]) -> ConformResult<MediaInfo> {
let mut info = MediaInfo {
duration: None,
width: None,
height: None,
fps: None,
timecode_start: None,
};
if let Some((mvhd_start, mvhd_len)) = find_mp4_box(buf, b"mvhd") {
let mv = &buf[mvhd_start..(mvhd_start + mvhd_len).min(buf.len())];
if mv.len() >= 20 {
let version = mv[0];
let (time_scale, duration_units) = if version == 1 {
if mv.len() >= 28 {
(read_u32_be(mv, 20), read_u64_be(mv, 24))
} else {
(0, 0)
}
} else {
if mv.len() >= 16 {
(read_u32_be(mv, 12), u64::from(read_u32_be(mv, 16)))
} else {
(0, 0)
}
};
if time_scale > 0 && duration_units > 0 {
info.duration = Some(duration_units as f64 / f64::from(time_scale));
}
}
}
let mut trak_pos = 0usize;
while let Some(moov_start) = find_bytes(buf, b"moov") {
let moov_size_raw = read_u32_be(buf, moov_start.saturating_sub(4));
let moov_end = if moov_start >= 4 {
(moov_start + moov_size_raw as usize).min(buf.len())
} else {
buf.len()
};
let moov_inner = &buf[moov_start..moov_end];
for (bt, pstart, plen) in iter_mp4_boxes(moov_inner) {
if &bt != b"trak" {
continue;
}
let trak = &moov_inner[pstart..(pstart + plen).min(moov_inner.len())];
if let Some((stsd_s, stsd_l)) = find_mp4_box(trak, b"stsd") {
let stsd = &trak[stsd_s..(stsd_s + stsd_l).min(trak.len())];
if stsd.len() > 8 {
let codec_type = &stsd[8..12];
let is_video = !matches!(
codec_type,
b"mp4a" | b"ac-3" | b"ec-3" | b"Opus" | b"sowt" | b"twos"
);
if is_video && stsd.len() >= 28 {
let width_off = 8 + 6 + 2 + 16;
if stsd.len() >= width_off + 4 {
let w = u32::from(read_u16_be(stsd, width_off));
let h = u32::from(read_u16_be(stsd, width_off + 2));
if w > 0 && h > 0 {
info.width = Some(w);
info.height = Some(h);
}
}
}
}
}
if let Some((stts_s, stts_l)) = find_mp4_box(trak, b"stts") {
let stts = &trak[stts_s..(stts_s + stts_l).min(trak.len())];
if stts.len() >= 12 {
let _entry_count = read_u32_be(stts, 4);
let sample_delta = read_u32_be(stts, 12);
if let Some((mdhd_s, mdhd_l)) = find_mp4_box(trak, b"mdhd") {
let mdhd = &trak[mdhd_s..(mdhd_s + mdhd_l).min(trak.len())];
if mdhd.len() >= 4 {
let version = mdhd[0];
let ts = if version == 1 {
if mdhd.len() >= 28 {
read_u32_be(mdhd, 20)
} else {
0
}
} else {
if mdhd.len() >= 16 {
read_u32_be(mdhd, 12)
} else {
0
}
};
if ts > 0 && sample_delta > 0 {
let fps_val = f64::from(ts) / f64::from(sample_delta);
info.fps = Some(fps_from_f64(fps_val));
}
}
}
}
}
}
trak_pos = moov_end;
break;
}
let _ = trak_pos;
if let Some(tmcd_pos) = find_bytes(buf, b"tmcd") {
if tmcd_pos + 12 < buf.len() {
let frames = read_u32_be(buf, tmcd_pos + 8);
let fps_val = info
.fps
.map_or(25.0, super::super::types::FrameRate::as_f64);
if fps_val > 0.0 {
let total_secs = f64::from(frames) / fps_val;
let h = (total_secs / 3600.0) as u8;
let m = ((total_secs % 3600.0) / 60.0) as u8;
let s = (total_secs % 60.0) as u8;
let f = (frames % (fps_val as u32).max(1)) as u8;
info.timecode_start = Some(Timecode::new(h, m, s, f));
}
}
}
Ok(info)
}
fn ebml_decode_var(buf: &[u8], pos: usize) -> Option<(u64, usize)> {
let b = *buf.get(pos)?;
if b == 0 {
return None;
}
let width = b.leading_zeros() as usize + 1;
if pos + width > buf.len() {
return None;
}
let mask = 0xFF_u64 >> width;
let mut val = u64::from(b) & mask;
for i in 1..width {
val = (val << 8) | u64::from(buf[pos + i]);
}
Some((val, width))
}
fn parse_mkv(buf: &[u8]) -> ConformResult<MediaInfo> {
let mut info = MediaInfo {
duration: None,
width: None,
height: None,
fps: None,
timecode_start: None,
};
let mut timecode_scale: u64 = 1_000_000; let mut default_duration_ns: Option<u64> = None;
let mut pos = 0usize;
while pos + 2 < buf.len() {
let Some((elem_id, id_bytes)) = ebml_decode_var(buf, pos) else {
pos += 1;
continue;
};
let size_pos = pos + id_bytes;
let Some((elem_size, sz_bytes)) = ebml_decode_var(buf, size_pos) else {
pos += 1;
continue;
};
let data_pos = size_pos + sz_bytes;
let data_end = (data_pos + elem_size as usize).min(buf.len());
let data = &buf[data_pos..data_end];
match elem_id {
0x2AD7B1 => {
if data.len() >= 4 {
timecode_scale = read_uint_be(data);
}
}
0x4489 => {
let dur = if data.len() == 8 {
f64::from_be_bytes(data.try_into().unwrap_or([0; 8]))
} else if data.len() == 4 {
let v = u32::from_be_bytes(data.try_into().unwrap_or([0; 4]));
f64::from(f32::from_bits(v))
} else {
0.0
};
if dur > 0.0 {
info.duration = Some(dur * timecode_scale as f64 / 1_000_000_000.0);
}
}
0xB0 => {
if !data.is_empty() {
info.width = Some(read_uint_be(data) as u32);
}
}
0xBA => {
if !data.is_empty() {
info.height = Some(read_uint_be(data) as u32);
}
}
0x23E383 => {
if !data.is_empty() {
default_duration_ns = Some(read_uint_be(data));
}
}
_ => {}
}
let advance = id_bytes + sz_bytes + elem_size as usize;
pos += advance.max(1);
}
if let Some(ns) = default_duration_ns {
if ns > 0 {
let fps_val = 1_000_000_000.0 / ns as f64;
info.fps = Some(fps_from_f64(fps_val));
}
}
Ok(info)
}
fn read_uint_be(data: &[u8]) -> u64 {
let mut v = 0u64;
for &b in data.iter().take(8) {
v = (v << 8) | u64::from(b);
}
v
}
fn parse_avi(buf: &[u8]) -> ConformResult<MediaInfo> {
let mut info = MediaInfo {
duration: None,
width: None,
height: None,
fps: None,
timecode_start: None,
};
if let Some(avih_pos) = find_bytes(buf, b"avih") {
let data_start = avih_pos + 8;
if data_start + 40 <= buf.len() {
let usec_per_frame = read_u32_le(buf, data_start);
let total_frames = read_u32_le(buf, data_start + 16);
let width = read_u32_le(buf, data_start + 32);
let height = read_u32_le(buf, data_start + 36);
if usec_per_frame > 0 {
let fps_val = 1_000_000.0 / f64::from(usec_per_frame);
info.fps = Some(fps_from_f64(fps_val));
if total_frames > 0 {
info.duration =
Some(f64::from(total_frames) * f64::from(usec_per_frame) / 1_000_000.0);
}
}
if width > 0 {
info.width = Some(width);
}
if height > 0 {
info.height = Some(height);
}
}
}
Ok(info)
}
fn parse_wav(buf: &[u8]) -> ConformResult<MediaInfo> {
let mut info = MediaInfo {
duration: None,
width: None,
height: None,
fps: None,
timecode_start: None,
};
if let Some(fmt_pos) = find_bytes(buf, b"fmt ") {
let data_start = fmt_pos + 8;
if data_start + 16 <= buf.len() {
let sample_rate = read_u32_le(buf, data_start + 4);
let bits_per_sample = u32::from(read_u16_be(buf, data_start + 14));
if sample_rate > 0 {
let byte_rate = read_u32_le(buf, data_start + 8);
if let Some(data_pos) = find_bytes(buf, b"data") {
let data_size = read_u32_le(buf, data_pos + 4);
if byte_rate > 0 && data_size > 0 {
info.duration = Some(f64::from(data_size) / f64::from(byte_rate));
}
}
let _ = bits_per_sample;
}
}
}
Ok(info)
}
fn parse_mxf(buf: &[u8]) -> ConformResult<MediaInfo> {
let mut info = MediaInfo {
duration: None,
width: None,
height: None,
fps: None,
timecode_start: None,
};
let ul_prefix = [0x06u8, 0x0E, 0x2B, 0x34];
let mut scan = 0usize;
while scan + 16 < buf.len() {
if buf[scan..scan + 4] != ul_prefix {
scan += 1;
continue;
}
let key = &buf[scan..scan + 16];
let Some((val_off, val_len)) = mxf_ber(buf, scan + 16) else {
scan += 1;
continue;
};
let val_end = (val_off + val_len).min(buf.len());
let val = &buf[val_off..val_end];
if key[4] == 0x02 && key[5] == 0x53 && key[8] == 0x0D {
match key[14] {
0x27..=0x29 => {
let mut lp = 0usize;
while lp + 4 <= val.len() {
let tag = read_u16_be(val, lp);
let len = read_u16_be(val, lp + 2) as usize;
let vs = lp + 4;
let ve = (vs + len).min(val.len());
let v = &val[vs..ve];
match tag {
0x3203 if v.len() >= 4 => {
info.width = Some(read_u32_be(v, 0));
}
0x3202 if v.len() >= 4 => {
info.height = Some(read_u32_be(v, 0));
}
_ => {}
}
lp = ve;
}
}
0x36 | 0x37 => {
let mut lp = 0usize;
while lp + 4 <= val.len() {
let tag = read_u16_be(val, lp);
let len = read_u16_be(val, lp + 2) as usize;
let vs = lp + 4;
let ve = (vs + len).min(val.len());
let v = &val[vs..ve];
match tag {
0x4901 if v.len() >= 8 => {
let num = read_u32_be(v, 0);
let den = read_u32_be(v, 4);
if num > 0 && den > 0 {
info.fps = Some(fps_from_f64(f64::from(num) / f64::from(den)));
}
}
0x4202 if v.len() >= 8 => {
let d = i64::from_be_bytes(v[..8].try_into().unwrap_or([0; 8]));
if d > 0 {
info.duration = Some(d as f64);
}
}
_ => {}
}
lp = ve;
}
}
_ => {}
}
}
let next = val_off + val_len;
scan = if next > scan { next } else { scan + 1 };
}
if let (Some(dur_frames), Some(ref fps)) = (info.duration, &info.fps) {
let fps_val = fps.as_f64();
if fps_val > 0.0 {
info.duration = Some(dur_frames / fps_val);
}
}
Ok(info)
}
fn mxf_ber(buf: &[u8], pos: usize) -> Option<(usize, usize)> {
let b = *buf.get(pos)?;
if b & 0x80 == 0 {
Some((pos + 1, b as usize))
} else {
let n = (b & 0x7F) as usize;
if n == 0 || n > 8 || pos + 1 + n > buf.len() {
return None;
}
let mut len = 0usize;
for i in 0..n {
len = (len << 8) | buf[pos + 1 + i] as usize;
}
Some((pos + 1 + n, len))
}
}
fn fps_from_f64(fps: f64) -> FrameRate {
if (fps - 23.976).abs() < 0.02 {
return FrameRate::Fps23976;
}
if (fps - 24.0).abs() < 0.02 {
return FrameRate::Fps24;
}
if (fps - 25.0).abs() < 0.02 {
return FrameRate::Fps25;
}
if (fps - 29.97).abs() < 0.02 {
return FrameRate::Fps2997NDF;
}
if (fps - 30.0).abs() < 0.02 {
return FrameRate::Fps30;
}
if (fps - 50.0).abs() < 0.02 {
return FrameRate::Fps50;
}
if (fps - 59.94).abs() < 0.02 {
return FrameRate::Fps5994;
}
if (fps - 60.0).abs() < 0.02 {
return FrameRate::Fps60;
}
FrameRate::Custom(fps)
}
pub fn extract_duration<P: AsRef<Path>>(path: P) -> ConformResult<f64> {
extract_media_info(path)?
.duration
.ok_or_else(|| ConformError::Other("Duration not available".to_string()))
}
pub fn extract_frame_rate<P: AsRef<Path>>(path: P) -> ConformResult<FrameRate> {
extract_media_info(path)?
.fps
.ok_or_else(|| ConformError::Other("Frame rate not available".to_string()))
}
pub fn extract_timecode<P: AsRef<Path>>(path: P) -> ConformResult<Timecode> {
extract_media_info(path)?
.timecode_start
.ok_or_else(|| ConformError::Other("Timecode not available".to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_generate_fingerprint() {
let mut temp_file = NamedTempFile::new().expect("test expectation failed");
temp_file
.write_all(b"test content")
.expect("write_all should succeed");
temp_file.flush().expect("flush should succeed");
let fingerprint =
generate_fingerprint(temp_file.path()).expect("fingerprint should be valid");
assert_eq!(fingerprint.md5.len(), 32); assert!(!fingerprint.xxhash.is_empty());
}
#[test]
fn test_fingerprint_consistency() {
let mut temp_file = NamedTempFile::new().expect("test expectation failed");
temp_file
.write_all(b"test content")
.expect("write_all should succeed");
temp_file.flush().expect("flush should succeed");
let fp1 = generate_fingerprint(temp_file.path()).expect("fp1 should be valid");
let fp2 = generate_fingerprint(temp_file.path()).expect("fp2 should be valid");
assert_eq!(fp1.md5, fp2.md5);
assert_eq!(fp1.xxhash, fp2.xxhash);
}
#[test]
fn test_extract_media_info() {
let mut temp_file = NamedTempFile::new().expect("test expectation failed");
temp_file
.write_all(b"test content")
.expect("write_all should succeed");
temp_file.flush().expect("flush should succeed");
let info = extract_media_info(temp_file.path()).expect("info should be valid");
assert!(info.duration.is_none());
}
}