pub mod annexb;
use std::path::Path;
use ffai_core::error::{Error, Result};
use ffai_core::types::{AudioBuffer, ImageBuffer, VideoFrame};
pub fn load_audio(path: &Path) -> Result<AudioBuffer> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
match ext.as_str() {
"wav" => load_wav(path),
other => Err(Error::Media(format!(
"`.{other}` decode is not wired yet — audio beyond WAV arrives with the \
remade_ffmpeg_rs (rff) backend in Phase 1; for now convert with \
`ffmpeg -i in.{other} -ar 16000 -ac 1 out.wav`"
))),
}
}
#[allow(clippy::cast_precision_loss)]
fn load_wav(path: &Path) -> Result<AudioBuffer> {
let mut reader =
hound::WavReader::open(path).map_err(|e| Error::Media(format!("WAV open failed: {e}")))?;
let spec = reader.spec();
let samples: Vec<f32> = match spec.sample_format {
hound::SampleFormat::Float => reader
.samples::<f32>()
.collect::<std::result::Result<_, _>>()
.map_err(|e| Error::Media(format!("WAV read failed: {e}")))?,
hound::SampleFormat::Int => {
let bits = spec.bits_per_sample;
if !(1..=32).contains(&bits) {
return Err(Error::Media(format!(
"WAV declares {bits} bits per sample; supported range is 1..=32"
)));
}
let scale = (1u32 << (bits - 1)) as f32;
reader
.samples::<i32>()
.map(|s| s.map(|v| v as f32 / scale))
.collect::<std::result::Result<_, _>>()
.map_err(|e| Error::Media(format!("WAV read failed: {e}")))?
}
};
Ok(AudioBuffer {
samples,
sample_rate: spec.sample_rate,
channels: spec.channels,
})
}
pub fn save_wav(path: &Path, audio: &AudioBuffer) -> Result<()> {
let spec = hound::WavSpec {
channels: audio.channels,
sample_rate: audio.sample_rate,
bits_per_sample: 32,
sample_format: hound::SampleFormat::Float,
};
let mut writer = hound::WavWriter::create(path, spec)
.map_err(|e| Error::Media(format!("WAV create failed: {e}")))?;
for &s in &audio.samples {
writer
.write_sample(s)
.map_err(|e| Error::Media(format!("WAV write failed: {e}")))?;
}
writer
.finalize()
.map_err(|e| Error::Media(format!("WAV finalize failed: {e}")))?;
Ok(())
}
pub fn load_image(path: &Path) -> Result<ImageBuffer> {
let bytes = std::fs::read(path).map_err(|e| Error::Media(format!("open failed: {e}")))?;
if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
return decode_png(&bytes);
}
if bytes.starts_with(&[0xFF, 0xD8]) {
return decode_jpeg(bytes);
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
match ext.as_str() {
"png" => decode_png(&bytes),
"jpg" | "jpeg" => decode_jpeg(bytes),
other => Err(Error::Media(format!(
"`.{other}` decode is not wired yet — PNG and JPEG are supported; WebP/AVIF \
arrive with the rff image decoders. Convert with `ffmpeg -i in.{other} out.png`"
))),
}
}
fn decode_jpeg(data: Vec<u8>) -> Result<ImageBuffer> {
use ffai_core::types::PixelFormat;
use rusty_jpeg::{Decoder, PixelFormat as JpegFormat};
let mut decoder = Decoder::new(std::io::Cursor::new(data));
let pixels = decoder
.decode()
.map_err(|e| Error::Media(format!("JPEG decode: {e}")))?;
let info = decoder
.info()
.ok_or_else(|| Error::Media("JPEG decoded without image info".into()))?;
let (w, h) = (info.width as usize, info.height as usize);
let (data, format) = match info.pixel_format {
JpegFormat::RGB24 => (pixels, PixelFormat::Rgb8),
JpegFormat::L8 => {
let size = w
.checked_mul(h)
.and_then(|n| n.checked_mul(3))
.ok_or_else(|| {
Error::Media(format!("frame {w}x{h} overflows this platform's usize"))
})?;
let mut rgb = vec![0u8; size];
for (i, &g) in pixels.iter().take(w * h).enumerate() {
rgb[i * 3..i * 3 + 3].copy_from_slice(&[g, g, g]);
}
(rgb, PixelFormat::Rgb8)
}
other => {
return Err(Error::Media(format!(
"JPEG pixel format {other:?} unsupported — ffai-media handles RGB and grayscale"
)));
}
};
Ok(ImageBuffer {
width: u32::from(info.width),
height: u32::from(info.height),
format,
data,
})
}
fn decode_png(data: &[u8]) -> Result<ImageBuffer> {
use ffai_core::types::PixelFormat;
use rusty_png::{BitDepth, ColorType, Decoder, Transformations};
let mut decoder = Decoder::new(std::io::Cursor::new(data));
decoder.set_transformations(Transformations::EXPAND | Transformations::STRIP_16);
let mut reader = decoder
.read_info()
.map_err(|e| Error::Media(format!("PNG header: {e}")))?;
let mut buf = vec![0u8; reader.output_buffer_size()];
let info = reader
.next_frame(&mut buf)
.map_err(|e| Error::Media(format!("PNG decode: {e}")))?;
buf.truncate(info.buffer_size());
if info.bit_depth != BitDepth::Eight {
return Err(Error::Media(format!(
"PNG bit depth {:?} survived STRIP_16 — unsupported",
info.bit_depth
)));
}
let format = match info.color_type {
ColorType::Grayscale => PixelFormat::Gray8,
ColorType::Rgb => PixelFormat::Rgb8,
ColorType::Rgba => PixelFormat::Rgba8,
ColorType::GrayscaleAlpha => {
buf = buf.chunks_exact(2).map(|p| p[0]).collect();
PixelFormat::Gray8
}
ColorType::Indexed => return Err(Error::Media("indexed PNG survived EXPAND".into())),
};
Ok(ImageBuffer {
width: info.width,
height: info.height,
format,
data: buf,
})
}
pub struct VideoStream {
demux: Box<dyn rff_format::Demuxer>,
dec: rusty_h264::Decoder,
nal_length_size: Option<usize>,
vidx: usize,
tb: rff_core::Rational,
interval: f64,
next_due: f64,
started: bool,
pkts: usize,
path: std::path::PathBuf,
done: bool,
}
impl VideoStream {
#[must_use]
pub const fn frame_count_hint(&self) -> Option<usize> {
None
}
}
impl Iterator for VideoStream {
type Item = Result<VideoFrame>;
#[allow(clippy::cast_precision_loss)]
fn next(&mut self) -> Option<Self::Item> {
if self.done {
return None;
}
loop {
let packet = match self.demux.read_packet() {
Ok(p) => p,
Err(rff_core::Error::Eof) => {
self.done = true;
return None;
}
Err(e) => {
self.done = true;
return Some(Err(Error::Media(format!("{}: {e}", self.path.display()))));
}
};
if packet.stream_index != self.vidx {
continue;
}
self.pkts += 1;
let converted = self
.nal_length_size
.and_then(|n| annexb::to_annexb(&packet.data, n));
let payload: &[u8] = converted.as_deref().unwrap_or(&packet.data);
let frame = match self.dec.decode(payload) {
Ok(f) => f,
Err(e) => {
self.done = true;
return Some(Err(Error::Media(format!(
"{}: decode failed on packet {} : {e}",
self.path.display(),
self.pkts
))));
}
};
let Some(v) = frame else { continue };
let ts = packet.pts.map_or(0.0, |p| {
p as f64 * f64::from(self.tb.num) / f64::from(self.tb.den.max(1))
});
if self.interval > 0.0 {
if self.started && ts < self.next_due {
continue;
}
#[allow(clippy::suboptimal_flops)]
let due = if self.started {
(self.next_due + self.interval).max(ts + self.interval * 0.5)
} else {
ts + self.interval
};
self.next_due = due;
self.started = true;
}
return Some(from_rusty_frame(&v, ts));
}
}
}
#[allow(clippy::cast_precision_loss)]
pub fn stream_frames(path: &Path, fps: f64) -> Result<VideoStream> {
use rff_format::FormatRegistry;
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.unwrap_or_default();
let demuxer_name = match ext.as_str() {
"mp4" | "mov" | "m4v" => "mp4",
"mkv" | "webm" | "mka" => "matroska",
"avi" => "avi",
"ts" | "m2ts" | "mts" => "mpegts",
other => {
return Err(Error::Media(format!(
"`.{other}`: no demuxer wired. Supported: mp4/mov/m4v, mkv/webm, \
avi, ts/m2ts/mts. MPEG-PS and ASF land when their rff-format-* \
crates publish — see docs/rff-gaps-for-ffai.md."
)));
}
};
let file = std::fs::File::open(path)?;
let mkerr = |e: rff_core::Error| Error::Media(format!("{}: {e}", path.display()));
let mut formats = FormatRegistry::new();
rff_format_mp4::register(&mut formats);
rff_format_mkv::register(&mut formats);
rff_format_avi::register(&mut formats);
rff_format_ts::register(&mut formats);
let mut demux = formats
.open_demuxer(demuxer_name, Box::new(std::io::BufReader::new(file)))
.map_err(mkerr)?;
let streams = demux.read_header().map_err(mkerr)?;
let (vidx, vstream) = streams
.iter()
.enumerate()
.find(|(_, s)| s.media_type == rff_core::MediaType::Video)
.ok_or_else(|| Error::Media(format!("{}: no video stream", path.display())))?;
let mut dec = rusty_h264::Decoder::new();
let avcc = annexb::parse_avcc(&vstream.extradata);
let nal_length_size = avcc.as_ref().map(|c| c.nal_length_size);
match &avcc {
Some(c) => {
let _ = dec.decode(&c.parameter_sets);
}
None if !vstream.extradata.is_empty() => {
let _ = dec.decode(&vstream.extradata);
}
None => {}
}
let tb = vstream.time_base;
let interval = if fps > 0.0 { 1.0 / fps } else { 0.0 };
Ok(VideoStream {
demux,
dec,
nal_length_size,
vidx,
tb,
interval,
next_due: 0.0,
started: false,
pkts: 0,
path: path.to_path_buf(),
done: false,
})
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
pub fn sample_frames(path: &Path, fps: f64) -> Result<Vec<VideoFrame>> {
stream_frames(path, fps)?.collect()
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn from_rusty_frame(v: &rusty_h264::YuvFrame, ts: f64) -> Result<VideoFrame> {
let (w, h) = (v.width, v.height);
let size = w
.checked_mul(h)
.and_then(|n| n.checked_mul(3))
.ok_or_else(|| Error::Media(format!("frame {w}x{h} overflows this platform's usize")))?;
let mut rgb = vec![0u8; size];
let (ys, us, vs) = (w, w.div_ceil(2), w.div_ceil(2));
for row in 0..h {
for col in 0..w {
let y = f32::from(v.y[row * ys + col]);
let cu = f32::from(v.u[(row / 2) * us + col / 2]) - 128.0;
let cv = f32::from(v.v[(row / 2) * vs + col / 2]) - 128.0;
let yy = 1.164 * (y - 16.0);
let o = (row * w + col) * 3;
rgb[o] = 1.596f32.mul_add(cv, yy).clamp(0.0, 255.0) as u8;
rgb[o + 1] = 0.391f32
.mul_add(-cu, 0.813f32.mul_add(-cv, yy))
.clamp(0.0, 255.0) as u8;
rgb[o + 2] = 2.018f32.mul_add(cu, yy).clamp(0.0, 255.0) as u8;
}
}
Ok(VideoFrame {
image: ImageBuffer {
width: w as u32,
height: h as u32,
format: ffai_core::types::PixelFormat::Rgb8,
data: rgb,
},
timestamp: ts,
})
}
#[allow(dead_code)]
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn from_rff_frame(v: &rff_core::VideoFrame, ts: f64) -> Result<VideoFrame> {
let (w, h) = (v.width as usize, v.height as usize);
if v.planes.len() < 3 || v.strides.len() < 3 {
return Err(Error::Media(format!(
"expected 3 planar YUV planes, found {}",
v.planes.len()
)));
}
let (yp, up, vp) = (&v.planes[0], &v.planes[1], &v.planes[2]);
let (ys, us, vs) = (v.strides[0], v.strides[1], v.strides[2]);
let size = w
.checked_mul(h)
.and_then(|n| n.checked_mul(3))
.ok_or_else(|| Error::Media(format!("frame {w}x{h} overflows this platform's usize")))?;
let mut rgb = vec![0u8; size];
for row in 0..h {
for col in 0..w {
let yv = f32::from(yp[row * ys + col]) - 16.0;
let uv = f32::from(up[(row / 2) * us + col / 2]) - 128.0;
let vv = f32::from(vp[(row / 2) * vs + col / 2]) - 128.0;
let o = (row * w + col) * 3;
rgb[o] = 1.164f32.mul_add(yv, 1.596 * vv).clamp(0.0, 255.0) as u8;
rgb[o + 1] = 0.391f32
.mul_add(-uv, 1.164f32.mul_add(yv, -(0.813 * vv)))
.clamp(0.0, 255.0) as u8;
rgb[o + 2] = 1.164f32.mul_add(yv, 2.018 * uv).clamp(0.0, 255.0) as u8;
}
}
Ok(VideoFrame {
image: ImageBuffer {
width: v.width,
height: v.height,
format: ffai_core::types::PixelFormat::Rgb8,
data: rgb,
},
timestamp: ts,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wav_roundtrip_preserves_samples() {
let audio = AudioBuffer {
samples: (0..1600).map(|i| (i as f32 / 100.0).sin() * 0.5).collect(),
sample_rate: 16_000,
channels: 1,
};
let path = std::env::temp_dir().join("ffai_media_roundtrip_test.wav");
save_wav(&path, &audio).unwrap();
let loaded = load_audio(&path).unwrap();
std::fs::remove_file(&path).ok();
assert_eq!(loaded.sample_rate, 16_000);
assert_eq!(loaded.channels, 1);
assert_eq!(loaded.samples.len(), audio.samples.len());
assert_eq!(loaded.samples, audio.samples);
}
#[test]
fn unknown_extension_names_the_backend_plan() {
let err = load_audio(Path::new("clip.mp3")).unwrap_err();
assert!(err.to_string().contains("remade_ffmpeg_rs"));
}
}
#[cfg(test)]
mod png_oracle {
use super::*;
use ffai_core::types::PixelFormat;
fn decode_png_upstream(data: &[u8]) -> Result<ImageBuffer> {
use ffai_core::types::PixelFormat;
let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder
.read_info()
.map_err(|e| Error::Media(format!("PNG header: {e}")))?;
let mut buf = vec![0u8; reader.output_buffer_size()];
let info = reader
.next_frame(&mut buf)
.map_err(|e| Error::Media(format!("PNG decode: {e}")))?;
buf.truncate(info.buffer_size());
if info.bit_depth != png::BitDepth::Eight {
return Err(Error::Media("non-8-bit".into()));
}
let format = match info.color_type {
png::ColorType::Grayscale => PixelFormat::Gray8,
png::ColorType::Rgb => PixelFormat::Rgb8,
png::ColorType::Rgba => PixelFormat::Rgba8,
png::ColorType::GrayscaleAlpha => {
buf = buf.chunks_exact(2).map(|p| p[0]).collect();
PixelFormat::Gray8
}
png::ColorType::Indexed => return Err(Error::Media("indexed".into())),
};
Ok(ImageBuffer {
width: info.width,
height: info.height,
format,
data: buf,
})
}
fn repo_root() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("crates/ffai-media has two ancestors")
.to_path_buf()
}
#[test]
fn rusty_jpeg_agrees_with_libjpeg_via_the_corpus_twins() {
let root = repo_root().join("corpora/clips/diana-coco");
let (mut n, mut worst) = (0usize, 0i32);
for i in 0..8 {
let (j, p) = (
root.join(format!("coco-{i:03}.src.jpg")),
root.join(format!("coco-{i:03}.png")),
);
if !j.exists() || !p.exists() {
continue;
}
let a = load_image(&j).expect("jpeg");
let b = load_image(&p).expect("png");
assert_eq!(
(a.width, a.height),
(b.width, b.height),
"coco-{i:03}: dimensions"
);
assert_eq!(a.data.len(), b.data.len(), "coco-{i:03}: buffer length");
worst = worst.max(
a.data
.iter()
.zip(&b.data)
.map(|(x, y)| (*x as i32 - *y as i32).abs())
.max()
.unwrap_or(0),
);
n += 1;
}
if n == 0 {
eprintln!("SKIP jpeg/libjpeg twin check: corpus absent");
return;
}
assert!(
worst <= 8,
"rusty_jpeg diverges from libjpeg by {worst}/255 over {n} images"
);
eprintln!("rusty_jpeg vs libjpeg: {n} images, worst channel delta {worst}/255");
}
#[test]
fn rusty_png_matches_upstream_png() {
let root = repo_root();
let dirs = [
"corpora/clips/diana-coco-v3",
"corpora/clips/diana-coco",
"corpora/clips/carmenta-doc",
"corpora/clips/carmenta-synth",
];
let (mut checked, mut dirs_seen) = (0usize, 0usize);
for d in dirs {
let Ok(entries) = std::fs::read_dir(root.join(d)) else {
continue;
};
dirs_seen += 1;
let mut paths: Vec<_> = entries
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "png"))
.collect();
paths.sort();
paths.truncate(12);
for p in paths {
let Ok(bytes) = std::fs::read(&p) else {
continue;
};
let Ok(want) = decode_png_upstream(&bytes) else {
continue;
};
let got = decode_png(&bytes)
.unwrap_or_else(|e| panic!("rff failed on {}: {e}", p.display()));
assert_eq!(got.width, want.width, "{}: width", p.display());
assert_eq!(got.height, want.height, "{}: height", p.display());
assert_eq!(got.format, want.format, "{}: pixel format", p.display());
assert_eq!(
got.data.len(),
want.data.len(),
"{}: byte count",
p.display()
);
assert!(got.data == want.data, "{}: PIXELS DIFFER", p.display());
checked += 1;
}
}
if dirs_seen == 0 {
eprintln!("png oracle: no corpus directories present, skipping");
return;
}
eprintln!("rusty_png == upstream png on {checked} images across {dirs_seen} corpora");
}
}
#[allow(clippy::cast_possible_truncation)]
pub fn save_gray16_png(path: &Path, pixels: &[u16], width: usize, height: usize) -> Result<()> {
if pixels.len() != width * height {
return Err(Error::Other(format!(
"save_gray16_png: {} pixels for a {width}x{height} image",
pixels.len()
)));
}
let file = std::fs::File::create(path)?;
let mut enc =
rusty_png::Encoder::new(std::io::BufWriter::new(file), width as u32, height as u32);
enc.set_color(rusty_png::ColorType::Grayscale);
enc.set_depth(rusty_png::BitDepth::Sixteen);
let mut w = enc
.write_header()
.map_err(|e| Error::Other(format!("png header: {e}")))?;
let mut bytes = Vec::with_capacity(pixels.len() * 2);
for p in pixels {
bytes.extend_from_slice(&p.to_be_bytes());
}
w.write_image_data(&bytes)
.map_err(|e| Error::Other(format!("png write: {e}")))?;
w.finish()
.map_err(|e| Error::Other(format!("png finish: {e}")))?;
Ok(())
}