mediaway_test_media/
lib.rs1#![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
15pub const DEFAULT_CACHE_DIR_NAME: &str = "local/.cache/test-media";
17
18pub const SOLID_RED_64X64_BLAKE3: &str =
23 "a78b4d88cbe2168f9532786db54838c1ae324e2b051c8305ef53eb9bab9602ea";
24
25#[derive(Debug, Error)]
27#[non_exhaustive]
28pub enum TestMediaError {
29 #[error("io error: {0}")]
31 Io(#[from] io::Error),
32 #[error("could not locate workspace root (set MEDIAWAY_TEST_MEDIA_CACHE)")]
34 WorkspaceRootNotFound,
35 #[error("hash mismatch for {path}: expected {expected}, got {actual}")]
37 HashMismatch {
38 path: PathBuf,
40 expected: String,
42 actual: String,
44 },
45 #[error("invalid expected blake3 hex (want 64 lowercase hex chars): {0}")]
47 InvalidExpectedHash(String),
48}
49
50pub 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
78pub 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#[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 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
121pub 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
150pub 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#[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
178pub 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
185pub const SOLID_GRAY_NV12_64X64_BLAKE3: &str =
190 "7f1b18528da0f7179df926ff90ccd1c4997564d1a970729a0c2f3c75365bf5cd";
191
192pub 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#[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
236pub 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
245pub const PCM_SILENCE_48K_STEREO_20MS_BLAKE3: &str =
249 "197c5da917d6a2893af03f02e1ac1cc385de0be89be78e979ce1dedb388652cb";
250
251#[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
258pub 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
269pub 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;