Skip to main content

rightkit_qa/
media.rs

1//! Content validators for generated artifacts: a file existing is not a result.
2//! Pure Rust for WAV/PNG/loudness; ffmpeg (a caller-supplied binary path) only where a
3//! compressed container must be decoded. See also [`crate::loudness`].
4use crate::process::{run_owned, RunOptions, Tracker};
5use crate::util::{err, sha256_hex, Result};
6use serde::Serialize;
7use serde_json::{json, Value};
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12pub fn file_stat(path: &Path) -> Value {
13    match fs::read(path) {
14        Ok(b) => json!({"exists": true, "size": b.len(), "sha256": sha256_hex(&b)}),
15        Err(_) => json!({"exists": false, "size": 0, "sha256": null}),
16    }
17}
18
19pub fn png_size(path: &Path) -> Result<Value> {
20    let b = fs::read(path)?;
21    png_size_bytes(&b, path)
22}
23
24/// Width and height from PNG bytes already in memory; `path` only names the file in the error.
25pub fn png_size_bytes(b: &[u8], path: &Path) -> Result<Value> {
26    if b.len() < 24 || b[..8] != [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a] {
27        return err(format!("{} is not a PNG", path.display()));
28    }
29    Ok(
30        json!({"width": u32::from_be_bytes(b[16..20].try_into().unwrap()), "height": u32::from_be_bytes(b[20..24].try_into().unwrap())}),
31    )
32}
33
34/// Parse a WAV: channels, rate, duration, RMS/peak dB, silence flag (JSON form of [`wav_info`]).
35/// Keys: `channels`, `rate`, `bits`, `duration_s`, `rms_db`, `peak_db`, `silent`, plus
36/// `sample_rate`, `frames`, `float`.
37pub fn wav_stats(path: &Path) -> Result<Value> {
38    let w = wav_info(path)?;
39    Ok(json!({
40        "channels": w.channels as f64, "rate": w.sample_rate as f64, "sample_rate": w.sample_rate,
41        "bits": w.bits, "float": w.float, "frames": w.frames, "duration_s": w.duration_s,
42        "rms_db": w.rms_dbfs, "peak_db": w.peak_dbfs, "silent": w.silent,
43    }))
44}
45
46/// Deterministic 16 kHz mono speech-like WAV (same generator the Node suite used).
47pub fn write_reference_wav(path: &Path, seconds: f64) -> Result<()> {
48    let rate = 16000f64;
49    let n = (rate * seconds) as usize;
50    let mut data = Vec::with_capacity(n * 2);
51    let (mut s, mut lp) = (12345i64, 0f64);
52    for i in 0..n {
53        s = (s * 1103515245 + 12345) & 0x7fffffff;
54        let rnd = s as f64 / 0x40000000 as f64 - 1.0;
55        let t = i as f64;
56        let env = 0.4 + 0.6 * (2.0 * std::f64::consts::PI * 3.3 * t / rate).sin().abs();
57        lp = 0.85 * lp + 0.15 * rnd;
58        let v = env
59            * (0.35 * (2.0 * std::f64::consts::PI * 190.0 * t / rate).sin()
60                + 0.2 * (2.0 * std::f64::consts::PI * 380.0 * t / rate + 1.0).sin()
61                + 0.25 * lp);
62        data.extend_from_slice(&((v.clamp(-1.0, 1.0) * 32000.0) as i16).to_le_bytes());
63    }
64    let mut out = Vec::with_capacity(44 + data.len());
65    out.extend_from_slice(b"RIFF");
66    out.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
67    out.extend_from_slice(b"WAVEfmt ");
68    out.extend_from_slice(&16u32.to_le_bytes());
69    out.extend_from_slice(&1u16.to_le_bytes());
70    out.extend_from_slice(&1u16.to_le_bytes());
71    out.extend_from_slice(&16000u32.to_le_bytes());
72    out.extend_from_slice(&32000u32.to_le_bytes());
73    out.extend_from_slice(&2u16.to_le_bytes());
74    out.extend_from_slice(&16u16.to_le_bytes());
75    out.extend_from_slice(b"data");
76    out.extend_from_slice(&(data.len() as u32).to_le_bytes());
77    out.extend_from_slice(&data);
78    if let Some(p) = path.parent() {
79        fs::create_dir_all(p)?;
80    }
81    fs::write(path, out)?;
82    Ok(())
83}
84
85static FFPROBE: std::sync::RwLock<Option<PathBuf>> = std::sync::RwLock::new(None);
86
87/// Pin the ffprobe binary for this process. The harness calls this with the tool
88/// [`locate_tool`] finds through its Settings file, so lookups survive scrubbed envs.
89pub fn set_ffprobe_bin(path: impl Into<PathBuf>) {
90    if let Ok(mut slot) = FFPROBE.write() {
91        *slot = Some(path.into());
92    }
93}
94
95/// Env override, then the binary pinned by [`set_ffprobe_bin`], then `ffprobe` on PATH.
96pub fn ffprobe_bin() -> String {
97    if let Ok(v) =
98        std::env::var("RIGHTKIT_QA_FFPROBE").or_else(|_| std::env::var("GENRIGHT_FFPROBE"))
99    {
100        return v;
101    }
102    if let Some(p) = FFPROBE.read().ok().and_then(|slot| slot.clone()) {
103        return p.to_string_lossy().into_owned();
104    }
105    "ffprobe".into()
106}
107
108/// `ffprobe -show_format -show_streams` as JSON; errors if ffprobe is absent.
109pub fn ffprobe(path: &Path, tracker: &Tracker) -> Result<Value> {
110    ffprobe_with(&ffprobe_bin(), path, tracker)
111}
112
113/// [`ffprobe`] with an explicit binary.
114pub fn ffprobe_with(bin: &str, path: &Path, tracker: &Tracker) -> Result<Value> {
115    ffprobe_run(bin, path, tracker, false)
116}
117
118/// [`ffprobe`] with `-count_frames`: each stream gains `nb_read_frames`, the frames ffprobe
119/// actually decoded. This decodes the whole file, so it is slower than [`ffprobe`].
120pub fn ffprobe_count_frames_with(bin: &str, path: &Path, tracker: &Tracker) -> Result<Value> {
121    ffprobe_run(bin, path, tracker, true)
122}
123
124/// [`ffprobe_count_frames_with`] with the binary from [`ffprobe_bin`].
125pub fn ffprobe_count_frames(path: &Path, tracker: &Tracker) -> Result<Value> {
126    ffprobe_count_frames_with(&ffprobe_bin(), path, tracker)
127}
128
129fn ffprobe_run(bin: &str, path: &Path, tracker: &Tracker, count_frames: bool) -> Result<Value> {
130    let mut args: Vec<String> = vec![
131        "-v".into(),
132        "quiet".into(),
133        "-print_format".into(),
134        "json".into(),
135        "-show_format".into(),
136        "-show_streams".into(),
137    ];
138    if count_frames {
139        args.push("-count_frames".into());
140    }
141    args.push(path.to_string_lossy().into());
142    let out = run_owned(
143        bin,
144        &args,
145        &RunOptions {
146            timeout: Some(Duration::from_secs(if count_frames { 600 } else { 60 })),
147            label: "ffprobe".into(),
148            ..Default::default()
149        },
150        tracker,
151    )?;
152    if out.code != Some(0) {
153        return err(format!(
154            "ffprobe failed ({:?}): {}",
155            out.code,
156            crate::util::tail(&out.stderr, 500)
157        ));
158    }
159    Ok(serde_json::from_str(&out.stdout)?)
160}
161
162// ------------------------------------------------------------------ PCM audio
163
164/// Decoded audio: interleaved samples in `[-1, 1]`.
165#[derive(Debug, Clone, PartialEq)]
166pub struct Pcm {
167    pub sample_rate: u32,
168    pub channels: u16,
169    pub samples: Vec<f32>,
170}
171
172impl Pcm {
173    pub fn frames(&self) -> usize {
174        self.samples.len() / self.channels.max(1) as usize
175    }
176    pub fn duration_s(&self) -> f64 {
177        self.frames() as f64 / self.sample_rate.max(1) as f64
178    }
179}
180
181/// WAV header facts plus signal level.
182#[derive(Debug, Clone, PartialEq, Serialize)]
183pub struct WavInfo {
184    pub channels: u16,
185    pub sample_rate: u32,
186    pub bits: u16,
187    /// IEEE float samples (format 3) rather than integer PCM.
188    pub float: bool,
189    pub frames: u64,
190    pub duration_s: f64,
191    /// Largest absolute sample, dBFS (-180 for digital silence).
192    pub peak_dbfs: f64,
193    /// RMS over every sample of every channel, dBFS.
194    pub rms_dbfs: f64,
195    /// RMS below 1e-4 (-80 dBFS).
196    pub silent: bool,
197}
198
199/// Read and decode a WAV file (PCM 8/16/24/32-bit, float 32/64-bit, WAVE_FORMAT_EXTENSIBLE).
200pub fn read_wav(path: &Path) -> Result<Pcm> {
201    parse_wav(&fs::read(path)?).map_err(|e| crate::util::Error(format!("{}: {e}", path.display())))
202}
203
204/// Decode WAV bytes. A streamed header (data size 0 or 0xFFFFFFFF) reads to end of file.
205pub fn parse_wav(b: &[u8]) -> Result<Pcm> {
206    if b.len() < 12 || &b[0..4] != b"RIFF" || &b[8..12] != b"WAVE" {
207        return err("not a RIFF/WAVE file");
208    }
209    let (mut off, mut fmt, mut data) = (12usize, None, None);
210    while off + 8 <= b.len() {
211        let id = &b[off..off + 4];
212        let mut size = u32::from_le_bytes(b[off + 4..off + 8].try_into().unwrap()) as usize;
213        if id == b"data" && (size == 0 || size == u32::MAX as usize) {
214            size = b.len() - (off + 8);
215        }
216        let end = (off + 8).saturating_add(size).min(b.len());
217        if id == b"fmt " {
218            fmt = Some(&b[off + 8..end]);
219        }
220        if id == b"data" {
221            data = Some(&b[off + 8..end]);
222        }
223        off = off.saturating_add(8 + size + (size % 2));
224    }
225    let (Some(fmt), Some(data)) = (fmt, data) else {
226        return err("WAV is missing fmt or data chunk");
227    };
228    if fmt.len() < 16 {
229        return err("WAV fmt chunk too short");
230    }
231    let mut tag = u16::from_le_bytes([fmt[0], fmt[1]]);
232    if tag == 0xFFFE && fmt.len() >= 26 {
233        tag = u16::from_le_bytes([fmt[24], fmt[25]]); // SubFormat GUID's leading format code
234    }
235    let channels = u16::from_le_bytes([fmt[2], fmt[3]]);
236    let sample_rate = u32::from_le_bytes(fmt[4..8].try_into().unwrap());
237    let bits = u16::from_le_bytes([fmt[14], fmt[15]]);
238    if channels == 0 || sample_rate == 0 {
239        return err("WAV declares zero channels or zero sample rate");
240    }
241    let width = (bits as usize).div_ceil(8);
242    let usable = data.len() - data.len() % (width.max(1) * channels as usize);
243    let data = &data[..usable];
244    let samples: Vec<f32> = match (tag, bits) {
245        (1, 8) => data.iter().map(|&v| (v as f32 - 128.0) / 128.0).collect(),
246        (1, 16) => data
247            .as_chunks::<2>()
248            .0
249            .iter()
250            .map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
251            .collect(),
252        (1, 24) => data
253            .as_chunks::<3>()
254            .0
255            .iter()
256            .map(|c| (i32::from_le_bytes([0, c[0], c[1], c[2]]) >> 8) as f32 / 8_388_608.0)
257            .collect(),
258        (1, 32) => data
259            .as_chunks::<4>()
260            .0
261            .iter()
262            .map(|c| (i32::from_le_bytes(*c) as f64 / 2_147_483_648.0) as f32)
263            .collect(),
264        (3, 32) => data
265            .as_chunks::<4>()
266            .0
267            .iter()
268            .map(|c| f32::from_le_bytes(*c))
269            .collect(),
270        (3, 64) => data
271            .as_chunks::<8>()
272            .0
273            .iter()
274            .map(|c| f64::from_le_bytes(*c) as f32)
275            .collect(),
276        _ => {
277            return err(format!(
278                "unsupported WAV encoding: format {tag}, {bits} bits"
279            ))
280        }
281    };
282    Ok(Pcm {
283        sample_rate,
284        channels,
285        samples,
286    })
287}
288
289/// Header facts and levels of a WAV file.
290pub fn wav_info(path: &Path) -> Result<WavInfo> {
291    let b = fs::read(path)?;
292    let pcm = parse_wav(&b).map_err(|e| crate::util::Error(format!("{}: {e}", path.display())))?;
293    // parse_wav validated the fmt chunk; re-read bits/float for the report.
294    let fmt_at = b.windows(4).position(|w| w == b"fmt ").unwrap_or(12) + 8;
295    let mut tag = u16::from_le_bytes([b[fmt_at], b[fmt_at + 1]]);
296    if tag == 0xFFFE && b.len() > fmt_at + 25 {
297        tag = u16::from_le_bytes([b[fmt_at + 24], b[fmt_at + 25]]);
298    }
299    let bits = u16::from_le_bytes([b[fmt_at + 14], b[fmt_at + 15]]);
300    let (mut sum_sq, mut peak) = (0f64, 0f64);
301    for &v in &pcm.samples {
302        let v = v as f64;
303        sum_sq += v * v;
304        peak = peak.max(v.abs());
305    }
306    let rms = (sum_sq / pcm.samples.len().max(1) as f64).sqrt();
307    Ok(WavInfo {
308        channels: pcm.channels,
309        sample_rate: pcm.sample_rate,
310        bits,
311        float: tag == 3,
312        frames: pcm.frames() as u64,
313        duration_s: pcm.duration_s(),
314        peak_dbfs: dbfs(peak),
315        rms_dbfs: dbfs(rms),
316        silent: rms < 0.0001,
317    })
318}
319
320fn dbfs(v: f64) -> f64 {
321    20.0 * (if v > 0.0 { v } else { 1e-9 }).log10()
322}
323
324/// Write 32-bit float PCM WAV (fixtures, decoded intermediates).
325pub fn write_wav_f32(path: &Path, pcm: &Pcm) -> Result<()> {
326    let data_len = (pcm.samples.len() * 4) as u32;
327    let mut out = Vec::with_capacity(44 + data_len as usize);
328    out.extend_from_slice(b"RIFF");
329    out.extend_from_slice(&(36 + data_len).to_le_bytes());
330    out.extend_from_slice(b"WAVEfmt ");
331    out.extend_from_slice(&16u32.to_le_bytes());
332    out.extend_from_slice(&3u16.to_le_bytes());
333    out.extend_from_slice(&pcm.channels.to_le_bytes());
334    out.extend_from_slice(&pcm.sample_rate.to_le_bytes());
335    out.extend_from_slice(&(pcm.sample_rate * pcm.channels as u32 * 4).to_le_bytes());
336    out.extend_from_slice(&(pcm.channels * 4).to_le_bytes());
337    out.extend_from_slice(&32u16.to_le_bytes());
338    out.extend_from_slice(b"data");
339    out.extend_from_slice(&data_len.to_le_bytes());
340    for s in &pcm.samples {
341        out.extend_from_slice(&s.to_le_bytes());
342    }
343    if let Some(p) = path.parent() {
344        fs::create_dir_all(p)?;
345    }
346    fs::write(path, out)?;
347    Ok(())
348}
349
350fn scratch_file(ext: &str) -> PathBuf {
351    std::env::temp_dir().join(format!("rkqa-{}.{ext}", crate::util::new_id()))
352}
353
354fn ffmpeg_to_file(
355    ffmpeg: &Path,
356    args: &[String],
357    out: &Path,
358    label: &str,
359    tracker: &Tracker,
360) -> Result<Vec<u8>> {
361    let mut all: Vec<String> = ["-hide_banner", "-nostdin", "-v", "error", "-y"]
362        .iter()
363        .map(|s| s.to_string())
364        .collect();
365    all.extend_from_slice(args);
366    all.push(out.to_string_lossy().into());
367    let o = run_owned(
368        &ffmpeg.to_string_lossy(),
369        &all,
370        &RunOptions {
371            timeout: Some(Duration::from_secs(300)),
372            label: label.into(),
373            ..Default::default()
374        },
375        tracker,
376    )?;
377    let bytes = fs::read(out).unwrap_or_default();
378    let _ = fs::remove_file(out);
379    if o.code != Some(0) {
380        return err(format!(
381            "{label} failed ({:?}): {}",
382            o.code,
383            crate::util::tail(&o.stderr, 800)
384        ));
385    }
386    Ok(bytes)
387}
388
389/// Decode any audio ffmpeg reads (FLAC, MP3, AAC, MP4 audio track) to float PCM at its native
390/// rate and channel count. `ffmpeg` is the binary path.
391pub fn decode_audio_ffmpeg(ffmpeg: &Path, input: &Path, tracker: &Tracker) -> Result<Pcm> {
392    let tmp = scratch_file("wav");
393    let args: Vec<String> = vec![
394        "-i".into(),
395        input.to_string_lossy().into(),
396        "-vn".into(),
397        "-map".into(),
398        "0:a:0".into(),
399        "-c:a".into(),
400        "pcm_f32le".into(),
401        "-f".into(),
402        "wav".into(),
403    ];
404    let bytes = ffmpeg_to_file(ffmpeg, &args, &tmp, "ffmpeg decode audio", tracker)?;
405    parse_wav(&bytes)
406}
407
408/// WAV read directly; anything else decoded through `ffmpeg` (an error when not given).
409pub fn read_audio(path: &Path, ffmpeg: Option<&Path>, tracker: &Tracker) -> Result<Pcm> {
410    let b = fs::read(path)?;
411    if b.len() >= 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WAVE" {
412        if let Ok(p) = parse_wav(&b) {
413            return Ok(p);
414        }
415    }
416    match ffmpeg {
417        Some(f) => decode_audio_ffmpeg(f, path, tracker),
418        None => err(format!(
419            "{} is not a decodable WAV and no ffmpeg path was given",
420            path.display()
421        )),
422    }
423}
424
425// ------------------------------------------------------------------ pixels
426
427/// Minimum per-frame standard deviation (largest of the R, G, B channels, 0-255 scale) for a
428/// decoded 64x64 RGB24 video frame to count as non-uniform. GenRight's `framesNonTrivial`
429/// threshold; GenRight pooled all channels, which let a flat non-grey colour pass, so the
430/// deviation here is taken per channel.
431pub const FRAME_STDDEV_MIN: f64 = 4.0;
432/// Minimum channel range (max - min, 0-255) after a 32x32 box downscale for a PNG to count as
433/// non-trivial (GenRight's `pngNonTrivial` threshold).
434pub const IMAGE_RANGE_MIN: u8 = 24;
435/// Edge length video frames are scaled to before [`pixel_stats`].
436pub const FRAME_EDGE: u32 = 64;
437/// Edge length images are box-downscaled to before the range test.
438pub const IMAGE_EDGE: u32 = 32;
439
440/// Statistics of an RGB24 buffer.
441#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
442pub struct PixelStats {
443    /// Mean of every byte.
444    pub mean: f64,
445    /// Largest per-channel population standard deviation: spatial variation, so a flat colour
446    /// scores 0 whatever its hue.
447    pub stddev: f64,
448    /// Per-channel standard deviation (R, G, B).
449    pub channel_stddev: [f64; 3],
450    /// Per-channel max - min.
451    pub range: [u8; 3],
452}
453
454/// Mean, per-channel standard deviation and ranges of RGB24 pixels.
455pub fn pixel_stats(rgb: &[u8]) -> PixelStats {
456    let (mut sum, mut sq) = ([0f64; 3], [0f64; 3]);
457    let (mut lo, mut hi) = ([255u8; 3], [0u8; 3]);
458    let px = rgb.as_chunks::<3>().0;
459    for p in px {
460        for c in 0..3 {
461            let v = p[c];
462            lo[c] = lo[c].min(v);
463            hi[c] = hi[c].max(v);
464            sum[c] += v as f64;
465            sq[c] += (v as f64) * (v as f64);
466        }
467    }
468    let n = px.len().max(1) as f64;
469    let channel_stddev = [0, 1, 2].map(|c| {
470        let m = sum[c] / n;
471        (sq[c] / n - m * m).max(0.0).sqrt()
472    });
473    PixelStats {
474        mean: (sum[0] + sum[1] + sum[2]) / (3.0 * n),
475        stddev: channel_stddev.iter().copied().fold(0.0, f64::max),
476        channel_stddev,
477        range: [0, 1, 2].map(|c| hi[c].saturating_sub(lo[c])),
478    }
479}
480
481/// Decoded-video verdict.
482#[derive(Debug, Clone, PartialEq, Serialize)]
483pub struct FramesReport {
484    pub frames: Vec<PixelStats>,
485    /// At least one frame decoded and every decoded frame has stddev > [`FRAME_STDDEV_MIN`].
486    pub non_trivial: bool,
487}
488
489/// Verdict over already-decoded RGB24 frames of `FRAME_EDGE x FRAME_EDGE` (or any equal size).
490pub fn frames_report(frames: &[Vec<u8>]) -> FramesReport {
491    let stats: Vec<PixelStats> = frames.iter().map(|f| pixel_stats(f)).collect();
492    let non_trivial = !stats.is_empty() && stats.iter().all(|s| s.stddev > FRAME_STDDEV_MIN);
493    FramesReport {
494        frames: stats,
495        non_trivial,
496    }
497}
498
499/// Decode `count` frames of a video (every `count`-th frame from the start, as GenRight does),
500/// scale each to 64x64 RGB24 and require visible variance in all of them. `ffmpeg` is the
501/// binary path. An undecodable file is an error; a decodable flat one is `non_trivial: false`.
502pub fn frames_non_trivial(
503    ffmpeg: &Path,
504    video: &Path,
505    count: usize,
506    tracker: &Tracker,
507) -> Result<FramesReport> {
508    let count = count.max(1);
509    let tmp = scratch_file("rgb");
510    let vf = format!("select='not(mod(n\\,{count}))',scale={FRAME_EDGE}:{FRAME_EDGE}");
511    let args: Vec<String> = [
512        "-i",
513        &video.to_string_lossy(),
514        "-an",
515        "-vf",
516        &vf,
517        "-frames:v",
518        &count.to_string(),
519        "-f",
520        "rawvideo",
521        "-pix_fmt",
522        "rgb24",
523    ]
524    .iter()
525    .map(|s| s.to_string())
526    .collect();
527    let buf = ffmpeg_to_file(ffmpeg, &args, &tmp, "ffmpeg decode frames", tracker)?;
528    let size = (FRAME_EDGE * FRAME_EDGE * 3) as usize;
529    if buf.len() < size {
530        return err(format!("{} decoded no video frames", video.display()));
531    }
532    let frames: Vec<Vec<u8>> = buf.chunks_exact(size).map(<[u8]>::to_vec).collect();
533    Ok(frames_report(&frames))
534}
535
536/// Decoded-image verdict.
537#[derive(Debug, Clone, PartialEq, Serialize)]
538pub struct ImageReport {
539    pub width: u32,
540    pub height: u32,
541    /// Stats of the [`IMAGE_EDGE`] box-downscaled RGB (alpha composited over black).
542    pub stats: PixelStats,
543    /// Every pixel has alpha 0.
544    pub fully_transparent: bool,
545    /// Not fully transparent, and some channel's range exceeds [`IMAGE_RANGE_MIN`].
546    pub non_trivial: bool,
547}
548
549/// Verdict over RGBA8 pixels. Downscaling (area average) first means single-pixel noise or a
550/// one-pixel border does not make a blank image pass.
551pub fn image_report(rgba: &[u8], width: u32, height: u32) -> ImageReport {
552    let (w, h) = (width.max(1) as usize, height.max(1) as usize);
553    let (tw, th) = (w.min(IMAGE_EDGE as usize), h.min(IMAGE_EDGE as usize));
554    let mut acc = vec![[0f64; 4]; tw * th];
555    let mut cnt = vec![0f64; tw * th];
556    let mut any_alpha = false;
557    for y in 0..h {
558        for x in 0..w {
559            let i = (y * w + x) * 4;
560            let Some(px) = rgba.get(i..i + 4) else {
561                continue;
562            };
563            any_alpha |= px[3] > 0;
564            let a = px[3] as f64 / 255.0;
565            let t = (y * th / h) * tw + (x * tw / w);
566            for c in 0..3 {
567                acc[t][c] += px[c] as f64 * a;
568            }
569            cnt[t] += 1.0;
570        }
571    }
572    let small: Vec<u8> = acc
573        .iter()
574        .zip(&cnt)
575        .flat_map(|(a, &n)| (0..3).map(move |c| (a[c] / n.max(1.0)).round() as u8))
576        .collect();
577    let stats = pixel_stats(&small);
578    let fully_transparent = !any_alpha;
579    ImageReport {
580        width,
581        height,
582        stats,
583        fully_transparent,
584        non_trivial: !fully_transparent && stats.range.iter().any(|&r| r > IMAGE_RANGE_MIN),
585    }
586}
587
588/// Decode a PNG (pure Rust) to RGBA8.
589pub fn decode_png(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
590    let bytes = fs::read(path)?;
591    let mut dec = png::Decoder::new(std::io::Cursor::new(bytes));
592    dec.set_transformations(png::Transformations::normalize_to_color8());
593    let mut reader = dec
594        .read_info()
595        .map_err(|e| crate::util::Error(format!("{}: PNG decode: {e}", path.display())))?;
596    let mut buf = vec![0u8; reader.output_buffer_size().unwrap_or(0)];
597    let info = reader
598        .next_frame(&mut buf)
599        .map_err(|e| crate::util::Error(format!("{}: PNG decode: {e}", path.display())))?;
600    buf.truncate(info.buffer_size());
601    let rgba = match info.color_type {
602        png::ColorType::Rgba => buf,
603        png::ColorType::Rgb => buf
604            .as_chunks::<3>()
605            .0
606            .iter()
607            .flat_map(|p| [p[0], p[1], p[2], 255])
608            .collect(),
609        png::ColorType::GrayscaleAlpha => buf
610            .as_chunks::<2>()
611            .0
612            .iter()
613            .flat_map(|p| [p[0], p[0], p[0], p[1]])
614            .collect(),
615        png::ColorType::Grayscale => buf.iter().flat_map(|&g| [g, g, g, 255]).collect(),
616        png::ColorType::Indexed => return err("PNG palette was not expanded"),
617    };
618    Ok((info.width, info.height, rgba))
619}
620
621/// A PNG that decodes and is not blank, uniform or fully transparent (pure Rust).
622pub fn png_non_trivial(path: &Path) -> Result<ImageReport> {
623    let (w, h, rgba) = decode_png(path)?;
624    Ok(image_report(&rgba, w, h))
625}
626
627/// Write a deterministic non-trivial RGB PNG (gradients plus a checkerboard), the pure-Rust
628/// stand-in for ffmpeg `testsrc2` input images.
629pub fn write_test_png(path: &Path, width: u32, height: u32) -> Result<PathBuf> {
630    let (w, h) = (width.max(1), height.max(1));
631    let mut px = Vec::with_capacity((w * h * 3) as usize);
632    for y in 0..h {
633        for x in 0..w {
634            let check = if ((x * 8 / w) + (y * 8 / h)) % 2 == 0 {
635                0
636            } else {
637                96
638            };
639            px.extend_from_slice(&[
640                (x * 255 / w.max(2).saturating_sub(1)) as u8,
641                (y * 255 / h.max(2).saturating_sub(1)) as u8,
642                check,
643            ]);
644        }
645    }
646    write_png_rgb(path, w, h, &px)?;
647    Ok(path.to_path_buf())
648}
649
650/// Write RGB8 pixels as a PNG.
651pub fn write_png_rgb(path: &Path, width: u32, height: u32, rgb: &[u8]) -> Result<()> {
652    if let Some(p) = path.parent() {
653        fs::create_dir_all(p)?;
654    }
655    let file = fs::File::create(path)?;
656    let mut enc = png::Encoder::new(std::io::BufWriter::new(file), width, height);
657    enc.set_color(png::ColorType::Rgb);
658    enc.set_depth(png::BitDepth::Eight);
659    let e = |e: png::EncodingError| crate::util::Error(format!("PNG encode: {e}"));
660    let mut w = enc.write_header().map_err(e)?;
661    w.write_image_data(rgb).map_err(e)?;
662    w.finish().map_err(e)?;
663    Ok(())
664}
665
666/// Find a tool: the settings key (path) first, then `PATH`, then common install directories
667/// (`/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`). Under `rightkit cargo` the environment,
668/// including `PATH`, may be scrubbed, so tests should pass the result explicitly.
669pub fn locate_tool(name: &str, settings: &crate::settings::Settings, key: &str) -> Option<PathBuf> {
670    if let Some(p) = settings.get(key) {
671        let p = PathBuf::from(p);
672        return p.is_file().then_some(p);
673    }
674    let exe = format!("{name}{}", std::env::consts::EXE_SUFFIX);
675    let mut dirs: Vec<PathBuf> = std::env::var_os("PATH")
676        .map(|p| std::env::split_paths(&p).collect())
677        .unwrap_or_default();
678    dirs.extend(["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"].map(PathBuf::from));
679    dirs.into_iter().map(|d| d.join(&exe)).find(|p| p.is_file())
680}
681
682// ------------------------------------------------------- interval detectors (#355)
683
684/// A `[start_s, end_s)` span of the media in seconds, the shape ffmpeg's `*detect` filters report.
685#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
686pub struct Interval {
687    pub start_s: f64,
688    pub end_s: f64,
689}
690
691impl Interval {
692    pub fn duration_s(&self) -> f64 {
693        self.end_s - self.start_s
694    }
695}
696
697/// Maximal runs of `true` in `flags` (unit `i` spans `[i * unit_s, (i + 1) * unit_s)`), kept when
698/// they last at least `min_s`.
699fn flag_runs(flags: &[bool], unit_s: f64, min_s: f64) -> Vec<Interval> {
700    let mut out = vec![];
701    let mut start: Option<usize> = None;
702    for (i, &flag) in flags.iter().chain(std::iter::once(&false)).enumerate() {
703        match (flag, start) {
704            (true, None) => start = Some(i),
705            (false, Some(s)) => {
706                let iv = Interval {
707                    start_s: s as f64 * unit_s,
708                    end_s: i as f64 * unit_s,
709                };
710                if iv.duration_s() + 1e-9 >= min_s {
711                    out.push(iv);
712                }
713                start = None;
714            }
715            _ => {}
716        }
717    }
718    out
719}
720
721/// Silent intervals, as ffmpeg `silencedetect` reports them. A frame (one sample per channel) is
722/// silent when every channel's absolute sample is at or below `threshold_db` dBFS (for example
723/// `-60.0`). Runs shorter than `min_s` seconds are dropped.
724pub fn silence_intervals(pcm: &Pcm, threshold_db: f64, min_s: f64) -> Vec<Interval> {
725    let ch = pcm.channels.max(1) as usize;
726    let limit = 10f64.powf(threshold_db / 20.0) as f32;
727    let flags: Vec<bool> = pcm
728        .samples
729        .chunks(ch)
730        .map(|frame| frame.iter().all(|s| s.abs() <= limit))
731        .collect();
732    flag_runs(&flags, 1.0 / pcm.sample_rate.max(1) as f64, min_s)
733}
734
735/// Clipped runs: maximal runs of at least `min_run` consecutive samples whose absolute value is at
736/// or above `level` (for example `0.999`), counted per channel. Returns the number of runs.
737pub fn clipped_runs(pcm: &Pcm, level: f32, min_run: usize) -> usize {
738    let ch = pcm.channels.max(1) as usize;
739    let mut total = 0;
740    for c in 0..ch {
741        let mut run = 0usize;
742        for s in pcm.samples.iter().skip(c).step_by(ch) {
743            if s.abs() >= level {
744                run += 1;
745            } else {
746                if run >= min_run {
747                    total += 1;
748                }
749                run = 0;
750            }
751        }
752        if run >= min_run {
753            total += 1;
754        }
755    }
756    total
757}
758
759/// Black intervals over decoded RGB24 frames at `fps` frames per second. A frame is black when the
760/// mean of its bytes is at or below `max_mean` (0 to 255). Runs shorter than `min_s` are dropped.
761pub fn black_intervals(frames: &[Vec<u8>], fps: f64, max_mean: f64, min_s: f64) -> Vec<Interval> {
762    if fps <= 0.0 {
763        return vec![];
764    }
765    let flags: Vec<bool> = frames
766        .iter()
767        .map(|f| pixel_stats(f).mean <= max_mean)
768        .collect();
769    flag_runs(&flags, 1.0 / fps, min_s)
770}
771
772/// Mean absolute byte difference of two equal-length buffers; infinity when the lengths differ.
773fn mean_abs_diff(a: &[u8], b: &[u8]) -> f64 {
774    if a.len() != b.len() || a.is_empty() {
775        return f64::INFINITY;
776    }
777    let total: u64 = a
778        .iter()
779        .zip(b)
780        .map(|(x, y)| u64::from(x.abs_diff(*y)))
781        .sum();
782    total as f64 / a.len() as f64
783}
784
785/// Freeze intervals over decoded RGB24 frames at `fps` frames per second. A frame repeats its
786/// predecessor when their mean absolute byte difference is at or below `noise` (0 to 255; `0.0`
787/// means identical). A frozen interval starts at the first frame of the repeat run, and runs
788/// shorter than `min_s` are dropped.
789pub fn freeze_intervals(frames: &[Vec<u8>], fps: f64, noise: f64, min_s: f64) -> Vec<Interval> {
790    if fps <= 0.0 || frames.len() < 2 {
791        return vec![];
792    }
793    let unit = 1.0 / fps;
794    let flags: Vec<bool> = std::iter::once(false)
795        .chain(
796            frames
797                .windows(2)
798                .map(|w| mean_abs_diff(&w[0], &w[1]) <= noise),
799        )
800        .collect();
801    flag_runs(&flags, unit, 0.0)
802        .into_iter()
803        .map(|iv| Interval {
804            start_s: iv.start_s - unit,
805            end_s: iv.end_s,
806        })
807        .filter(|iv| iv.duration_s() + 1e-9 >= min_s)
808        .collect()
809}
810
811#[cfg(test)]
812mod interval_tests {
813    use super::*;
814
815    fn close(a: &[Interval], want: &[(f64, f64)]) -> bool {
816        a.len() == want.len()
817            && a.iter()
818                .zip(want)
819                .all(|(x, (s, e))| (x.start_s - s).abs() < 1e-9 && (x.end_s - e).abs() < 1e-9)
820    }
821
822    #[test]
823    fn silence_runs_shorter_than_the_minimum_are_dropped() {
824        let pcm = Pcm {
825            sample_rate: 10,
826            channels: 1,
827            samples: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0],
828        };
829        let got = silence_intervals(&pcm, -60.0, 0.2);
830        assert!(close(&got, &[(0.0, 0.4), (0.6, 0.9)]), "{got:?}");
831        assert!(silence_intervals(&pcm, -60.0, 0.35).len() == 1);
832    }
833
834    #[test]
835    fn stereo_frame_is_silent_only_when_every_channel_is() {
836        let pcm = Pcm {
837            sample_rate: 4,
838            channels: 2,
839            samples: vec![0.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
840        };
841        let got = silence_intervals(&pcm, -60.0, 0.0);
842        assert!(close(&got, &[(0.25, 1.0)]), "{got:?}");
843    }
844
845    #[test]
846    fn clipped_runs_count_per_channel_and_respect_the_minimum() {
847        let pcm = Pcm {
848            sample_rate: 10,
849            channels: 1,
850            samples: vec![1.0, 1.0, 1.0, 0.1, 1.0, 1.0],
851        };
852        assert_eq!(clipped_runs(&pcm, 0.999, 2), 2);
853        assert_eq!(clipped_runs(&pcm, 0.999, 3), 1);
854    }
855
856    #[test]
857    fn black_frames_form_intervals() {
858        let frames = vec![vec![0u8; 12], vec![200u8; 12], vec![0u8; 12]];
859        let got = black_intervals(&frames, 1.0, 16.0, 0.5);
860        assert!(close(&got, &[(0.0, 1.0), (2.0, 3.0)]), "{got:?}");
861    }
862
863    #[test]
864    fn repeated_frames_form_a_freeze_interval_from_the_first_frame() {
865        let a = vec![10u8; 12];
866        let b = vec![200u8; 12];
867        let frames = vec![a.clone(), a.clone(), a.clone(), b.clone()];
868        let got = freeze_intervals(&frames, 1.0, 0.0, 0.5);
869        assert!(close(&got, &[(0.0, 3.0)]), "{got:?}");
870        assert!(freeze_intervals(&[a.clone(), b.clone()], 1.0, 0.0, 0.0).is_empty());
871    }
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877
878    #[test]
879    fn reference_wav_round_trips_through_stats() {
880        let p = std::env::temp_dir().join(format!("rkqa-{}.wav", crate::util::new_id()));
881        write_reference_wav(&p, 1.0).unwrap();
882        let s = wav_stats(&p).unwrap();
883        assert_eq!(s["rate"], 16000.0);
884        assert_eq!(s["silent"], false);
885        assert!((s["duration_s"].as_f64().unwrap() - 1.0).abs() < 0.01);
886        assert!(png_size(&p).is_err());
887        let info = wav_info(&p).unwrap();
888        assert_eq!(
889            (info.channels, info.sample_rate, info.bits, info.float),
890            (1, 16000, 16, false)
891        );
892        assert_eq!(info.frames, 16000);
893        assert!(info.peak_dbfs < 0.0 && info.peak_dbfs > -6.0, "{info:?}");
894        assert!(info.rms_dbfs < info.peak_dbfs);
895        let _ = fs::remove_file(p);
896    }
897
898    fn tmp(ext: &str) -> PathBuf {
899        scratch_file(ext)
900    }
901
902    #[test]
903    fn wav_decodes_float_24bit_extensible_and_streamed_headers() {
904        let pcm = Pcm {
905            sample_rate: 48000,
906            channels: 2,
907            samples: (0..9600)
908                .map(|i| ((i % 200) as f32 / 100.0 - 1.0) * 0.5)
909                .collect(),
910        };
911        let p = tmp("wav");
912        write_wav_f32(&p, &pcm).unwrap();
913        let back = read_wav(&p).unwrap();
914        assert_eq!(back, pcm);
915        let info = wav_info(&p).unwrap();
916        assert!(info.float && info.bits == 32 && info.channels == 2);
917        assert!((info.duration_s - 0.1).abs() < 1e-9);
918        assert!((info.peak_dbfs - dbfs(0.5)).abs() < 0.01);
919        let _ = fs::remove_file(&p);
920
921        // 24-bit WAVE_FORMAT_EXTENSIBLE, data size 0xFFFFFFFF (ffmpeg writing to a pipe).
922        let mut fmt = Vec::new();
923        fmt.extend_from_slice(&0xFFFEu16.to_le_bytes());
924        fmt.extend_from_slice(&1u16.to_le_bytes());
925        fmt.extend_from_slice(&8000u32.to_le_bytes());
926        fmt.extend_from_slice(&24000u32.to_le_bytes());
927        fmt.extend_from_slice(&3u16.to_le_bytes());
928        fmt.extend_from_slice(&24u16.to_le_bytes());
929        fmt.extend_from_slice(&22u16.to_le_bytes());
930        fmt.extend_from_slice(&24u16.to_le_bytes());
931        fmt.extend_from_slice(&4u32.to_le_bytes());
932        fmt.extend_from_slice(&1u16.to_le_bytes()); // SubFormat: PCM
933        fmt.extend_from_slice(&[0u8; 14]);
934        let mut b = b"RIFF\xff\xff\xff\xffWAVEfmt ".to_vec();
935        b.extend_from_slice(&(fmt.len() as u32).to_le_bytes());
936        b.extend_from_slice(&fmt);
937        b.extend_from_slice(b"data\xff\xff\xff\xff");
938        for v in [0x400000i32, -0x400000, 0x7fffff] {
939            b.extend_from_slice(&v.to_le_bytes()[..3]);
940        }
941        let p = parse_wav(&b).unwrap();
942        assert_eq!(p.samples.len(), 3);
943        assert!((p.samples[0] - 0.5).abs() < 1e-6 && (p.samples[1] + 0.5).abs() < 1e-6);
944        assert!(parse_wav(b"RIFF....WAVE").is_err());
945    }
946
947    #[test]
948    fn image_report_rejects_flat_transparent_and_speckled() {
949        let flat: Vec<u8> = [40u8, 80, 120, 255].repeat(64 * 64);
950        let r = image_report(&flat, 64, 64);
951        assert!(
952            !r.non_trivial && !r.fully_transparent && r.stats.stddev < 0.5,
953            "{r:?}"
954        );
955        let clear = vec![0u8; 64 * 64 * 4];
956        assert!(image_report(&clear, 64, 64).fully_transparent);
957        assert!(!image_report(&clear, 64, 64).non_trivial);
958        // One white pixel on black: a 32x32 area average dilutes it below the threshold.
959        let mut speck = [0u8, 0, 0, 255].repeat(256 * 256);
960        speck[..4].copy_from_slice(&[255, 255, 255, 255]);
961        assert!(!image_report(&speck, 256, 256).non_trivial);
962        let grad: Vec<u8> = (0..64 * 64)
963            .flat_map(|i| [(i % 64 * 4) as u8, 0, 0, 255])
964            .collect();
965        let g = image_report(&grad, 64, 64);
966        assert!(g.non_trivial && g.stats.range[0] > 200, "{g:?}");
967    }
968
969    #[test]
970    fn png_round_trip_and_non_trivial_verdicts() {
971        let good = write_test_png(&tmp("png"), 96, 64).unwrap();
972        let r = png_non_trivial(&good).unwrap();
973        assert!(r.non_trivial && (r.width, r.height) == (96, 64), "{r:?}");
974        assert_eq!(png_size(&good).unwrap()["width"], 96);
975        let flat = tmp("png");
976        write_png_rgb(&flat, 16, 16, &[200u8; 16 * 16 * 3]).unwrap();
977        assert!(!png_non_trivial(&flat).unwrap().non_trivial);
978        let junk = tmp("png");
979        fs::write(&junk, b"\x89PNG\r\n\x1a\nnot really").unwrap();
980        assert!(
981            png_non_trivial(&junk).is_err(),
982            "undecodable PNG is an error, not trivial"
983        );
984        for p in [good, flat, junk] {
985            let _ = fs::remove_file(p);
986        }
987    }
988
989    #[test]
990    fn frames_report_requires_every_frame_to_vary() {
991        let edge = (FRAME_EDGE * FRAME_EDGE * 3) as usize;
992        let busy: Vec<u8> = (0..edge).map(|i| (i * 37 % 256) as u8).collect();
993        let flat = vec![16u8; edge];
994        assert!(frames_report(&[busy.clone(), busy.clone()]).non_trivial);
995        assert!(!frames_report(&[busy, flat]).non_trivial);
996        assert!(!frames_report(&[]).non_trivial);
997    }
998
999    #[test]
1000    fn read_audio_without_ffmpeg_needs_wav() {
1001        let p = tmp("flac");
1002        fs::write(&p, b"fLaC....").unwrap();
1003        let e = read_audio(&p, None, &Tracker::new()).unwrap_err();
1004        assert!(e.0.contains("no ffmpeg path"), "{e}");
1005        let _ = fs::remove_file(p);
1006    }
1007}