ffai_media/lib.rs
1//! # ffai-media
2//!
3//! Media ingest/egress for `FFai` — the `libavformat` seat.
4//!
5//! Policy: all container/codec work routes through **`remade_ffmpeg_rs`**
6//! (`rff-*` crates) as the default backend — we own it, it is pure Rust, and
7//! it keeps the zero-C/C++ promise. Phase 0 ships native WAV support (the one
8//! format every ASR/TTS engine needs on day one); everything else returns a
9//! clear "pending rff integration" error rather than silently failing.
10
11pub mod annexb;
12
13use std::path::Path;
14
15use ffai_core::error::{Error, Result};
16use ffai_core::types::{AudioBuffer, ImageBuffer, VideoFrame};
17
18/// Load an audio file into a normalized f32 [`AudioBuffer`].
19///
20/// Phase 0: WAV only (PCM int/float). Other containers/codecs land with the
21/// `remade_ffmpeg_rs` integration in Phase 1.
22pub fn load_audio(path: &Path) -> Result<AudioBuffer> {
23 let ext = path
24 .extension()
25 .and_then(|e| e.to_str())
26 .unwrap_or_default()
27 .to_ascii_lowercase();
28 match ext.as_str() {
29 "wav" => load_wav(path),
30 other => Err(Error::Media(format!(
31 "`.{other}` decode is not wired yet — audio beyond WAV arrives with the \
32 remade_ffmpeg_rs (rff) backend in Phase 1; for now convert with \
33 `ffmpeg -i in.{other} -ar 16000 -ac 1 out.wav`"
34 ))),
35 }
36}
37
38// `v as f32` widens an i32 PCM sample. Beyond 2^24 the mantissa rounds, which
39// is inherent to representing 32-bit PCM as f32 at all - the whole pipeline is
40// f32 audio - and is a rounding difference of one LSB at full scale, not a
41// correctness issue.
42#[allow(clippy::cast_precision_loss)]
43fn load_wav(path: &Path) -> Result<AudioBuffer> {
44 let mut reader =
45 hound::WavReader::open(path).map_err(|e| Error::Media(format!("WAV open failed: {e}")))?;
46 let spec = reader.spec();
47 let samples: Vec<f32> = match spec.sample_format {
48 hound::SampleFormat::Float => reader
49 .samples::<f32>()
50 .collect::<std::result::Result<_, _>>()
51 .map_err(|e| Error::Media(format!("WAV read failed: {e}")))?,
52 hound::SampleFormat::Int => {
53 // `bits_per_sample` comes from the file's fmt chunk, i.e. from
54 // untrusted bytes. hound validates the formats IT supports, but this
55 // arithmetic must not rest on that: 0 underflows `- 1` (u16), and
56 // anything >= 65 overflows the shift. In debug both panic; in
57 // release - where this workspace deliberately carries no
58 // overflow-checks - the shift is masked and `scale` comes out
59 // silently wrong, which quietly rescales every sample.
60 let bits = spec.bits_per_sample;
61 if !(1..=32).contains(&bits) {
62 return Err(Error::Media(format!(
63 "WAV declares {bits} bits per sample; supported range is 1..=32"
64 )));
65 }
66 let scale = (1u32 << (bits - 1)) as f32;
67 reader
68 .samples::<i32>()
69 .map(|s| s.map(|v| v as f32 / scale))
70 .collect::<std::result::Result<_, _>>()
71 .map_err(|e| Error::Media(format!("WAV read failed: {e}")))?
72 }
73 };
74 Ok(AudioBuffer {
75 samples,
76 sample_rate: spec.sample_rate,
77 channels: spec.channels,
78 })
79}
80
81/// Write an [`AudioBuffer`] as 32-bit float WAV.
82pub fn save_wav(path: &Path, audio: &AudioBuffer) -> Result<()> {
83 let spec = hound::WavSpec {
84 channels: audio.channels,
85 sample_rate: audio.sample_rate,
86 bits_per_sample: 32,
87 sample_format: hound::SampleFormat::Float,
88 };
89 let mut writer = hound::WavWriter::create(path, spec)
90 .map_err(|e| Error::Media(format!("WAV create failed: {e}")))?;
91 for &s in &audio.samples {
92 writer
93 .write_sample(s)
94 .map_err(|e| Error::Media(format!("WAV write failed: {e}")))?;
95 }
96 writer
97 .finalize()
98 .map_err(|e| Error::Media(format!("WAV finalize failed: {e}")))?;
99 Ok(())
100}
101
102/// Decode a still image — PNG and JPEG today; WebP/AVIF/GIF follow.
103///
104/// **Both decoders are ours, and both come from crates.io**: `rusty_png`
105/// (our performance fork of image-rs/image-png) and `rusty_jpeg` (baseline
106/// and progressive DCT, AVX2 FDCT/quantize, two-block AVX2 IDCT). Principle
107/// 7 without the tax that used to come with it.
108///
109/// The route here was PNG/`png` + JPEG/`rff-codec-jpeg`, then both through
110/// rff's `CodecRegistry`, and now both direct. The registry seam is the
111/// right home for demuxers and video codecs, where rff provides something
112/// nothing else does. For a still-image decoder it cost two things that the
113/// standalone crates give back:
114///
115/// * **Publication.** rff is a git dependency and `cargo publish` refuses
116/// one, which made every `FFai` crate downstream of this one unpublishable.
117/// `cargo publish --dry-run -p ffai-media` passes again.
118/// * **Grayscale.** `rff_core::PixelFormat` has no single-channel variant,
119/// so a gray PNG had to expand to `Rgb24` and contract back — measured
120/// 2.36x slower on Carmenta's 32/32-grayscale corpus. `rusty_png` carries
121/// `ColorType::Grayscale` natively, so the round-trip is gone rather than
122/// optimised.
123///
124/// Both are gated by standing tests: `rusty_png` byte-identical to upstream
125/// `png` across every corpus image, and `rusty_jpeg` within 3/255 of
126/// libjpeg using the corpus's own JPEG/PNG twins.
127pub fn load_image(path: &Path) -> Result<ImageBuffer> {
128 // MAGIC BYTES FIRST, extension only as a fallback.
129 //
130 // Dispatching on the extension alone is a real-world trap, not a
131 // hypothetical one: 169 of OmniDocBench's 316 English pages are JPEGs
132 // named `.png`, and every one of them failed to decode with "Invalid PNG
133 // signature" — producing an empty result that scored as 100 % CER and
134 // silently contaminated a whole benchmark run before anyone read stderr.
135 // A file's contents are authoritative; its name is a hint.
136 // Read ONCE and decode from the buffer.
137 //
138 // This sniffed the magic with `fs::read(path).take(8)` — which reads the
139 // WHOLE file to look at eight bytes, and then the decoder read it again.
140 // Every image was loaded twice: measured at 1.16x slower than a
141 // single-read path over 40 images, i.e. the sniff cost more than the
142 // decoder choice it was making. Reading once and passing the bytes down
143 // costs nothing and removes a whole file read per image.
144 let bytes = std::fs::read(path).map_err(|e| Error::Media(format!("open failed: {e}")))?;
145 if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
146 return decode_png(&bytes);
147 }
148 if bytes.starts_with(&[0xFF, 0xD8]) {
149 return decode_jpeg(bytes);
150 }
151 let ext = path
152 .extension()
153 .and_then(|e| e.to_str())
154 .unwrap_or_default()
155 .to_ascii_lowercase();
156 match ext.as_str() {
157 "png" => decode_png(&bytes),
158 "jpg" | "jpeg" => decode_jpeg(bytes),
159 other => Err(Error::Media(format!(
160 "`.{other}` decode is not wired yet — PNG and JPEG are supported; WebP/AVIF \
161 arrive with the rff image decoders. Convert with `ffmpeg -i in.{other} out.png`"
162 ))),
163 }
164}
165
166/// JPEG, decoded by **`rusty_jpeg`** — ours, from crates.io.
167///
168/// Moved off `rff-codec-jpeg` for the same reason PNG did: rff is a git
169/// dependency and `cargo publish` refuses those, which made every crate
170/// downstream of this one unpublishable. `rusty_jpeg` is the same decoder
171/// rff wraps, published directly, with AVX2 FDCT/quantize and a two-block
172/// AVX2 IDCT.
173///
174/// **Grayscale JPEGs are expanded to RGB here on purpose.** `rusty_jpeg`
175/// reports `L8` natively and returning `Gray8` would be leaner — but this
176/// function's existing contract is packed RGB for every JPEG, Carmenta's
177/// OCR corpus is read through it, and changing an output FORMAT is a
178/// different decision from changing a decoder. Made separately, or not at
179/// all.
180fn decode_jpeg(data: Vec<u8>) -> Result<ImageBuffer> {
181 use ffai_core::types::PixelFormat;
182 use rusty_jpeg::{Decoder, PixelFormat as JpegFormat};
183
184 let mut decoder = Decoder::new(std::io::Cursor::new(data));
185 let pixels = decoder
186 .decode()
187 .map_err(|e| Error::Media(format!("JPEG decode: {e}")))?;
188 let info = decoder
189 .info()
190 .ok_or_else(|| Error::Media("JPEG decoded without image info".into()))?;
191 let (w, h) = (info.width as usize, info.height as usize);
192
193 let (data, format) = match info.pixel_format {
194 JpegFormat::RGB24 => (pixels, PixelFormat::Rgb8),
195 JpegFormat::L8 => {
196 // `w * h * 3` is unchecked multiplication on dimensions that came from a
197 // decoded bitstream. On 64-bit it merely asks for an absurd allocation; on
198 // 32-bit - and `ffai-wasm` makes wasm32 a real target - it WRAPS to a small
199 // buffer, and the row/column indexing below then runs past it. Same defect
200 // class as the ONNX dims product (see ffai-mercury's audit, gate H-17).
201 let size = w
202 .checked_mul(h)
203 .and_then(|n| n.checked_mul(3))
204 .ok_or_else(|| {
205 Error::Media(format!("frame {w}x{h} overflows this platform's usize"))
206 })?;
207 let mut rgb = vec![0u8; size];
208 for (i, &g) in pixels.iter().take(w * h).enumerate() {
209 rgb[i * 3..i * 3 + 3].copy_from_slice(&[g, g, g]);
210 }
211 (rgb, PixelFormat::Rgb8)
212 }
213 other => {
214 return Err(Error::Media(format!(
215 "JPEG pixel format {other:?} unsupported — ffai-media handles RGB and grayscale"
216 )));
217 }
218 };
219 Ok(ImageBuffer {
220 width: u32::from(info.width),
221 height: u32::from(info.height),
222 format,
223 data,
224 })
225}
226
227/// PNG, decoded by **`rusty_png`** — our own performance fork of
228/// image-rs/image-png, from crates.io.
229///
230/// This is the third home for this function in two days and the reasoning is
231/// worth keeping, because each move was for a different reason:
232///
233/// 1. The `png` crate. Worked; not ours.
234/// 2. `rff-codec-png` through rff's `CodecRegistry`. Ours, and the registry
235/// seam is genuinely nice — but it forced two costs. rff is a GIT
236/// dependency, and `cargo publish` refuses those outright, so every `FFai`
237/// crate downstream became unpublishable. And `rff_core::PixelFormat` has
238/// no single-channel variant, so grayscale had to expand to `Rgb24` and
239/// contract back: measured **2.36x slower** on Carmenta's 32/32-grayscale
240/// document corpus.
241/// 3. `rusty_png`, published on crates.io. Ours, no git dependency, and
242/// API-compatible with the `png` crate — including `ColorType::Grayscale`,
243/// so gray stays one channel the whole way through and the round-trip
244/// disappears rather than being optimised.
245///
246/// `EXPAND | STRIP_16` is set so palette and 16-bit PNGs decode instead of
247/// erroring, which is the capability the rff path added and this keeps.
248fn decode_png(data: &[u8]) -> Result<ImageBuffer> {
249 use ffai_core::types::PixelFormat;
250 use rusty_png::{BitDepth, ColorType, Decoder, Transformations};
251
252 let mut decoder = Decoder::new(std::io::Cursor::new(data));
253 decoder.set_transformations(Transformations::EXPAND | Transformations::STRIP_16);
254 let mut reader = decoder
255 .read_info()
256 .map_err(|e| Error::Media(format!("PNG header: {e}")))?;
257 let mut buf = vec![0u8; reader.output_buffer_size()];
258 let info = reader
259 .next_frame(&mut buf)
260 .map_err(|e| Error::Media(format!("PNG decode: {e}")))?;
261 buf.truncate(info.buffer_size());
262
263 if info.bit_depth != BitDepth::Eight {
264 return Err(Error::Media(format!(
265 "PNG bit depth {:?} survived STRIP_16 — unsupported",
266 info.bit_depth
267 )));
268 }
269 let format = match info.color_type {
270 ColorType::Grayscale => PixelFormat::Gray8,
271 ColorType::Rgb => PixelFormat::Rgb8,
272 ColorType::Rgba => PixelFormat::Rgba8,
273 ColorType::GrayscaleAlpha => {
274 // Drop alpha: every consumer of a gray page reads luminance.
275 buf = buf.chunks_exact(2).map(|p| p[0]).collect();
276 PixelFormat::Gray8
277 }
278 // EXPAND turns palettes into RGB/RGBA before we get here, so this
279 // arm means the transformation did not apply rather than that the
280 // file is exotic.
281 ColorType::Indexed => return Err(Error::Media("indexed PNG survived EXPAND".into())),
282 };
283 Ok(ImageBuffer {
284 width: info.width,
285 height: info.height,
286 format,
287 data: buf,
288 })
289}
290
291/// Sample frames from a video at `fps` frames/second (for Argus video
292/// understanding). Pending the rff demux/decode integration.
293/// A lazily-decoded video, yielding one frame at a time.
294///
295/// # Why this exists rather than a `Vec`
296///
297/// `sample_frames` used to decode the whole file and hand back a
298/// `Vec<VideoFrame>`. A 1080p RGB frame is 5.9 MiB, so **one minute of video is
299/// 10.4 GiB and ten minutes is 104 GiB** — the API could only ever be used on
300/// clips, and "video ingest" meant "short video ingest".
301///
302/// This holds the demuxer and decoder open and pulls exactly as far as the
303/// caller asks. Memory is one frame plus the decoder's own reference buffers,
304/// whatever the file's length.
305///
306/// Mirrors what Ultralytics' `predict(source, stream=True)` returns — a
307/// generator rather than a list — for the same reason.
308pub struct VideoStream {
309 demux: Box<dyn rff_format::Demuxer>,
310 dec: rusty_h264::Decoder,
311 /// `Some(n)` when the container hands us AVCC and every packet needs its
312 /// `n`-byte length prefixes rewritten as start codes; `None` for Annex-B.
313 nal_length_size: Option<usize>,
314 vidx: usize,
315 tb: rff_core::Rational,
316 /// Seconds between kept frames; `<= 0` keeps every frame.
317 ///
318 /// **Decimation is by TIMESTAMP, not by a decoded-frame stride.** The
319 /// stride version computed its source rate as `time_base.den /
320 /// time_base.num` — but a time base is a CLOCK TICK RATE, not a frame
321 /// rate. MP4 commonly uses 1/12800, so `stream_frames(path, 1.0)` asked
322 /// for one frame per second and computed a stride of 12800, returning
323 /// **exactly one frame** from every clip in the corpus. The failure is
324 /// quiet in the worst way: one frame is a perfectly good frame, so a
325 /// caller sees a plausible result rather than an error.
326 ///
327 /// A deadline in seconds needs no frame rate at all — it reads the
328 /// timestamps the container already carries — and it stays correct on
329 /// variable-frame-rate sources, where no single stride can be right.
330 interval: f64,
331 /// Timestamp the next kept frame must reach.
332 next_due: f64,
333 /// Whether any frame has been kept yet, so the first one is always taken
334 /// regardless of where its timestamp starts.
335 started: bool,
336 /// Packets fed, for error messages that say WHERE it stopped.
337 pkts: usize,
338 path: std::path::PathBuf,
339 done: bool,
340}
341
342impl VideoStream {
343 /// Frames the container claims, when it says. `None` means unknown —
344 /// report it as unknown rather than guessing, since a wrong total in a
345 /// progress line is worse than no total.
346 #[must_use]
347 pub const fn frame_count_hint(&self) -> Option<usize> {
348 None
349 }
350}
351
352impl Iterator for VideoStream {
353 type Item = Result<VideoFrame>;
354
355 // `pts as f64` for a presentation timestamp: an i64 pts beyond 2^53 is
356 // centuries of video at any real time base.
357 #[allow(clippy::cast_precision_loss)]
358 fn next(&mut self) -> Option<Self::Item> {
359 if self.done {
360 return None;
361 }
362 loop {
363 let packet = match self.demux.read_packet() {
364 Ok(p) => p,
365 Err(rff_core::Error::Eof) => {
366 self.done = true;
367 return None;
368 }
369 Err(e) => {
370 self.done = true;
371 return Some(Err(Error::Media(format!("{}: {e}", self.path.display()))));
372 }
373 };
374 if packet.stream_index != self.vidx {
375 continue;
376 }
377 self.pkts += 1;
378 // Errors PROPAGATE. This was `if ... .is_err() { continue; }`,
379 // which turned a decoder reporting itself clearly into a silent
380 // short read — a standard x264 file yielded zero frames and no
381 // diagnostic, indistinguishable from an empty video.
382 // Rewrite AVCC to Annex-B when the container uses it. `to_annexb`
383 // returns None for anything that is not length-prefixed — including
384 // a packet that is already Annex-B — so a wrong guess passes the
385 // data through untouched rather than mangling it.
386 let converted = self
387 .nal_length_size
388 .and_then(|n| annexb::to_annexb(&packet.data, n));
389 let payload: &[u8] = converted.as_deref().unwrap_or(&packet.data);
390 let frame = match self.dec.decode(payload) {
391 Ok(f) => f,
392 Err(e) => {
393 self.done = true;
394 return Some(Err(Error::Media(format!(
395 "{}: decode failed on packet {} : {e}",
396 self.path.display(),
397 self.pkts
398 ))));
399 }
400 };
401 let Some(v) = frame else { continue };
402 let ts = packet.pts.map_or(0.0, |p| {
403 p as f64 * f64::from(self.tb.num) / f64::from(self.tb.den.max(1))
404 });
405 if self.interval > 0.0 {
406 if self.started && ts < self.next_due {
407 continue;
408 }
409 // Advance from the DEADLINE, not from the frame's own
410 // timestamp, so the sampling grid does not drift late by half
411 // an interval on every step. Clamped forward for sources whose
412 // gaps exceed the interval, so a long gap does not leave a
413 // backlog of instantly-due frames afterwards.
414 // SITE-REVIEWED: clippy offers `self.interval.mul_add(0.5, ts)`
415 // and it is REFUSED here. `mul_add` is a fused multiply-add --
416 // one rounding where this expression has two -- so it can move
417 // the deadline by an ULP. This value is a frame-selection
418 // boundary, compared against `ts` on the next iteration, so an
419 // ULP either way can change WHICH FRAME a source emits at an
420 // exact tick. A decoder does not get to be 1 ULP creative.
421 //
422 // Bound through a `let` because an attribute on the assignment
423 // itself is `#![feature(stmt_expr_attributes)]`, still unstable.
424 #[allow(clippy::suboptimal_flops)]
425 let due = if self.started {
426 (self.next_due + self.interval).max(ts + self.interval * 0.5)
427 } else {
428 ts + self.interval
429 };
430 self.next_due = due;
431 self.started = true;
432 }
433 return Some(from_rusty_frame(&v, ts));
434 }
435 }
436}
437
438/// Open a video and stream its frames. `fps <= 0` keeps every frame.
439///
440/// Demuxes with `rff-format-mp4` and decodes with `rusty_h264` — the whole path
441/// is Remade-With-Rust, no libavformat and no libavcodec.
442#[allow(clippy::cast_precision_loss)]
443pub fn stream_frames(path: &Path, fps: f64) -> Result<VideoStream> {
444 use rff_format::FormatRegistry;
445
446 // Extension -> the name the demuxer REGISTERS UNDER, which is not the
447 // extension: `rff-format-mkv` registers as "matroska", `rff-format-ts` as
448 // "mpegts". Looking up by extension silently found nothing and reported
449 // "no demuxer found for input `mkv`" while the crate was linked and working.
450 let ext = path
451 .extension()
452 .and_then(|e| e.to_str())
453 .map(str::to_ascii_lowercase)
454 .unwrap_or_default();
455 let demuxer_name = match ext.as_str() {
456 "mp4" | "mov" | "m4v" => "mp4",
457 "mkv" | "webm" | "mka" => "matroska",
458 "avi" => "avi",
459 "ts" | "m2ts" | "mts" => "mpegts",
460 other => {
461 return Err(Error::Media(format!(
462 "`.{other}`: no demuxer wired. Supported: mp4/mov/m4v, mkv/webm, \
463 avi, ts/m2ts/mts. MPEG-PS and ASF land when their rff-format-* \
464 crates publish — see docs/rff-gaps-for-ffai.md."
465 )));
466 }
467 };
468
469 let file = std::fs::File::open(path)?;
470 let mkerr = |e: rff_core::Error| Error::Media(format!("{}: {e}", path.display()));
471 let mut formats = FormatRegistry::new();
472 rff_format_mp4::register(&mut formats);
473 rff_format_mkv::register(&mut formats);
474 rff_format_avi::register(&mut formats);
475 rff_format_ts::register(&mut formats);
476 let mut demux = formats
477 .open_demuxer(demuxer_name, Box::new(std::io::BufReader::new(file)))
478 .map_err(mkerr)?;
479 let streams = demux.read_header().map_err(mkerr)?;
480
481 let (vidx, vstream) = streams
482 .iter()
483 .enumerate()
484 .find(|(_, s)| s.media_type == rff_core::MediaType::Video)
485 .ok_or_else(|| Error::Media(format!("{}: no video stream", path.display())))?;
486
487 let mut dec = rusty_h264::Decoder::new();
488 // Two container conventions, and getting this wrong is SILENT.
489 //
490 // `rff-format-mp4` hands back Annex-B with empty extradata. `rff-format-mkv`
491 // hands back AVCC (length-prefixed) with an `avcC` in extradata, which
492 // `rusty_h264` does not parse — measured at 164 packets, 0 frames, 0 errors.
493 // If this is an avcC, convert the parameter sets to Annex-B, feed them, and
494 // remember the length size so every packet can be rewritten too.
495 let avcc = annexb::parse_avcc(&vstream.extradata);
496 let nal_length_size = avcc.as_ref().map(|c| c.nal_length_size);
497 match &avcc {
498 Some(c) => {
499 let _ = dec.decode(&c.parameter_sets);
500 }
501 None if !vstream.extradata.is_empty() => {
502 let _ = dec.decode(&vstream.extradata);
503 }
504 None => {}
505 }
506
507 let tb = vstream.time_base;
508 // NOT `time_base.den / time_base.num` — see `VideoStream::interval`. That
509 // read a clock tick rate as a frame rate and decimated 12800x.
510 let interval = if fps > 0.0 { 1.0 / fps } else { 0.0 };
511
512 Ok(VideoStream {
513 demux,
514 dec,
515 nal_length_size,
516 vidx,
517 tb,
518 interval,
519 next_due: 0.0,
520 started: false,
521 pkts: 0,
522 path: path.to_path_buf(),
523 done: false,
524 })
525}
526
527/// Every frame at once. Prefer [`stream_frames`] — this holds the whole video
528/// in memory and exists for callers that genuinely want a `Vec`.
529// `(src_fps / fps).round().max(1.0) as usize`: `.max(1.0)` fixes the low end
530// AND absorbs NaN (f64::max returns the non-NaN operand), and Rust saturates
531// float->int casts. A ratio large enough to truncate needs an fps of ~1e-19.
532#[allow(
533 clippy::cast_possible_truncation,
534 clippy::cast_sign_loss,
535 clippy::cast_precision_loss
536)]
537pub fn sample_frames(path: &Path, fps: f64) -> Result<Vec<VideoFrame>> {
538 stream_frames(path, fps)?.collect()
539}
540
541/// `rusty_h264`'s `YUV420p` frame to RGB8, the format every `FFai` engine consumes.
542///
543/// BT.601 limited range, matching `OpenCV`'s `COLOR_YUV2RGB_I420` — so frames
544/// decoded here and frames extracted by the Python tooling agree, and a
545/// comparison between two engines is not secretly a comparison between two
546/// colour conversions.
547// Every `as u8` here is preceded by `.clamp(0.0, 255.0)` on the same
548// expression, so truncation and sign loss are impossible by construction -
549// the clamp IS the guard, not an afterthought. `width/height as u32` round-trip
550// dimensions that arrived as u32 from the decoder.
551#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
552fn from_rusty_frame(v: &rusty_h264::YuvFrame, ts: f64) -> Result<VideoFrame> {
553 let (w, h) = (v.width, v.height);
554 // `w * h * 3` is unchecked multiplication on dimensions that came from a
555 // decoded bitstream. On 64-bit it merely asks for an absurd allocation; on
556 // 32-bit - and `ffai-wasm` makes wasm32 a real target - it WRAPS to a small
557 // buffer, and the row/column indexing below then runs past it. Same defect
558 // class as the ONNX dims product (see ffai-mercury's audit, gate H-17).
559 let size = w
560 .checked_mul(h)
561 .and_then(|n| n.checked_mul(3))
562 .ok_or_else(|| Error::Media(format!("frame {w}x{h} overflows this platform's usize")))?;
563 let mut rgb = vec![0u8; size];
564 let (ys, us, vs) = (w, w.div_ceil(2), w.div_ceil(2));
565 for row in 0..h {
566 for col in 0..w {
567 let y = f32::from(v.y[row * ys + col]);
568 let cu = f32::from(v.u[(row / 2) * us + col / 2]) - 128.0;
569 let cv = f32::from(v.v[(row / 2) * vs + col / 2]) - 128.0;
570 let yy = 1.164 * (y - 16.0);
571 let o = (row * w + col) * 3;
572 rgb[o] = 1.596f32.mul_add(cv, yy).clamp(0.0, 255.0) as u8;
573 rgb[o + 1] = 0.391f32
574 .mul_add(-cu, 0.813f32.mul_add(-cv, yy))
575 .clamp(0.0, 255.0) as u8;
576 rgb[o + 2] = 2.018f32.mul_add(cu, yy).clamp(0.0, 255.0) as u8;
577 }
578 }
579 Ok(VideoFrame {
580 image: ImageBuffer {
581 width: w as u32,
582 height: h as u32,
583 format: ffai_core::types::PixelFormat::Rgb8,
584 data: rgb,
585 },
586 timestamp: ts,
587 })
588}
589
590/// `YUV420p` to RGB8, the format every `FFai` engine consumes.
591///
592/// BT.601 limited range, matching `OpenCV`'s `COLOR_YUV2RGB_I420` — so frames
593/// extracted here and frames extracted by the Python tooling agree, and a
594/// comparison between the two engines is not secretly a comparison between
595/// two colour conversions.
596// Unused until the video path lands: `rff` provides demuxers and H.264/VP9,
597// and nothing calls this until that arrives (see the workspace manifest's note
598// on rff being the one git dependency). Retained rather than deleted because
599// the colour-conversion contract above is the hard part and would have to be
600// rewritten identically.
601#[allow(dead_code)]
602// Every `as u8` here is preceded by `.clamp(0.0, 255.0)` on the same
603// expression, so truncation and sign loss are impossible by construction -
604// the clamp IS the guard, not an afterthought. `width/height as u32` round-trip
605// dimensions that arrived as u32 from the decoder.
606#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
607fn from_rff_frame(v: &rff_core::VideoFrame, ts: f64) -> Result<VideoFrame> {
608 let (w, h) = (v.width as usize, v.height as usize);
609 if v.planes.len() < 3 || v.strides.len() < 3 {
610 return Err(Error::Media(format!(
611 "expected 3 planar YUV planes, found {}",
612 v.planes.len()
613 )));
614 }
615 let (yp, up, vp) = (&v.planes[0], &v.planes[1], &v.planes[2]);
616 let (ys, us, vs) = (v.strides[0], v.strides[1], v.strides[2]);
617 // `w * h * 3` is unchecked multiplication on dimensions that came from a
618 // decoded bitstream. On 64-bit it merely asks for an absurd allocation; on
619 // 32-bit - and `ffai-wasm` makes wasm32 a real target - it WRAPS to a small
620 // buffer, and the row/column indexing below then runs past it. Same defect
621 // class as the ONNX dims product (see ffai-mercury's audit, gate H-17).
622 let size = w
623 .checked_mul(h)
624 .and_then(|n| n.checked_mul(3))
625 .ok_or_else(|| Error::Media(format!("frame {w}x{h} overflows this platform's usize")))?;
626 let mut rgb = vec![0u8; size];
627 for row in 0..h {
628 for col in 0..w {
629 let yv = f32::from(yp[row * ys + col]) - 16.0;
630 let uv = f32::from(up[(row / 2) * us + col / 2]) - 128.0;
631 let vv = f32::from(vp[(row / 2) * vs + col / 2]) - 128.0;
632 let o = (row * w + col) * 3;
633 rgb[o] = 1.164f32.mul_add(yv, 1.596 * vv).clamp(0.0, 255.0) as u8;
634 rgb[o + 1] = 0.391f32
635 .mul_add(-uv, 1.164f32.mul_add(yv, -(0.813 * vv)))
636 .clamp(0.0, 255.0) as u8;
637 rgb[o + 2] = 1.164f32.mul_add(yv, 2.018 * uv).clamp(0.0, 255.0) as u8;
638 }
639 }
640 Ok(VideoFrame {
641 image: ImageBuffer {
642 width: v.width,
643 height: v.height,
644 format: ffai_core::types::PixelFormat::Rgb8,
645 data: rgb,
646 },
647 timestamp: ts,
648 })
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654
655 #[test]
656 fn wav_roundtrip_preserves_samples() {
657 let audio = AudioBuffer {
658 samples: (0..1600).map(|i| (i as f32 / 100.0).sin() * 0.5).collect(),
659 sample_rate: 16_000,
660 channels: 1,
661 };
662 let path = std::env::temp_dir().join("ffai_media_roundtrip_test.wav");
663 save_wav(&path, &audio).unwrap();
664 let loaded = load_audio(&path).unwrap();
665 std::fs::remove_file(&path).ok();
666
667 assert_eq!(loaded.sample_rate, 16_000);
668 assert_eq!(loaded.channels, 1);
669 assert_eq!(loaded.samples.len(), audio.samples.len());
670 // f32 WAV is lossless.
671 assert_eq!(loaded.samples, audio.samples);
672 }
673
674 #[test]
675 fn unknown_extension_names_the_backend_plan() {
676 let err = load_audio(Path::new("clip.mp3")).unwrap_err();
677 assert!(err.to_string().contains("remade_ffmpeg_rs"));
678 }
679}
680
681#[cfg(test)]
682mod png_oracle {
683 use super::*;
684 use ffai_core::types::PixelFormat;
685
686 /// Upstream `png` — the crate `rusty_png` is a performance FORK of.
687 ///
688 /// That is the right oracle for a fork: it is the implementation whose
689 /// behaviour ours is supposed to reproduce, maintained by someone else,
690 /// so it cannot drift with our edits. A decoder swap changes the pixels
691 /// every downstream gate is computed from, which is why this is a
692 /// standing test and not a one-off check.
693 fn decode_png_upstream(data: &[u8]) -> Result<ImageBuffer> {
694 use ffai_core::types::PixelFormat;
695 let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
696 decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
697 let mut reader = decoder
698 .read_info()
699 .map_err(|e| Error::Media(format!("PNG header: {e}")))?;
700 let mut buf = vec![0u8; reader.output_buffer_size()];
701 let info = reader
702 .next_frame(&mut buf)
703 .map_err(|e| Error::Media(format!("PNG decode: {e}")))?;
704 buf.truncate(info.buffer_size());
705 if info.bit_depth != png::BitDepth::Eight {
706 return Err(Error::Media("non-8-bit".into()));
707 }
708 let format = match info.color_type {
709 png::ColorType::Grayscale => PixelFormat::Gray8,
710 png::ColorType::Rgb => PixelFormat::Rgb8,
711 png::ColorType::Rgba => PixelFormat::Rgba8,
712 png::ColorType::GrayscaleAlpha => {
713 buf = buf.chunks_exact(2).map(|p| p[0]).collect();
714 PixelFormat::Gray8
715 }
716 png::ColorType::Indexed => return Err(Error::Media("indexed".into())),
717 };
718 Ok(ImageBuffer {
719 width: info.width,
720 height: info.height,
721 format,
722 data: buf,
723 })
724 }
725
726 fn repo_root() -> std::path::PathBuf {
727 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
728 .parent()
729 .and_then(|p| p.parent())
730 .expect("crates/ffai-media has two ancestors")
731 .to_path_buf()
732 }
733
734 /// `rusty_jpeg` against libjpeg, for free, using the corpus's own twins.
735 ///
736 /// `tools/diana_coco_corpus.py` builds each `coco-NNN.png` by decoding
737 /// `coco-NNN.src.jpg` with Pillow (libjpeg) and re-encoding LOSSLESSLY.
738 /// So the PNG carries libjpeg's decode of that exact JPEG, and decoding
739 /// both through this crate compares our JPEG decoder against libjpeg's
740 /// with no reference implementation to install.
741 ///
742 /// The bound is a TOLERANCE, not equality, and deliberately so: the JPEG
743 /// spec does not mandate a single IDCT, so conforming decoders disagree
744 /// in the last bit or two. Measured worst channel delta here is **3 of
745 /// 255**. A regression that broke the decoder would blow through this by
746 /// orders of magnitude; a legitimate IDCT change would not.
747 #[test]
748 fn rusty_jpeg_agrees_with_libjpeg_via_the_corpus_twins() {
749 let root = repo_root().join("corpora/clips/diana-coco");
750 let (mut n, mut worst) = (0usize, 0i32);
751 for i in 0..8 {
752 let (j, p) = (
753 root.join(format!("coco-{i:03}.src.jpg")),
754 root.join(format!("coco-{i:03}.png")),
755 );
756 if !j.exists() || !p.exists() {
757 continue;
758 }
759 let a = load_image(&j).expect("jpeg");
760 let b = load_image(&p).expect("png");
761 assert_eq!(
762 (a.width, a.height),
763 (b.width, b.height),
764 "coco-{i:03}: dimensions"
765 );
766 assert_eq!(a.data.len(), b.data.len(), "coco-{i:03}: buffer length");
767 worst = worst.max(
768 a.data
769 .iter()
770 .zip(&b.data)
771 .map(|(x, y)| (*x as i32 - *y as i32).abs())
772 .max()
773 .unwrap_or(0),
774 );
775 n += 1;
776 }
777 if n == 0 {
778 eprintln!("SKIP jpeg/libjpeg twin check: corpus absent");
779 return;
780 }
781 assert!(
782 worst <= 8,
783 "rusty_jpeg diverges from libjpeg by {worst}/255 over {n} images"
784 );
785 eprintln!("rusty_jpeg vs libjpeg: {n} images, worst channel delta {worst}/255");
786 }
787
788 /// Every corpus PNG must decode BIT-IDENTICALLY through rff and through
789 /// the implementation it replaced — RGB (Diana) and grayscale
790 /// (Carmenta) alike, since the grayscale contraction is the part that
791 /// could plausibly differ.
792 #[test]
793 fn rusty_png_matches_upstream_png() {
794 let root = repo_root();
795 let dirs = [
796 "corpora/clips/diana-coco-v3",
797 "corpora/clips/diana-coco",
798 "corpora/clips/carmenta-doc",
799 "corpora/clips/carmenta-synth",
800 ];
801 let (mut checked, mut dirs_seen) = (0usize, 0usize);
802 for d in dirs {
803 let Ok(entries) = std::fs::read_dir(root.join(d)) else {
804 continue;
805 };
806 dirs_seen += 1;
807 let mut paths: Vec<_> = entries
808 .filter_map(|e| e.ok().map(|e| e.path()))
809 .filter(|p| p.extension().is_some_and(|x| x == "png"))
810 .collect();
811 paths.sort();
812 paths.truncate(12);
813 for p in paths {
814 let Ok(bytes) = std::fs::read(&p) else {
815 continue;
816 };
817 let Ok(want) = decode_png_upstream(&bytes) else {
818 continue;
819 };
820 let got = decode_png(&bytes)
821 .unwrap_or_else(|e| panic!("rff failed on {}: {e}", p.display()));
822 assert_eq!(got.width, want.width, "{}: width", p.display());
823 assert_eq!(got.height, want.height, "{}: height", p.display());
824 assert_eq!(got.format, want.format, "{}: pixel format", p.display());
825 assert_eq!(
826 got.data.len(),
827 want.data.len(),
828 "{}: byte count",
829 p.display()
830 );
831 assert!(got.data == want.data, "{}: PIXELS DIFFER", p.display());
832 checked += 1;
833 }
834 }
835 // SKIP rather than fail when the corpus is absent, matching how the
836 // tokenizer oracle handles missing weights. A checkout without corpora -
837 // any CI runner, any fresh clone - would otherwise fail a test that has
838 // nothing to say, and a suite that is red for an uninteresting reason
839 // stops being read.
840 if dirs_seen == 0 {
841 eprintln!("png oracle: no corpus directories present, skipping");
842 return;
843 }
844 eprintln!("rusty_png == upstream png on {checked} images across {dirs_seen} corpora");
845 }
846}
847
848/// Write a 16-bit grayscale PNG.
849///
850/// Lives here rather than in a caller because this crate already owns the
851/// PNG dependency and the decode side, so encode belongs beside them.
852///
853/// Diana's depth maps are the motivating case: a `u16` per pixel carries
854/// enough precision for a normalised depth visualisation, where 8 bits
855/// visibly bands a smooth field. The METRES do not survive normalisation —
856/// anything numeric should take the raw f32 instead.
857///
858/// `pixels` is row-major, `width * height` samples. PNG stores 16-bit
859/// samples BIG-endian regardless of host order, which is the one detail
860/// easy to get wrong and impossible to see afterwards: a byte-swapped map
861/// still renders, as noise.
862// Dimensions cast to u32 for the PNG header. A width or height beyond u32 is
863// not representable in PNG at all, and the encoder rejects it downstream.
864#[allow(clippy::cast_possible_truncation)]
865pub fn save_gray16_png(path: &Path, pixels: &[u16], width: usize, height: usize) -> Result<()> {
866 if pixels.len() != width * height {
867 return Err(Error::Other(format!(
868 "save_gray16_png: {} pixels for a {width}x{height} image",
869 pixels.len()
870 )));
871 }
872 let file = std::fs::File::create(path)?;
873 let mut enc =
874 rusty_png::Encoder::new(std::io::BufWriter::new(file), width as u32, height as u32);
875 enc.set_color(rusty_png::ColorType::Grayscale);
876 enc.set_depth(rusty_png::BitDepth::Sixteen);
877 let mut w = enc
878 .write_header()
879 .map_err(|e| Error::Other(format!("png header: {e}")))?;
880 let mut bytes = Vec::with_capacity(pixels.len() * 2);
881 for p in pixels {
882 bytes.extend_from_slice(&p.to_be_bytes());
883 }
884 w.write_image_data(&bytes)
885 .map_err(|e| Error::Other(format!("png write: {e}")))?;
886 w.finish()
887 .map_err(|e| Error::Other(format!("png finish: {e}")))?;
888 Ok(())
889}