Skip to main content

mediaway_test_media/
lib.rs

1//! Deterministic test-media generators with a **local gitignored cache**.
2//!
3//! Cache hits are validated with **BLAKE3** against an expected digest — same
4//! path alone is not enough. Binaries must not be committed — see
5//! `docs/conventions/testing.md`.
6
7#![forbid(unsafe_code)]
8
9use std::fs::{self, File};
10use std::io::{self, Read, Write};
11use std::path::{Path, PathBuf};
12
13use thiserror::Error;
14
15/// Default cache directory name under the workspace root.
16pub const DEFAULT_CACHE_DIR_NAME: &str = "local/.cache/test-media";
17
18/// BLAKE3 hex digest of a 64×64 opaque red RGBA8 frame (`solid/red_64x64.rgba`).
19///
20/// Recompute with `hash_bytes(&solid_rgba8_bytes(64, 64, [255, 0, 0, 255]))` if the
21/// generator pattern changes.
22pub const SOLID_RED_64X64_BLAKE3: &str =
23    "a78b4d88cbe2168f9532786db54838c1ae324e2b051c8305ef53eb9bab9602ea";
24
25/// Errors from fixture ensure / generate.
26#[derive(Debug, Error)]
27#[non_exhaustive]
28pub enum TestMediaError {
29    /// I/O failure while reading or writing the cache.
30    #[error("io error: {0}")]
31    Io(#[from] io::Error),
32    /// Workspace root could not be located.
33    #[error("could not locate workspace root (set MEDIAWAY_TEST_MEDIA_CACHE)")]
34    WorkspaceRootNotFound,
35    /// Cached or generated bytes do not match the expected BLAKE3 digest.
36    #[error("hash mismatch for {path}: expected {expected}, got {actual}")]
37    HashMismatch {
38        /// Fixture path (cache file).
39        path: PathBuf,
40        /// Expected lowercase hex digest.
41        expected: String,
42        /// Actual lowercase hex digest.
43        actual: String,
44    },
45    /// Expected digest string is not 64 hex chars.
46    #[error("invalid expected blake3 hex (want 64 lowercase hex chars): {0}")]
47    InvalidExpectedHash(String),
48}
49
50/// Resolve the local fixture cache directory.
51///
52/// Order: `MEDIAWAY_TEST_MEDIA_CACHE` env → `<workspace>/local/.cache/test-media`.
53pub fn cache_dir() -> Result<PathBuf, TestMediaError> {
54    if let Ok(p) = std::env::var("MEDIAWAY_TEST_MEDIA_CACHE") {
55        return Ok(PathBuf::from(p));
56    }
57    let root = workspace_root()?;
58    Ok(root.join(DEFAULT_CACHE_DIR_NAME))
59}
60
61fn workspace_root() -> Result<PathBuf, TestMediaError> {
62    let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
63    for _ in 0..8 {
64        let candidate = dir.join("Cargo.toml");
65        if candidate.is_file() {
66            let text = fs::read_to_string(&candidate)?;
67            if text.contains("[workspace]") {
68                return Ok(dir);
69            }
70        }
71        if !dir.pop() {
72            break;
73        }
74    }
75    Err(TestMediaError::WorkspaceRootNotFound)
76}
77
78/// BLAKE3 hex digest of a file (streaming).
79pub fn hash_file(path: &Path) -> Result<String, TestMediaError> {
80    let mut file = File::open(path)?;
81    let mut hasher = blake3::Hasher::new();
82    let mut buf = vec![0_u8; 64 * 1024];
83    loop {
84        let n = file.read(&mut buf)?;
85        if n == 0 {
86            break;
87        }
88        hasher.update(&buf[..n]);
89    }
90    Ok(hasher.finalize().to_hex().to_string())
91}
92
93/// BLAKE3 hex digest of an in-memory buffer.
94#[must_use]
95pub fn hash_bytes(data: &[u8]) -> String {
96    blake3::hash(data).to_hex().to_string()
97}
98
99fn normalize_expected_hex(expected: &str) -> Result<String, TestMediaError> {
100    let lower = expected.trim().to_ascii_lowercase();
101    if lower.len() != 64 || !lower.chars().all(|c| c.is_ascii_hexdigit()) {
102        // clone: error owns the original invalid string for Display
103        return Err(TestMediaError::InvalidExpectedHash(expected.to_owned()));
104    }
105    Ok(lower)
106}
107
108fn verify_hash(path: &Path, expected_hex: &str) -> Result<(), TestMediaError> {
109    let expected = normalize_expected_hex(expected_hex)?;
110    let actual = hash_file(path)?;
111    if actual != expected {
112        return Err(TestMediaError::HashMismatch {
113            path: path.to_path_buf(),
114            expected,
115            actual,
116        });
117    }
118    Ok(())
119}
120
121/// Ensure `relative_name` exists under the cache **and** matches `expected_blake3_hex`.
122///
123/// - Cache hit + matching hash → return path.
124/// - Missing or hash mismatch → regenerate, then verify (generator drift fails loudly).
125///
126/// `relative_name` uses `/` separators (e.g. `solid/red_64x64.rgba`).
127pub fn ensure(
128    relative_name: &str,
129    expected_blake3_hex: &str,
130    generate: impl FnOnce(&Path) -> Result<(), TestMediaError>,
131) -> Result<PathBuf, TestMediaError> {
132    let expected = normalize_expected_hex(expected_blake3_hex)?;
133    let path = cache_dir()?.join(relative_name.replace('/', std::path::MAIN_SEPARATOR_STR));
134
135    if path.is_file() && verify_hash(&path, &expected).is_ok() {
136        return Ok(path);
137    }
138
139    if let Some(parent) = path.parent() {
140        fs::create_dir_all(parent)?;
141    }
142    if path.is_file() {
143        fs::remove_file(&path)?;
144    }
145    generate(&path)?;
146    verify_hash(&path, &expected)?;
147    Ok(path)
148}
149
150/// Write a solid-color packed RGBA8 frame (`width * height * 4` bytes).
151pub fn write_solid_rgba8(
152    path: &Path,
153    width: u32,
154    height: u32,
155    rgba: [u8; 4],
156) -> Result<(), TestMediaError> {
157    let mut file = File::create(path)?;
158    let mut row = Vec::with_capacity(width as usize * 4);
159    for _ in 0..width {
160        row.extend_from_slice(&rgba);
161    }
162    for _ in 0..height {
163        file.write_all(&row)?;
164    }
165    Ok(())
166}
167
168/// In-memory solid RGBA8 buffer (for digest computation / tiny fixtures).
169#[must_use]
170pub fn solid_rgba8_bytes(width: u32, height: u32, rgba: [u8; 4]) -> Vec<u8> {
171    let mut out = Vec::with_capacity(width as usize * height as usize * 4);
172    for _ in 0..(width * height) {
173        out.extend_from_slice(&rgba);
174    }
175    out
176}
177
178/// Ensure a 64×64 opaque red RGBA8 fixture (`solid/red_64x64.rgba`).
179pub fn ensure_solid_red_64x64() -> Result<PathBuf, TestMediaError> {
180    ensure("solid/red_64x64.rgba", SOLID_RED_64X64_BLAKE3, |path| {
181        write_solid_rgba8(path, 64, 64, [255, 0, 0, 255])
182    })
183}
184
185/// BLAKE3 hex digest of a 64×64 mid-gray NV12 frame (`solid/gray_nv12_64x64.yuv`).
186///
187/// Recompute with `hash_bytes(&solid_nv12_bytes(64, 64, 128, 128, 128))` if the
188/// generator pattern changes.
189pub const SOLID_GRAY_NV12_64X64_BLAKE3: &str =
190    "7f1b18528da0f7179df926ff90ccd1c4997564d1a970729a0c2f3c75365bf5cd";
191
192/// Write a solid-color NV12 frame: `width * height` luma bytes, then
193/// `width * height / 2` bytes of interleaved chroma (`U, V, U, V, …` per row).
194pub fn write_solid_nv12(
195    path: &Path,
196    width: u32,
197    height: u32,
198    luma: u8,
199    chroma_u: u8,
200    chroma_v: u8,
201) -> Result<(), TestMediaError> {
202    let mut file = File::create(path)?;
203    let y_row = vec![luma; width as usize];
204    for _ in 0..height {
205        file.write_all(&y_row)?;
206    }
207    let uv_row = uv_row_bytes(width, chroma_u, chroma_v);
208    for _ in 0..(height / 2) {
209        file.write_all(&uv_row)?;
210    }
211    Ok(())
212}
213
214/// In-memory solid-color NV12 buffer (layout matches [`write_solid_nv12`]).
215#[must_use]
216pub fn solid_nv12_bytes(width: u32, height: u32, luma: u8, chroma_u: u8, chroma_v: u8) -> Vec<u8> {
217    let w = width as usize;
218    let h = height as usize;
219    let mut out = Vec::with_capacity(w * h + w * (h / 2));
220    out.extend(std::iter::repeat_n(luma, w * h));
221    let uv_row = uv_row_bytes(width, chroma_u, chroma_v);
222    for _ in 0..(h / 2) {
223        out.extend_from_slice(&uv_row);
224    }
225    out
226}
227
228fn uv_row_bytes(width: u32, chroma_u: u8, chroma_v: u8) -> Vec<u8> {
229    let mut row = Vec::with_capacity(width as usize);
230    for i in 0..width {
231        row.push(if i % 2 == 0 { chroma_u } else { chroma_v });
232    }
233    row
234}
235
236/// Ensure a 64×64 mid-gray NV12 fixture (`solid/gray_nv12_64x64.yuv`).
237pub fn ensure_solid_gray_nv12_64x64() -> Result<PathBuf, TestMediaError> {
238    ensure(
239        "solid/gray_nv12_64x64.yuv",
240        SOLID_GRAY_NV12_64X64_BLAKE3,
241        |path| write_solid_nv12(path, 64, 64, 128, 128, 128),
242    )
243}
244
245/// BLAKE3 hex digest of 20 ms of silent 48 kHz stereo 16-bit PCM (`pcm/silence_48k_stereo_20ms.pcm`).
246///
247/// Recompute with `hash_bytes(&pcm_silence_bytes(960, 2))` if the generator pattern changes.
248pub const PCM_SILENCE_48K_STEREO_20MS_BLAKE3: &str =
249    "197c5da917d6a2893af03f02e1ac1cc385de0be89be78e979ce1dedb388652cb";
250
251/// In-memory silent interleaved 16-bit PCM buffer (`frames_per_channel * channels * 2` bytes,
252/// all zero).
253#[must_use]
254pub fn pcm_silence_bytes(frames_per_channel: u32, channels: u16) -> Vec<u8> {
255    vec![0_u8; frames_per_channel as usize * usize::from(channels) * 2]
256}
257
258/// Write a silent interleaved 16-bit PCM buffer.
259pub fn write_pcm_silence(
260    path: &Path,
261    frames_per_channel: u32,
262    channels: u16,
263) -> Result<(), TestMediaError> {
264    let mut file = File::create(path)?;
265    file.write_all(&pcm_silence_bytes(frames_per_channel, channels))?;
266    Ok(())
267}
268
269/// Ensure 20 ms of silent 48 kHz stereo 16-bit PCM (`pcm/silence_48k_stereo_20ms.pcm`).
270pub fn ensure_pcm_silence_48k_stereo_20ms() -> Result<PathBuf, TestMediaError> {
271    ensure(
272        "pcm/silence_48k_stereo_20ms.pcm",
273        PCM_SILENCE_48K_STEREO_20MS_BLAKE3,
274        |path| write_pcm_silence(path, 960, 2),
275    )
276}
277
278#[cfg(test)]
279#[path = "lib_tests.rs"]
280mod tests;