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