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