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 png_size_bytes(&b, path)
22}
23
24pub fn png_size_bytes(b: &[u8], path: &Path) -> Result<Value> {
26 if b.len() < 24 || b[..8] != [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a] {
27 return err(format!("{} is not a PNG", path.display()));
28 }
29 Ok(
30 json!({"width": u32::from_be_bytes(b[16..20].try_into().unwrap()), "height": u32::from_be_bytes(b[20..24].try_into().unwrap())}),
31 )
32}
33
34pub fn wav_stats(path: &Path) -> Result<Value> {
38 let w = wav_info(path)?;
39 Ok(json!({
40 "channels": w.channels as f64, "rate": w.sample_rate as f64, "sample_rate": w.sample_rate,
41 "bits": w.bits, "float": w.float, "frames": w.frames, "duration_s": w.duration_s,
42 "rms_db": w.rms_dbfs, "peak_db": w.peak_dbfs, "silent": w.silent,
43 }))
44}
45
46pub fn write_reference_wav(path: &Path, seconds: f64) -> Result<()> {
48 let rate = 16000f64;
49 let n = (rate * seconds) as usize;
50 let mut data = Vec::with_capacity(n * 2);
51 let (mut s, mut lp) = (12345i64, 0f64);
52 for i in 0..n {
53 s = (s * 1103515245 + 12345) & 0x7fffffff;
54 let rnd = s as f64 / 0x40000000 as f64 - 1.0;
55 let t = i as f64;
56 let env = 0.4 + 0.6 * (2.0 * std::f64::consts::PI * 3.3 * t / rate).sin().abs();
57 lp = 0.85 * lp + 0.15 * rnd;
58 let v = env
59 * (0.35 * (2.0 * std::f64::consts::PI * 190.0 * t / rate).sin()
60 + 0.2 * (2.0 * std::f64::consts::PI * 380.0 * t / rate + 1.0).sin()
61 + 0.25 * lp);
62 data.extend_from_slice(&((v.clamp(-1.0, 1.0) * 32000.0) as i16).to_le_bytes());
63 }
64 let mut out = Vec::with_capacity(44 + data.len());
65 out.extend_from_slice(b"RIFF");
66 out.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
67 out.extend_from_slice(b"WAVEfmt ");
68 out.extend_from_slice(&16u32.to_le_bytes());
69 out.extend_from_slice(&1u16.to_le_bytes());
70 out.extend_from_slice(&1u16.to_le_bytes());
71 out.extend_from_slice(&16000u32.to_le_bytes());
72 out.extend_from_slice(&32000u32.to_le_bytes());
73 out.extend_from_slice(&2u16.to_le_bytes());
74 out.extend_from_slice(&16u16.to_le_bytes());
75 out.extend_from_slice(b"data");
76 out.extend_from_slice(&(data.len() as u32).to_le_bytes());
77 out.extend_from_slice(&data);
78 if let Some(p) = path.parent() {
79 fs::create_dir_all(p)?;
80 }
81 fs::write(path, out)?;
82 Ok(())
83}
84
85static FFPROBE: std::sync::RwLock<Option<PathBuf>> = std::sync::RwLock::new(None);
86
87pub fn set_ffprobe_bin(path: impl Into<PathBuf>) {
90 if let Ok(mut slot) = FFPROBE.write() {
91 *slot = Some(path.into());
92 }
93}
94
95pub fn ffprobe_bin() -> String {
97 if let Ok(v) =
98 std::env::var("RIGHTKIT_QA_FFPROBE").or_else(|_| std::env::var("GENRIGHT_FFPROBE"))
99 {
100 return v;
101 }
102 if let Some(p) = FFPROBE.read().ok().and_then(|slot| slot.clone()) {
103 return p.to_string_lossy().into_owned();
104 }
105 "ffprobe".into()
106}
107
108pub fn ffprobe(path: &Path, tracker: &Tracker) -> Result<Value> {
110 ffprobe_with(&ffprobe_bin(), path, tracker)
111}
112
113pub fn ffprobe_with(bin: &str, path: &Path, tracker: &Tracker) -> Result<Value> {
115 ffprobe_run(bin, path, tracker, false)
116}
117
118pub fn ffprobe_count_frames_with(bin: &str, path: &Path, tracker: &Tracker) -> Result<Value> {
121 ffprobe_run(bin, path, tracker, true)
122}
123
124pub fn ffprobe_count_frames(path: &Path, tracker: &Tracker) -> Result<Value> {
126 ffprobe_count_frames_with(&ffprobe_bin(), path, tracker)
127}
128
129fn ffprobe_run(bin: &str, path: &Path, tracker: &Tracker, count_frames: bool) -> Result<Value> {
130 let mut args: Vec<String> = vec![
131 "-v".into(),
132 "quiet".into(),
133 "-print_format".into(),
134 "json".into(),
135 "-show_format".into(),
136 "-show_streams".into(),
137 ];
138 if count_frames {
139 args.push("-count_frames".into());
140 }
141 args.push(path.to_string_lossy().into());
142 let out = run_owned(
143 bin,
144 &args,
145 &RunOptions {
146 timeout: Some(Duration::from_secs(if count_frames { 600 } else { 60 })),
147 label: "ffprobe".into(),
148 ..Default::default()
149 },
150 tracker,
151 )?;
152 if out.code != Some(0) {
153 return err(format!(
154 "ffprobe failed ({:?}): {}",
155 out.code,
156 crate::util::tail(&out.stderr, 500)
157 ));
158 }
159 Ok(serde_json::from_str(&out.stdout)?)
160}
161
162#[derive(Debug, Clone, PartialEq)]
166pub struct Pcm {
167 pub sample_rate: u32,
168 pub channels: u16,
169 pub samples: Vec<f32>,
170}
171
172impl Pcm {
173 pub fn frames(&self) -> usize {
174 self.samples.len() / self.channels.max(1) as usize
175 }
176 pub fn duration_s(&self) -> f64 {
177 self.frames() as f64 / self.sample_rate.max(1) as f64
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Serialize)]
183pub struct WavInfo {
184 pub channels: u16,
185 pub sample_rate: u32,
186 pub bits: u16,
187 pub float: bool,
189 pub frames: u64,
190 pub duration_s: f64,
191 pub peak_dbfs: f64,
193 pub rms_dbfs: f64,
195 pub silent: bool,
197}
198
199pub fn read_wav(path: &Path) -> Result<Pcm> {
201 parse_wav(&fs::read(path)?).map_err(|e| crate::util::Error(format!("{}: {e}", path.display())))
202}
203
204pub fn parse_wav(b: &[u8]) -> Result<Pcm> {
206 if b.len() < 12 || &b[0..4] != b"RIFF" || &b[8..12] != b"WAVE" {
207 return err("not a RIFF/WAVE file");
208 }
209 let (mut off, mut fmt, mut data) = (12usize, None, None);
210 while off + 8 <= b.len() {
211 let id = &b[off..off + 4];
212 let mut size = u32::from_le_bytes(b[off + 4..off + 8].try_into().unwrap()) as usize;
213 if id == b"data" && (size == 0 || size == u32::MAX as usize) {
214 size = b.len() - (off + 8);
215 }
216 let end = (off + 8).saturating_add(size).min(b.len());
217 if id == b"fmt " {
218 fmt = Some(&b[off + 8..end]);
219 }
220 if id == b"data" {
221 data = Some(&b[off + 8..end]);
222 }
223 off = off.saturating_add(8 + size + (size % 2));
224 }
225 let (Some(fmt), Some(data)) = (fmt, data) else {
226 return err("WAV is missing fmt or data chunk");
227 };
228 if fmt.len() < 16 {
229 return err("WAV fmt chunk too short");
230 }
231 let mut tag = u16::from_le_bytes([fmt[0], fmt[1]]);
232 if tag == 0xFFFE && fmt.len() >= 26 {
233 tag = u16::from_le_bytes([fmt[24], fmt[25]]); }
235 let channels = u16::from_le_bytes([fmt[2], fmt[3]]);
236 let sample_rate = u32::from_le_bytes(fmt[4..8].try_into().unwrap());
237 let bits = u16::from_le_bytes([fmt[14], fmt[15]]);
238 if channels == 0 || sample_rate == 0 {
239 return err("WAV declares zero channels or zero sample rate");
240 }
241 let width = (bits as usize).div_ceil(8);
242 let usable = data.len() - data.len() % (width.max(1) * channels as usize);
243 let data = &data[..usable];
244 let samples: Vec<f32> = match (tag, bits) {
245 (1, 8) => data.iter().map(|&v| (v as f32 - 128.0) / 128.0).collect(),
246 (1, 16) => data
247 .as_chunks::<2>()
248 .0
249 .iter()
250 .map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
251 .collect(),
252 (1, 24) => data
253 .as_chunks::<3>()
254 .0
255 .iter()
256 .map(|c| (i32::from_le_bytes([0, c[0], c[1], c[2]]) >> 8) as f32 / 8_388_608.0)
257 .collect(),
258 (1, 32) => data
259 .as_chunks::<4>()
260 .0
261 .iter()
262 .map(|c| (i32::from_le_bytes(*c) as f64 / 2_147_483_648.0) as f32)
263 .collect(),
264 (3, 32) => data
265 .as_chunks::<4>()
266 .0
267 .iter()
268 .map(|c| f32::from_le_bytes(*c))
269 .collect(),
270 (3, 64) => data
271 .as_chunks::<8>()
272 .0
273 .iter()
274 .map(|c| f64::from_le_bytes(*c) as f32)
275 .collect(),
276 _ => {
277 return err(format!(
278 "unsupported WAV encoding: format {tag}, {bits} bits"
279 ))
280 }
281 };
282 Ok(Pcm {
283 sample_rate,
284 channels,
285 samples,
286 })
287}
288
289pub fn wav_info(path: &Path) -> Result<WavInfo> {
291 let b = fs::read(path)?;
292 let pcm = parse_wav(&b).map_err(|e| crate::util::Error(format!("{}: {e}", path.display())))?;
293 let fmt_at = b.windows(4).position(|w| w == b"fmt ").unwrap_or(12) + 8;
295 let mut tag = u16::from_le_bytes([b[fmt_at], b[fmt_at + 1]]);
296 if tag == 0xFFFE && b.len() > fmt_at + 25 {
297 tag = u16::from_le_bytes([b[fmt_at + 24], b[fmt_at + 25]]);
298 }
299 let bits = u16::from_le_bytes([b[fmt_at + 14], b[fmt_at + 15]]);
300 let (mut sum_sq, mut peak) = (0f64, 0f64);
301 for &v in &pcm.samples {
302 let v = v as f64;
303 sum_sq += v * v;
304 peak = peak.max(v.abs());
305 }
306 let rms = (sum_sq / pcm.samples.len().max(1) as f64).sqrt();
307 Ok(WavInfo {
308 channels: pcm.channels,
309 sample_rate: pcm.sample_rate,
310 bits,
311 float: tag == 3,
312 frames: pcm.frames() as u64,
313 duration_s: pcm.duration_s(),
314 peak_dbfs: dbfs(peak),
315 rms_dbfs: dbfs(rms),
316 silent: rms < 0.0001,
317 })
318}
319
320fn dbfs(v: f64) -> f64 {
321 20.0 * (if v > 0.0 { v } else { 1e-9 }).log10()
322}
323
324pub fn write_wav_f32(path: &Path, pcm: &Pcm) -> Result<()> {
326 let data_len = (pcm.samples.len() * 4) as u32;
327 let mut out = Vec::with_capacity(44 + data_len as usize);
328 out.extend_from_slice(b"RIFF");
329 out.extend_from_slice(&(36 + data_len).to_le_bytes());
330 out.extend_from_slice(b"WAVEfmt ");
331 out.extend_from_slice(&16u32.to_le_bytes());
332 out.extend_from_slice(&3u16.to_le_bytes());
333 out.extend_from_slice(&pcm.channels.to_le_bytes());
334 out.extend_from_slice(&pcm.sample_rate.to_le_bytes());
335 out.extend_from_slice(&(pcm.sample_rate * pcm.channels as u32 * 4).to_le_bytes());
336 out.extend_from_slice(&(pcm.channels * 4).to_le_bytes());
337 out.extend_from_slice(&32u16.to_le_bytes());
338 out.extend_from_slice(b"data");
339 out.extend_from_slice(&data_len.to_le_bytes());
340 for s in &pcm.samples {
341 out.extend_from_slice(&s.to_le_bytes());
342 }
343 if let Some(p) = path.parent() {
344 fs::create_dir_all(p)?;
345 }
346 fs::write(path, out)?;
347 Ok(())
348}
349
350fn scratch_file(ext: &str) -> PathBuf {
351 std::env::temp_dir().join(format!("rkqa-{}.{ext}", crate::util::new_id()))
352}
353
354fn ffmpeg_to_file(
355 ffmpeg: &Path,
356 args: &[String],
357 out: &Path,
358 label: &str,
359 tracker: &Tracker,
360) -> Result<Vec<u8>> {
361 let mut all: Vec<String> = ["-hide_banner", "-nostdin", "-v", "error", "-y"]
362 .iter()
363 .map(|s| s.to_string())
364 .collect();
365 all.extend_from_slice(args);
366 all.push(out.to_string_lossy().into());
367 let o = run_owned(
368 &ffmpeg.to_string_lossy(),
369 &all,
370 &RunOptions {
371 timeout: Some(Duration::from_secs(300)),
372 label: label.into(),
373 ..Default::default()
374 },
375 tracker,
376 )?;
377 let bytes = fs::read(out).unwrap_or_default();
378 let _ = fs::remove_file(out);
379 if o.code != Some(0) {
380 return err(format!(
381 "{label} failed ({:?}): {}",
382 o.code,
383 crate::util::tail(&o.stderr, 800)
384 ));
385 }
386 Ok(bytes)
387}
388
389pub fn decode_audio_ffmpeg(ffmpeg: &Path, input: &Path, tracker: &Tracker) -> Result<Pcm> {
392 let tmp = scratch_file("wav");
393 let args: Vec<String> = vec![
394 "-i".into(),
395 input.to_string_lossy().into(),
396 "-vn".into(),
397 "-map".into(),
398 "0:a:0".into(),
399 "-c:a".into(),
400 "pcm_f32le".into(),
401 "-f".into(),
402 "wav".into(),
403 ];
404 let bytes = ffmpeg_to_file(ffmpeg, &args, &tmp, "ffmpeg decode audio", tracker)?;
405 parse_wav(&bytes)
406}
407
408pub fn read_audio(path: &Path, ffmpeg: Option<&Path>, tracker: &Tracker) -> Result<Pcm> {
410 let b = fs::read(path)?;
411 if b.len() >= 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WAVE" {
412 if let Ok(p) = parse_wav(&b) {
413 return Ok(p);
414 }
415 }
416 match ffmpeg {
417 Some(f) => decode_audio_ffmpeg(f, path, tracker),
418 None => err(format!(
419 "{} is not a decodable WAV and no ffmpeg path was given",
420 path.display()
421 )),
422 }
423}
424
425pub const FRAME_STDDEV_MIN: f64 = 4.0;
432pub const IMAGE_RANGE_MIN: u8 = 24;
435pub const FRAME_EDGE: u32 = 64;
437pub const IMAGE_EDGE: u32 = 32;
439
440#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
442pub struct PixelStats {
443 pub mean: f64,
445 pub stddev: f64,
448 pub channel_stddev: [f64; 3],
450 pub range: [u8; 3],
452}
453
454pub fn pixel_stats(rgb: &[u8]) -> PixelStats {
456 let (mut sum, mut sq) = ([0f64; 3], [0f64; 3]);
457 let (mut lo, mut hi) = ([255u8; 3], [0u8; 3]);
458 let px = rgb.as_chunks::<3>().0;
459 for p in px {
460 for c in 0..3 {
461 let v = p[c];
462 lo[c] = lo[c].min(v);
463 hi[c] = hi[c].max(v);
464 sum[c] += v as f64;
465 sq[c] += (v as f64) * (v as f64);
466 }
467 }
468 let n = px.len().max(1) as f64;
469 let channel_stddev = [0, 1, 2].map(|c| {
470 let m = sum[c] / n;
471 (sq[c] / n - m * m).max(0.0).sqrt()
472 });
473 PixelStats {
474 mean: (sum[0] + sum[1] + sum[2]) / (3.0 * n),
475 stddev: channel_stddev.iter().copied().fold(0.0, f64::max),
476 channel_stddev,
477 range: [0, 1, 2].map(|c| hi[c].saturating_sub(lo[c])),
478 }
479}
480
481#[derive(Debug, Clone, PartialEq, Serialize)]
483pub struct FramesReport {
484 pub frames: Vec<PixelStats>,
485 pub non_trivial: bool,
487}
488
489pub fn frames_report(frames: &[Vec<u8>]) -> FramesReport {
491 let stats: Vec<PixelStats> = frames.iter().map(|f| pixel_stats(f)).collect();
492 let non_trivial = !stats.is_empty() && stats.iter().all(|s| s.stddev > FRAME_STDDEV_MIN);
493 FramesReport {
494 frames: stats,
495 non_trivial,
496 }
497}
498
499pub fn frames_non_trivial(
503 ffmpeg: &Path,
504 video: &Path,
505 count: usize,
506 tracker: &Tracker,
507) -> Result<FramesReport> {
508 let count = count.max(1);
509 let tmp = scratch_file("rgb");
510 let vf = format!("select='not(mod(n\\,{count}))',scale={FRAME_EDGE}:{FRAME_EDGE}");
511 let args: Vec<String> = [
512 "-i",
513 &video.to_string_lossy(),
514 "-an",
515 "-vf",
516 &vf,
517 "-frames:v",
518 &count.to_string(),
519 "-f",
520 "rawvideo",
521 "-pix_fmt",
522 "rgb24",
523 ]
524 .iter()
525 .map(|s| s.to_string())
526 .collect();
527 let buf = ffmpeg_to_file(ffmpeg, &args, &tmp, "ffmpeg decode frames", tracker)?;
528 let size = (FRAME_EDGE * FRAME_EDGE * 3) as usize;
529 if buf.len() < size {
530 return err(format!("{} decoded no video frames", video.display()));
531 }
532 let frames: Vec<Vec<u8>> = buf.chunks_exact(size).map(<[u8]>::to_vec).collect();
533 Ok(frames_report(&frames))
534}
535
536#[derive(Debug, Clone, PartialEq, Serialize)]
538pub struct ImageReport {
539 pub width: u32,
540 pub height: u32,
541 pub stats: PixelStats,
543 pub fully_transparent: bool,
545 pub non_trivial: bool,
547}
548
549pub fn image_report(rgba: &[u8], width: u32, height: u32) -> ImageReport {
552 let (w, h) = (width.max(1) as usize, height.max(1) as usize);
553 let (tw, th) = (w.min(IMAGE_EDGE as usize), h.min(IMAGE_EDGE as usize));
554 let mut acc = vec![[0f64; 4]; tw * th];
555 let mut cnt = vec![0f64; tw * th];
556 let mut any_alpha = false;
557 for y in 0..h {
558 for x in 0..w {
559 let i = (y * w + x) * 4;
560 let Some(px) = rgba.get(i..i + 4) else {
561 continue;
562 };
563 any_alpha |= px[3] > 0;
564 let a = px[3] as f64 / 255.0;
565 let t = (y * th / h) * tw + (x * tw / w);
566 for c in 0..3 {
567 acc[t][c] += px[c] as f64 * a;
568 }
569 cnt[t] += 1.0;
570 }
571 }
572 let small: Vec<u8> = acc
573 .iter()
574 .zip(&cnt)
575 .flat_map(|(a, &n)| (0..3).map(move |c| (a[c] / n.max(1.0)).round() as u8))
576 .collect();
577 let stats = pixel_stats(&small);
578 let fully_transparent = !any_alpha;
579 ImageReport {
580 width,
581 height,
582 stats,
583 fully_transparent,
584 non_trivial: !fully_transparent && stats.range.iter().any(|&r| r > IMAGE_RANGE_MIN),
585 }
586}
587
588pub fn decode_png(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
590 let bytes = fs::read(path)?;
591 let mut dec = png::Decoder::new(std::io::Cursor::new(bytes));
592 dec.set_transformations(png::Transformations::normalize_to_color8());
593 let mut reader = dec
594 .read_info()
595 .map_err(|e| crate::util::Error(format!("{}: PNG decode: {e}", path.display())))?;
596 let mut buf = vec![0u8; reader.output_buffer_size().unwrap_or(0)];
597 let info = reader
598 .next_frame(&mut buf)
599 .map_err(|e| crate::util::Error(format!("{}: PNG decode: {e}", path.display())))?;
600 buf.truncate(info.buffer_size());
601 let rgba = match info.color_type {
602 png::ColorType::Rgba => buf,
603 png::ColorType::Rgb => buf
604 .as_chunks::<3>()
605 .0
606 .iter()
607 .flat_map(|p| [p[0], p[1], p[2], 255])
608 .collect(),
609 png::ColorType::GrayscaleAlpha => buf
610 .as_chunks::<2>()
611 .0
612 .iter()
613 .flat_map(|p| [p[0], p[0], p[0], p[1]])
614 .collect(),
615 png::ColorType::Grayscale => buf.iter().flat_map(|&g| [g, g, g, 255]).collect(),
616 png::ColorType::Indexed => return err("PNG palette was not expanded"),
617 };
618 Ok((info.width, info.height, rgba))
619}
620
621pub fn png_non_trivial(path: &Path) -> Result<ImageReport> {
623 let (w, h, rgba) = decode_png(path)?;
624 Ok(image_report(&rgba, w, h))
625}
626
627pub fn write_test_png(path: &Path, width: u32, height: u32) -> Result<PathBuf> {
630 let (w, h) = (width.max(1), height.max(1));
631 let mut px = Vec::with_capacity((w * h * 3) as usize);
632 for y in 0..h {
633 for x in 0..w {
634 let check = if ((x * 8 / w) + (y * 8 / h)) % 2 == 0 {
635 0
636 } else {
637 96
638 };
639 px.extend_from_slice(&[
640 (x * 255 / w.max(2).saturating_sub(1)) as u8,
641 (y * 255 / h.max(2).saturating_sub(1)) as u8,
642 check,
643 ]);
644 }
645 }
646 write_png_rgb(path, w, h, &px)?;
647 Ok(path.to_path_buf())
648}
649
650pub fn write_png_rgb(path: &Path, width: u32, height: u32, rgb: &[u8]) -> Result<()> {
652 if let Some(p) = path.parent() {
653 fs::create_dir_all(p)?;
654 }
655 let file = fs::File::create(path)?;
656 let mut enc = png::Encoder::new(std::io::BufWriter::new(file), width, height);
657 enc.set_color(png::ColorType::Rgb);
658 enc.set_depth(png::BitDepth::Eight);
659 let e = |e: png::EncodingError| crate::util::Error(format!("PNG encode: {e}"));
660 let mut w = enc.write_header().map_err(e)?;
661 w.write_image_data(rgb).map_err(e)?;
662 w.finish().map_err(e)?;
663 Ok(())
664}
665
666pub fn locate_tool(name: &str, settings: &crate::settings::Settings, key: &str) -> Option<PathBuf> {
670 if let Some(p) = settings.get(key) {
671 let p = PathBuf::from(p);
672 return p.is_file().then_some(p);
673 }
674 let exe = format!("{name}{}", std::env::consts::EXE_SUFFIX);
675 let mut dirs: Vec<PathBuf> = std::env::var_os("PATH")
676 .map(|p| std::env::split_paths(&p).collect())
677 .unwrap_or_default();
678 dirs.extend(["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"].map(PathBuf::from));
679 dirs.into_iter().map(|d| d.join(&exe)).find(|p| p.is_file())
680}
681
682#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
686pub struct Interval {
687 pub start_s: f64,
688 pub end_s: f64,
689}
690
691impl Interval {
692 pub fn duration_s(&self) -> f64 {
693 self.end_s - self.start_s
694 }
695}
696
697fn flag_runs(flags: &[bool], unit_s: f64, min_s: f64) -> Vec<Interval> {
700 let mut out = vec![];
701 let mut start: Option<usize> = None;
702 for (i, &flag) in flags.iter().chain(std::iter::once(&false)).enumerate() {
703 match (flag, start) {
704 (true, None) => start = Some(i),
705 (false, Some(s)) => {
706 let iv = Interval {
707 start_s: s as f64 * unit_s,
708 end_s: i as f64 * unit_s,
709 };
710 if iv.duration_s() + 1e-9 >= min_s {
711 out.push(iv);
712 }
713 start = None;
714 }
715 _ => {}
716 }
717 }
718 out
719}
720
721pub fn silence_intervals(pcm: &Pcm, threshold_db: f64, min_s: f64) -> Vec<Interval> {
725 let ch = pcm.channels.max(1) as usize;
726 let limit = 10f64.powf(threshold_db / 20.0) as f32;
727 let flags: Vec<bool> = pcm
728 .samples
729 .chunks(ch)
730 .map(|frame| frame.iter().all(|s| s.abs() <= limit))
731 .collect();
732 flag_runs(&flags, 1.0 / pcm.sample_rate.max(1) as f64, min_s)
733}
734
735pub fn clipped_runs(pcm: &Pcm, level: f32, min_run: usize) -> usize {
738 let ch = pcm.channels.max(1) as usize;
739 let mut total = 0;
740 for c in 0..ch {
741 let mut run = 0usize;
742 for s in pcm.samples.iter().skip(c).step_by(ch) {
743 if s.abs() >= level {
744 run += 1;
745 } else {
746 if run >= min_run {
747 total += 1;
748 }
749 run = 0;
750 }
751 }
752 if run >= min_run {
753 total += 1;
754 }
755 }
756 total
757}
758
759pub fn black_intervals(frames: &[Vec<u8>], fps: f64, max_mean: f64, min_s: f64) -> Vec<Interval> {
762 if fps <= 0.0 {
763 return vec![];
764 }
765 let flags: Vec<bool> = frames
766 .iter()
767 .map(|f| pixel_stats(f).mean <= max_mean)
768 .collect();
769 flag_runs(&flags, 1.0 / fps, min_s)
770}
771
772fn mean_abs_diff(a: &[u8], b: &[u8]) -> f64 {
774 if a.len() != b.len() || a.is_empty() {
775 return f64::INFINITY;
776 }
777 let total: u64 = a
778 .iter()
779 .zip(b)
780 .map(|(x, y)| u64::from(x.abs_diff(*y)))
781 .sum();
782 total as f64 / a.len() as f64
783}
784
785pub fn freeze_intervals(frames: &[Vec<u8>], fps: f64, noise: f64, min_s: f64) -> Vec<Interval> {
790 if fps <= 0.0 || frames.len() < 2 {
791 return vec![];
792 }
793 let unit = 1.0 / fps;
794 let flags: Vec<bool> = std::iter::once(false)
795 .chain(
796 frames
797 .windows(2)
798 .map(|w| mean_abs_diff(&w[0], &w[1]) <= noise),
799 )
800 .collect();
801 flag_runs(&flags, unit, 0.0)
802 .into_iter()
803 .map(|iv| Interval {
804 start_s: iv.start_s - unit,
805 end_s: iv.end_s,
806 })
807 .filter(|iv| iv.duration_s() + 1e-9 >= min_s)
808 .collect()
809}
810
811#[cfg(test)]
812mod interval_tests {
813 use super::*;
814
815 fn close(a: &[Interval], want: &[(f64, f64)]) -> bool {
816 a.len() == want.len()
817 && a.iter()
818 .zip(want)
819 .all(|(x, (s, e))| (x.start_s - s).abs() < 1e-9 && (x.end_s - e).abs() < 1e-9)
820 }
821
822 #[test]
823 fn silence_runs_shorter_than_the_minimum_are_dropped() {
824 let pcm = Pcm {
825 sample_rate: 10,
826 channels: 1,
827 samples: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0],
828 };
829 let got = silence_intervals(&pcm, -60.0, 0.2);
830 assert!(close(&got, &[(0.0, 0.4), (0.6, 0.9)]), "{got:?}");
831 assert!(silence_intervals(&pcm, -60.0, 0.35).len() == 1);
832 }
833
834 #[test]
835 fn stereo_frame_is_silent_only_when_every_channel_is() {
836 let pcm = Pcm {
837 sample_rate: 4,
838 channels: 2,
839 samples: vec![0.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
840 };
841 let got = silence_intervals(&pcm, -60.0, 0.0);
842 assert!(close(&got, &[(0.25, 1.0)]), "{got:?}");
843 }
844
845 #[test]
846 fn clipped_runs_count_per_channel_and_respect_the_minimum() {
847 let pcm = Pcm {
848 sample_rate: 10,
849 channels: 1,
850 samples: vec![1.0, 1.0, 1.0, 0.1, 1.0, 1.0],
851 };
852 assert_eq!(clipped_runs(&pcm, 0.999, 2), 2);
853 assert_eq!(clipped_runs(&pcm, 0.999, 3), 1);
854 }
855
856 #[test]
857 fn black_frames_form_intervals() {
858 let frames = vec![vec![0u8; 12], vec![200u8; 12], vec![0u8; 12]];
859 let got = black_intervals(&frames, 1.0, 16.0, 0.5);
860 assert!(close(&got, &[(0.0, 1.0), (2.0, 3.0)]), "{got:?}");
861 }
862
863 #[test]
864 fn repeated_frames_form_a_freeze_interval_from_the_first_frame() {
865 let a = vec![10u8; 12];
866 let b = vec![200u8; 12];
867 let frames = vec![a.clone(), a.clone(), a.clone(), b.clone()];
868 let got = freeze_intervals(&frames, 1.0, 0.0, 0.5);
869 assert!(close(&got, &[(0.0, 3.0)]), "{got:?}");
870 assert!(freeze_intervals(&[a.clone(), b.clone()], 1.0, 0.0, 0.0).is_empty());
871 }
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877
878 #[test]
879 fn reference_wav_round_trips_through_stats() {
880 let p = std::env::temp_dir().join(format!("rkqa-{}.wav", crate::util::new_id()));
881 write_reference_wav(&p, 1.0).unwrap();
882 let s = wav_stats(&p).unwrap();
883 assert_eq!(s["rate"], 16000.0);
884 assert_eq!(s["silent"], false);
885 assert!((s["duration_s"].as_f64().unwrap() - 1.0).abs() < 0.01);
886 assert!(png_size(&p).is_err());
887 let info = wav_info(&p).unwrap();
888 assert_eq!(
889 (info.channels, info.sample_rate, info.bits, info.float),
890 (1, 16000, 16, false)
891 );
892 assert_eq!(info.frames, 16000);
893 assert!(info.peak_dbfs < 0.0 && info.peak_dbfs > -6.0, "{info:?}");
894 assert!(info.rms_dbfs < info.peak_dbfs);
895 let _ = fs::remove_file(p);
896 }
897
898 fn tmp(ext: &str) -> PathBuf {
899 scratch_file(ext)
900 }
901
902 #[test]
903 fn wav_decodes_float_24bit_extensible_and_streamed_headers() {
904 let pcm = Pcm {
905 sample_rate: 48000,
906 channels: 2,
907 samples: (0..9600)
908 .map(|i| ((i % 200) as f32 / 100.0 - 1.0) * 0.5)
909 .collect(),
910 };
911 let p = tmp("wav");
912 write_wav_f32(&p, &pcm).unwrap();
913 let back = read_wav(&p).unwrap();
914 assert_eq!(back, pcm);
915 let info = wav_info(&p).unwrap();
916 assert!(info.float && info.bits == 32 && info.channels == 2);
917 assert!((info.duration_s - 0.1).abs() < 1e-9);
918 assert!((info.peak_dbfs - dbfs(0.5)).abs() < 0.01);
919 let _ = fs::remove_file(&p);
920
921 let mut fmt = Vec::new();
923 fmt.extend_from_slice(&0xFFFEu16.to_le_bytes());
924 fmt.extend_from_slice(&1u16.to_le_bytes());
925 fmt.extend_from_slice(&8000u32.to_le_bytes());
926 fmt.extend_from_slice(&24000u32.to_le_bytes());
927 fmt.extend_from_slice(&3u16.to_le_bytes());
928 fmt.extend_from_slice(&24u16.to_le_bytes());
929 fmt.extend_from_slice(&22u16.to_le_bytes());
930 fmt.extend_from_slice(&24u16.to_le_bytes());
931 fmt.extend_from_slice(&4u32.to_le_bytes());
932 fmt.extend_from_slice(&1u16.to_le_bytes()); fmt.extend_from_slice(&[0u8; 14]);
934 let mut b = b"RIFF\xff\xff\xff\xffWAVEfmt ".to_vec();
935 b.extend_from_slice(&(fmt.len() as u32).to_le_bytes());
936 b.extend_from_slice(&fmt);
937 b.extend_from_slice(b"data\xff\xff\xff\xff");
938 for v in [0x400000i32, -0x400000, 0x7fffff] {
939 b.extend_from_slice(&v.to_le_bytes()[..3]);
940 }
941 let p = parse_wav(&b).unwrap();
942 assert_eq!(p.samples.len(), 3);
943 assert!((p.samples[0] - 0.5).abs() < 1e-6 && (p.samples[1] + 0.5).abs() < 1e-6);
944 assert!(parse_wav(b"RIFF....WAVE").is_err());
945 }
946
947 #[test]
948 fn image_report_rejects_flat_transparent_and_speckled() {
949 let flat: Vec<u8> = [40u8, 80, 120, 255].repeat(64 * 64);
950 let r = image_report(&flat, 64, 64);
951 assert!(
952 !r.non_trivial && !r.fully_transparent && r.stats.stddev < 0.5,
953 "{r:?}"
954 );
955 let clear = vec![0u8; 64 * 64 * 4];
956 assert!(image_report(&clear, 64, 64).fully_transparent);
957 assert!(!image_report(&clear, 64, 64).non_trivial);
958 let mut speck = [0u8, 0, 0, 255].repeat(256 * 256);
960 speck[..4].copy_from_slice(&[255, 255, 255, 255]);
961 assert!(!image_report(&speck, 256, 256).non_trivial);
962 let grad: Vec<u8> = (0..64 * 64)
963 .flat_map(|i| [(i % 64 * 4) as u8, 0, 0, 255])
964 .collect();
965 let g = image_report(&grad, 64, 64);
966 assert!(g.non_trivial && g.stats.range[0] > 200, "{g:?}");
967 }
968
969 #[test]
970 fn png_round_trip_and_non_trivial_verdicts() {
971 let good = write_test_png(&tmp("png"), 96, 64).unwrap();
972 let r = png_non_trivial(&good).unwrap();
973 assert!(r.non_trivial && (r.width, r.height) == (96, 64), "{r:?}");
974 assert_eq!(png_size(&good).unwrap()["width"], 96);
975 let flat = tmp("png");
976 write_png_rgb(&flat, 16, 16, &[200u8; 16 * 16 * 3]).unwrap();
977 assert!(!png_non_trivial(&flat).unwrap().non_trivial);
978 let junk = tmp("png");
979 fs::write(&junk, b"\x89PNG\r\n\x1a\nnot really").unwrap();
980 assert!(
981 png_non_trivial(&junk).is_err(),
982 "undecodable PNG is an error, not trivial"
983 );
984 for p in [good, flat, junk] {
985 let _ = fs::remove_file(p);
986 }
987 }
988
989 #[test]
990 fn frames_report_requires_every_frame_to_vary() {
991 let edge = (FRAME_EDGE * FRAME_EDGE * 3) as usize;
992 let busy: Vec<u8> = (0..edge).map(|i| (i * 37 % 256) as u8).collect();
993 let flat = vec![16u8; edge];
994 assert!(frames_report(&[busy.clone(), busy.clone()]).non_trivial);
995 assert!(!frames_report(&[busy, flat]).non_trivial);
996 assert!(!frames_report(&[]).non_trivial);
997 }
998
999 #[test]
1000 fn read_audio_without_ffmpeg_needs_wav() {
1001 let p = tmp("flac");
1002 fs::write(&p, b"fLaC....").unwrap();
1003 let e = read_audio(&p, None, &Tracker::new()).unwrap_err();
1004 assert!(e.0.contains("no ffmpeg path"), "{e}");
1005 let _ = fs::remove_file(p);
1006 }
1007}