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
80static FFPROBE: std::sync::RwLock<Option<PathBuf>> = std::sync::RwLock::new(None);
81
82pub fn set_ffprobe_bin(path: impl Into<PathBuf>) {
85 if let Ok(mut slot) = FFPROBE.write() {
86 *slot = Some(path.into());
87 }
88}
89
90pub 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
103pub fn ffprobe(path: &Path, tracker: &Tracker) -> Result<Value> {
105 ffprobe_with(&ffprobe_bin(), path, tracker)
106}
107
108pub 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#[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#[derive(Debug, Clone, PartialEq, Serialize)]
159pub struct WavInfo {
160 pub channels: u16,
161 pub sample_rate: u32,
162 pub bits: u16,
163 pub float: bool,
165 pub frames: u64,
166 pub duration_s: f64,
167 pub peak_dbfs: f64,
169 pub rms_dbfs: f64,
171 pub silent: bool,
173}
174
175pub 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
180pub 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]]); }
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
265pub 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 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
300pub 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
365pub 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
384pub 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
401pub const FRAME_STDDEV_MIN: f64 = 4.0;
408pub const IMAGE_RANGE_MIN: u8 = 24;
411pub const FRAME_EDGE: u32 = 64;
413pub const IMAGE_EDGE: u32 = 32;
415
416#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
418pub struct PixelStats {
419 pub mean: f64,
421 pub stddev: f64,
424 pub channel_stddev: [f64; 3],
426 pub range: [u8; 3],
428}
429
430pub 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#[derive(Debug, Clone, PartialEq, Serialize)]
459pub struct FramesReport {
460 pub frames: Vec<PixelStats>,
461 pub non_trivial: bool,
463}
464
465pub 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
475pub 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#[derive(Debug, Clone, PartialEq, Serialize)]
514pub struct ImageReport {
515 pub width: u32,
516 pub height: u32,
517 pub stats: PixelStats,
519 pub fully_transparent: bool,
521 pub non_trivial: bool,
523}
524
525pub 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
564pub 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
597pub 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
603pub 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
626pub 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
642pub 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 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()); 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 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}