1use 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
29pub 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
41pub 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
86pub 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#[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#[derive(Debug, Clone, PartialEq, Serialize)]
137pub struct WavInfo {
138 pub channels: u16,
139 pub sample_rate: u32,
140 pub bits: u16,
141 pub float: bool,
143 pub frames: u64,
144 pub duration_s: f64,
145 pub peak_dbfs: f64,
147 pub rms_dbfs: f64,
149 pub silent: bool,
151}
152
153pub 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
158pub 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]]); }
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
243pub 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 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
278pub 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
343pub 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
362pub 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
379pub const FRAME_STDDEV_MIN: f64 = 4.0;
386pub const IMAGE_RANGE_MIN: u8 = 24;
389pub const FRAME_EDGE: u32 = 64;
391pub const IMAGE_EDGE: u32 = 32;
393
394#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
396pub struct PixelStats {
397 pub mean: f64,
399 pub stddev: f64,
402 pub channel_stddev: [f64; 3],
404 pub range: [u8; 3],
406}
407
408pub 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#[derive(Debug, Clone, PartialEq, Serialize)]
437pub struct FramesReport {
438 pub frames: Vec<PixelStats>,
439 pub non_trivial: bool,
441}
442
443pub 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
453pub 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#[derive(Debug, Clone, PartialEq, Serialize)]
492pub struct ImageReport {
493 pub width: u32,
494 pub height: u32,
495 pub stats: PixelStats,
497 pub fully_transparent: bool,
499 pub non_trivial: bool,
501}
502
503pub 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
542pub 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
575pub 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
581pub 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
604pub 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
620pub 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 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()); 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 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}