Skip to main content

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}