1use crate::engine::compose::Compositor;
15use crate::engine::export::{self, ExportOptions, Progress};
16use crate::engine::mixer::Mixer;
17use crate::engine::text::TextRasterizer;
18use crate::engine::xmeml;
19use crate::media::waveform::WaveformCache;
20use crate::media::{self, ffpipe, Backend, DecoderPool, Frame};
21use crate::model::{BlendMode, Project, TextStyle, TrackKind};
22use std::path::{Path, PathBuf};
23use std::sync::{Arc, Mutex};
24use std::time::{Duration, Instant};
25
26type R = Result<(), String>;
27
28static PANIC_AT: Mutex<String> = Mutex::new(String::new());
30
31macro_rules! check {
32 ($cond:expr, $($arg:tt)+) => {
33 if !$cond {
34 return Err(format!($($arg)+));
35 }
36 };
37}
38
39pub fn run(args: &[String]) -> i32 {
40 std::panic::set_hook(Box::new(|info| {
41 let p = info.payload();
42 let msg = p
43 .downcast_ref::<&str>()
44 .map(|s| s.to_string())
45 .or_else(|| p.downcast_ref::<String>().cloned())
46 .unwrap_or_default();
47 let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default();
48 *PANIC_AT.lock().unwrap() = format!("{msg}{at}");
49 }));
50
51 let Some(ffmpeg) = ffpipe::ffmpeg_exe() else {
52 println!("FAIL ffmpeg: ffmpeg.exe not found");
53 println!("SELFTEST FAILED (1)");
54 return 1;
55 };
56 let dir = args.first().map(PathBuf::from).unwrap_or_else(|| std::env::temp_dir().join("simple-editor-selftest"));
57 let mp4 = dir.join("test.mp4").to_string_lossy().into_owned();
58 let png = dir.join("logo.png").to_string_lossy().into_owned();
59 println!("selftest dir: {}", dir.display());
60 let mut fails = 0u32;
61
62 step(&mut fails, "generate", || {
64 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
65 #[rustfmt::skip]
66 ff(&ffmpeg, &[
67 "-f", "lavfi", "-i", "color=red:s=320x240:d=2",
68 "-f", "lavfi", "-i", "color=lime:s=320x240:d=2",
69 "-f", "lavfi", "-i", "sine=frequency=440:duration=4,volume=4",
70 "-f", "lavfi", "-i", "sine=frequency=880:duration=4,volume=4",
71 "-filter_complex", "[0:v][1:v]concat=n=2:v=1[v]",
72 "-map", "[v]", "-map", "2:a", "-map", "3:a",
73 "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-c:a", "aac",
74 "-metadata:s:a:0", "language=eng", "-metadata:s:a:1", "title=Music",
75 &mp4,
76 ])?;
77 #[rustfmt::skip]
78 ff(&ffmpeg, &["-f", "lavfi", "-i", "color=blue:s=64x48:d=1", "-frames:v", "1", &png])?;
79 Ok(())
80 });
81 if fails > 0 {
82 println!("SELFTEST FAILED (1)");
83 return 1;
84 }
85
86 for b in [Backend::Ffmpeg, Backend::Mf] {
88 let bn = bname(b);
89 step(&mut fails, &format!("probe/{bn}"), || {
90 let a = media::probe(&mp4, b)?;
91 check!(near(a.duration, 4.0, 0.1), "duration {}", a.duration);
92 check!(a.width == 320 && a.height == 240, "size {}x{}", a.width, a.height);
93 check!(near(a.fps, 30.0, 0.5), "fps {}", a.fps);
94 check!(a.audio_streams.len() == 2, "{} audio streams", a.audio_streams.len());
95 Ok(())
96 });
97 step(&mut fails, &format!("video/{bn}"), || {
98 let mut v = media::open_video(&mp4, b)?;
99 let mut f = Frame::default();
100 check!(v.frame_at(0.5, 320, 240, &mut f), "frame_at(0.5) returned false");
101 check!(is_red(px(&f, 160, 120)), "t=0.5 centre {:?}, expected red", px(&f, 160, 120));
102 check!(v.frame_at(2.5, 320, 240, &mut f), "frame_at(2.5) returned false");
103 check!(is_green(px(&f, 160, 120)), "t=2.5 centre {:?}, expected green", px(&f, 160, 120));
104 check!(v.frame_at(0.5, 320, 240, &mut f), "frame_at(0.5) again returned false");
105 check!(is_red(px(&f, 160, 120)), "t=0.5 again centre {:?}, expected red", px(&f, 160, 120));
106 check!(v.frame_at(1.0, 160, 120, &mut f), "frame_at(1.0, 160x120) returned false");
107 check!(
108 f.width == 160 && f.height == 120 && f.rgba.len() == 160 * 120 * 4,
109 "scaled frame {}x{} ({} bytes)",
110 f.width,
111 f.height,
112 f.rgba.len()
113 );
114 let t0 = Instant::now();
115 let mut ok = 0;
116 for i in 0..60 {
117 ok += v.frame_at(i as f64 / 30.0, 320, 240, &mut f) as u32;
118 }
119 println!(" {bn}: 60 sequential frame_at calls in {} ms", t0.elapsed().as_millis());
120 check!(ok == 60, "only {ok}/60 sequential frames decoded");
121 Ok(())
122 });
123 step(&mut fails, &format!("audio/{bn}"), || {
124 let mut buf = vec![0f32; 4800 * 2];
125 let mut a = media::open_audio(&mp4, 0, b)?;
126 a.read_at(1.0, &mut buf);
127 let r = rms(&buf);
128 check!((0.1..=1.0).contains(&r), "stream 0 rms at 1.0 = {r}");
129 a.read_at(10.0, &mut buf);
130 check!(buf.iter().all(|s| *s == 0.0), "stream 0 at 10.0 not silent (rms {})", rms(&buf));
131 let mut a1 = media::open_audio(&mp4, 1, b)?;
132 a1.read_at(1.0, &mut buf);
133 let r = rms(&buf);
134 check!((0.1..=1.0).contains(&r), "stream 1 rms at 1.0 = {r}");
135 Ok(())
136 });
137 }
138
139 let probe = || media::probe(&mp4, Backend::Auto);
141 let mut project = Project::new();
142 let mut pool = DecoderPool::new(Backend::Auto);
143 let mut comp = Compositor::new();
144 let mut text = TextRasterizer::new();
145 let mut frame = Frame::default();
146 step(&mut fails, "project", || {
147 project = Project::from_media(probe()?);
148 let names: Vec<&str> = project.tracks.iter().map(|t| t.name.as_str()).collect();
149 check!(names == ["V1", "A1", "A2"], "tracks {names:?}");
150 check!(near(project.duration(), 4.0, 0.1), "duration {}", project.duration());
151 let n = project.split_at(2.0, None).len();
152 check!(n == 3, "split_at(2.0) made {n} clips, expected 3");
153 Ok(())
154 });
155 step(&mut fails, "compose", || {
156 comp.render(&project, 1.0, 320, 240, &mut pool, &mut text, &mut frame);
157 check!(frame.width == 320 && frame.height == 240, "canvas {}x{}", frame.width, frame.height);
158 check!(is_red(px(&frame, 160, 120)), "t=1.0 centre {:?}, expected red", px(&frame, 160, 120));
159 comp.render(&project, 3.0, 320, 240, &mut pool, &mut text, &mut frame);
160 check!(is_green(px(&frame, 160, 120)), "t=3.0 centre {:?}, expected green", px(&frame, 160, 120));
161 Ok(())
162 });
163 step(&mut fails, "compose/blend", || {
164 let aid = project.add_asset(media::probe(&png, Backend::Auto)?);
165 let ti = project.add_track(TrackKind::Video);
166 let ids = project.insert_asset_clips(aid, 0.0, Some(ti));
167 let c = ids.first().and_then(|&id| project.clip_mut(id)).ok_or("no image clip inserted")?;
168 c.scale.value = 0.25;
169 c.blend = BlendMode::Multiply;
170 c.opacity.value = 1.0;
171 comp.render(&project, 1.0, 320, 240, &mut pool, &mut text, &mut frame);
172 let c = px(&frame, 160, 120);
173 check!(c[0] < 60 && c[2] < 60, "centre {c:?}, expected red*blue (dark)");
174 check!(is_red(px(&frame, 5, 5)), "corner {:?}, expected red", px(&frame, 5, 5));
175 Ok(())
176 });
177 step(&mut fails, "compose/text", || {
178 let id = project.add_text_clip(1.0, 2.0);
179 let c = project.clip_mut(id).ok_or("no text clip")?;
180 c.text = Some(TextStyle { text: "Hi".into(), size: 40.0, outline_width: 2.0, ..TextStyle::default() });
181 comp.render(&project, 1.0, 320, 240, &mut pool, &mut text, &mut frame);
182 let white = (60..180)
183 .flat_map(|y| (100..220).map(move |x| (x, y)))
184 .filter(|&(x, y)| px(&frame, x, y).iter().take(3).all(|&c| c > 200))
185 .count();
186 check!(white > 0, "no white pixels near the centre");
187 Ok(())
188 });
189 step(&mut fails, "mixer", || {
190 let mut mixer = Mixer::new();
191 let mut buf = vec![0f32; 4800 * 2];
192 mixer.mix(&project, 1.0, &mut pool, &mut buf);
193 let r = rms(&buf);
194 check!(r > 0.05, "rms {r} with A1+A2");
195 for i in project.audio_tracks() {
196 project.tracks[i].muted = true;
197 }
198 mixer.mix(&project, 1.0, &mut pool, &mut buf);
199 check!(buf.iter().all(|s| *s == 0.0), "rms {} with all muted, expected 0", rms(&buf));
200 let a2 = *project.audio_tracks().last().ok_or("no audio tracks")?;
201 project.tracks[a2].solo = true;
202 mixer.mix(&project, 1.0, &mut pool, &mut buf);
203 let r = rms(&buf);
204 check!(r > 0.05, "rms {r} with A2 solo");
205 Ok(())
206 });
207
208 step(&mut fails, "waveform", || {
210 let mut wf = WaveformCache::new(eframe::egui::Context::default(), Backend::Auto);
211 let t0 = Instant::now();
212 let mut peaks = wf.get(&mp4, 0);
213 check!(t0.elapsed() < Duration::from_millis(500), "first get blocked for {:?}", t0.elapsed());
214 let peaks = loop {
215 if let Some(p) = peaks {
216 break p;
217 }
218 check!(t0.elapsed() < Duration::from_secs(5), "no peaks after 5 s");
219 std::thread::sleep(Duration::from_millis(50));
220 peaks = wf.get(&mp4, 0);
221 };
222 check!((380..=420).contains(&peaks.len()), "peaks len {}, expected ≈400", peaks.len());
223 let mx = peaks.max.iter().cloned().fold(0.0f32, f32::max);
224 check!(mx > 0.1, "peak max {mx}");
225 Ok(())
226 });
227
228 let opts = |out: &Path| ExportOptions {
230 out_path: out.to_path_buf(),
231 encoder: "auto".into(),
232 crf: 23,
233 preset: "ultrafast".into(),
234 backend: Backend::Auto,
235 out_size: None,
236 scaler: "bicubic".into(),
237 frames: crate::engine::export::FrameSource::Cpu,
238 metadata: Vec::new(),
239 };
240 let trimmed = |a: f64, b: f64| -> Result<Project, String> {
241 let mut p = Project::from_media(probe()?);
242 p.trim_to_range(a, b);
243 Ok(p)
244 };
245 step(&mut fails, "export/mp4", || {
246 let out = dir.join("export.mp4");
247 let _ = std::fs::remove_file(&out);
248 let prog = export::start_export(trimmed(0.5, 3.5)?, opts(&out), Arc::new(Mutex::new(TextRasterizer::new())));
249 wait_done(&prog, Duration::from_secs(120))?;
250 check!(out.is_file(), "output missing");
251 let out = out.to_string_lossy().into_owned();
252 let d = ffprobe_duration(&out)?;
253 check!(near(d, 3.0, 0.2), "duration {d}, expected 3.0");
254 let mut v = media::open_video(&out, Backend::Auto)?;
255 let mut f = Frame::default();
256 check!(v.frame_at(0.5, 320, 240, &mut f), "frame_at(0.5) returned false");
257 check!(is_red(px(&f, 160, 120)), "t=0.5 centre {:?}, expected red", px(&f, 160, 120));
258 check!(v.frame_at(2.5, 320, 240, &mut f), "frame_at(2.5) returned false");
259 check!(is_green(px(&f, 160, 120)), "t=2.5 centre {:?}, expected green", px(&f, 160, 120));
260 Ok(())
261 });
262 step(&mut fails, "export/wav", || {
263 let out = dir.join("export.wav");
264 let _ = std::fs::remove_file(&out);
265 let prog = export::start_export(trimmed(0.5, 3.5)?, opts(&out), Arc::new(Mutex::new(TextRasterizer::new())));
266 wait_done(&prog, Duration::from_secs(120))?;
267 let d = ffprobe_duration(&out.to_string_lossy())?;
268 check!(near(d, 3.0, 0.2), "duration {d}, expected 3.0");
269 Ok(())
270 });
271
272 step(&mut fails, "lossless", || {
274 let p = trimmed(1.0, 3.0)?;
275 let segs = export::lossless_segments(&p).ok_or("lossless_segments returned None for a plain cut")?;
276 check!(
277 segs.len() == 1 && near(segs[0].0, 1.0, 0.01) && near(segs[0].1, 2.0, 0.01),
278 "segments {segs:?}, expected [(1.0, 2.0)]"
279 );
280 let out = dir.join("cut.mp4");
281 let _ = std::fs::remove_file(&out);
282 let prog = export::start_lossless_cut(p, out.clone());
283 wait_done(&prog, Duration::from_secs(60))?;
284 let d = ffprobe_duration(&out.to_string_lossy())?;
285 check!((1.5..=3.5).contains(&d), "duration {d}, expected 1.5..3.5");
286 Ok(())
287 });
288
289 step(&mut fails, "xmeml", || {
291 let xml = xmeml::export_xmeml(&Project::from_media(probe()?));
292 check!(xml.contains("<xmeml"), "no <xmeml element");
293 let p = mp4.replace('\\', "/");
294 check!(xml.contains(&p) || xml.contains(&p.replace(' ', "%20")), "asset path {p} missing");
295 Ok(())
296 });
297
298 step(&mut fails, "shapes", || {
301 use crate::engine::shapes::ShapeRasterizer;
302 use crate::model::{ShapeKind, ShapeStyle};
303 let mut style = ShapeStyle::new(ShapeKind::Rect);
304 style.fill = [255, 0, 0, 255];
305 style.stroke = [0, 0, 0, 0];
306 style.w.value = 100.0;
307 style.h.value = 60.0;
308 let (w, h) = ShapeRasterizer::size(&style, 0.0);
310 check!(near(w as f64, 200.0, 8.0) && near(h as f64, 120.0, 8.0), "size {w}x{h}, expected ≈200x120");
311 let mut r = ShapeRasterizer::new();
312 let f = r.render(&style, 1.0, 0.0);
313 check!(!f.is_empty(), "empty raster");
314 let c = px(&f, f.width / 2, f.height / 2);
315 check!(is_red(c) && c[3] > 200, "centre {c:?}, expected opaque red");
316 let f2 = r.render(&style, 1.0, 0.0);
318 check!(f2.rgba == f.rgba, "cached raster differs");
319 Ok(())
320 });
321 step(&mut fails, "effects/round3", || {
322 use crate::engine::effects;
323 use crate::model::{Effect, EffectKind};
324 let mut base = Frame::new(64, 48);
325 for (i, px) in base.rgba.chunks_exact_mut(4).enumerate() {
326 px.copy_from_slice(&[(i % 251) as u8, (i % 97) as u8, 200, 255]);
327 }
328 let mut scratch = Frame::default();
329 let mut changed = 0;
330 for kind in EffectKind::ALL {
331 let mut img = base.clone();
332 effects::apply(&Effect::new(kind), 0.25, 1.0, &mut img, &mut scratch);
333 check!(img.width == 64 && img.height == 48, "{} resized the layer", kind.name());
334 check!(img.rgba.len() == 64 * 48 * 4, "{} broke the buffer", kind.name());
335 check!(img.rgba.chunks_exact(4).all(|p| p[3] > 0), "{} zeroed the alpha", kind.name());
336 changed += (img.rgba != base.rgba) as u32;
337 }
338 check!(changed >= 5, "only {changed} of {} effects changed anything", EffectKind::ALL.len());
340 println!(" {changed}/{} effect kinds change pixels on the CPU path", EffectKind::ALL.len());
341 Ok(())
342 });
343 step(&mut fails, "mixer/bus", || {
344 use crate::engine::mixer_fx::BusGraph;
345 use crate::model::{AudioFilter, FilterKind};
346 let mut p = Project::new();
347 let main = p.main_bus();
348 let bus = p.add_bus("Music");
349 p.bus_mut(bus).ok_or("no bus")?.filters.push(AudioFilter::new(FilterKind::Gain));
350 let mut g = BusGraph::new();
351 g.sync(&p);
352 let order = g.order();
353 check!(order.last() == Some(&main), "evaluation order {order:?}, Main must be last");
354 check!(order.contains(&bus), "the new bus is missing from {order:?}");
355 let frames = 512;
356 for (i, s) in g.buffer(bus, frames).iter_mut().enumerate() {
357 *s = if (i / 2) % 2 == 0 { 0.5 } else { -0.5 };
358 }
359 let (music, main_bus) = (p.bus(bus).ok_or("no bus")?.clone(), p.bus(main).ok_or("no main")?.clone());
360 let mut out = vec![0f32; frames * 2];
361 g.flush(&music, 0.0, &mut out); g.flush(&main_bus, 0.0, &mut out);
363 let r = rms(&out);
364 check!(r > 0.05, "rms {r} out of the Main bus");
365 let (l, _) = g.meter(bus);
366 check!(l > 0.05, "bus meter {l}");
367 Ok(())
368 });
369 step(&mut fails, "import/roundtrip", || {
370 let path = dir.join("roundtrip.xml");
371 let mut p = Project::from_media(probe()?);
372 p.split_at(2.0, None);
373 std::fs::write(&path, xmeml::export_xmeml(&p)).map_err(|e| e.to_string())?;
374 let r = crate::engine::import::import_file(&path)?;
375 check!(r.clips >= 2, "{} clips imported, expected the 2+ we exported", r.clips);
376 check!(r.tracks >= 1, "{} tracks", r.tracks);
377 check!(near(r.project.duration(), p.duration(), 0.2), "duration {} vs {}", r.project.duration(), p.duration());
378 check!(r.to_markdown().contains('|'), "the report is not a markdown table");
379 Ok(())
380 });
381 step(&mut fails, "prerender", || {
382 use crate::engine::prerender::PreRender;
383 let p = Project::from_media(probe()?);
384 let mut pr = PreRender::new();
385 pr.request(&p, 0.0, 1.0);
386 let t0 = Instant::now();
387 while pr.tick(&p, 8.0, None) {
388 check!(t0.elapsed() < Duration::from_secs(30), "pre-render did not finish in 30 s");
389 }
390 check!(pr.progress() > 0.99, "progress {}", pr.progress());
391 let f = pr.frame(&p, 0.5).ok_or("no cached frame at 0.5 s")?;
392 check!(!f.is_empty(), "empty cached frame");
393 check!(is_red(px(&f, f.width / 2, f.height / 2)), "cached frame at 0.5 s is not red");
394 pr.clear();
395 check!(pr.frame(&p, 0.5).is_none(), "clear() left the cache in place");
396 Ok(())
397 });
398 step(&mut fails, "markers/labels/paste", || {
399 use crate::model::{AttrSet, BlendMode as Bm, Effect, EffectKind};
400 let mut p = Project::from_media(probe()?);
401 let clip = p.all_clips().next().ok_or("no clips")?.1.id;
403 let m1 = p.add_marker(3.0, "three");
404 let m0 = p.add_marker(1.0, "one");
405 let mc = p.add_clip_marker(clip, 0.5, "on the clip").ok_or("no clip marker")?;
406 let list = p.markers_in_timeline();
407 let times: Vec<f64> = list.iter().map(|m| m.1).collect();
408 check!(times.windows(2).all(|w| w[0] <= w[1]), "markers are not in timeline order: {times:?}");
409 check!(list.len() == 3, "{} markers, expected 3", list.len());
410 p.marker_mut(mc).ok_or("marker gone")?.label = 2;
411 p.remove_marker(m0);
412 check!(p.markers_in_timeline().len() == 2, "remove_marker did not remove one");
413 check!(p.marker_mut(m1).is_some(), "the other project marker disappeared");
414 let idx = p.add_label("Retake", [10, 20, 30]);
416 check!(p.label_name(idx) == "Retake", "label name {}", p.label_name(idx));
417 check!(p.label_color(idx) == Some([10, 20, 30]), "label colour");
418 p.clip_mut(clip).ok_or("no clip")?.label = idx;
419 p.remove_label(idx);
420 check!(p.clip(clip).ok_or("no clip")?.label == 0, "a removed label must clear its users");
421 check!(p.label_name(idx) == "None" || idx as usize <= p.labels.len(), "label list is inconsistent");
422 let ids = p.split_at(2.0, None);
424 let target = *ids.first().ok_or("split made no clip")?;
425 {
426 let c = p.clip_mut(clip).ok_or("no clip")?;
427 c.opacity.value = 0.5;
428 c.blend = Bm::Screen;
429 c.effects.push(Effect::new(EffectKind::Blur));
430 }
431 let src = p.copy_attributes(clip).ok_or("copy_attributes returned None")?;
432 let (start, dur) = {
433 let c = p.clip(target).ok_or("no target")?;
434 (c.start, c.duration)
435 };
436 let n = p.paste_attributes(&src, &[target], AttrSet { opacity: true, ..AttrSet::NONE });
437 check!(n == 1, "paste_attributes changed {n} clips");
438 let c = p.clip(target).ok_or("no target")?;
439 check!(c.opacity.value == 0.5, "opacity {} was not pasted", c.opacity.value);
440 check!(c.blend == Bm::Normal, "blend was pasted although it was not ticked");
441 check!(c.effects.is_empty(), "effects were pasted although they were not ticked");
442 check!(c.start == start && c.duration == dur, "timing changed ({start} {dur} -> {} {})", c.start, c.duration);
443 Ok(())
444 });
445
446 if fails == 0 {
447 println!("SELFTEST OK");
448 0
449 } else {
450 println!("SELFTEST FAILED ({fails})");
451 1
452 }
453}
454
455fn step(fails: &mut u32, name: &str, f: impl FnOnce() -> R) {
458 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
459 Ok(Ok(())) => println!("PASS {name}"),
460 Ok(Err(e)) => {
461 println!("FAIL {name}: {e}");
462 *fails += 1;
463 }
464 Err(_) => {
465 let at = PANIC_AT.lock().map(|s| s.clone()).unwrap_or_default();
466 if at.starts_with("not yet implemented") {
467 println!("SKIP {name}: {at}");
468 } else {
469 println!("FAIL {name}: panic: {at}");
470 *fails += 1;
471 }
472 }
473 }
474}
475
476fn bname(b: Backend) -> &'static str {
477 match b {
478 Backend::Mf => "mf",
479 Backend::Ffmpeg => "ffmpeg",
480 Backend::Auto => "auto",
481 }
482}
483
484fn ff(exe: &Path, args: &[&str]) -> R {
486 let out = ffpipe::command(exe).args(["-y", "-v", "error"]).args(args).output().map_err(|e| e.to_string())?;
487 check!(out.status.success(), "ffmpeg: {}", String::from_utf8_lossy(&out.stderr).trim());
488 Ok(())
489}
490
491fn ffprobe_duration(path: &str) -> Result<f64, String> {
492 let exe = ffpipe::ffprobe_exe().ok_or("ffprobe.exe not found")?;
493 let out = ffpipe::command(&exe)
494 .args(["-v", "quiet", "-print_format", "json", "-show_format", path])
495 .output()
496 .map_err(|e| e.to_string())?;
497 let v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("ffprobe {path}: {e}"))?;
498 v["format"]["duration"]
499 .as_str()
500 .and_then(|s| s.parse().ok())
501 .ok_or_else(|| format!("ffprobe: no duration for {path}"))
502}
503
504fn wait_done(p: &Arc<Progress>, timeout: Duration) -> R {
507 let t0 = Instant::now();
508 while !p.is_done() {
509 check!(!(Arc::strong_count(p) == 1 && !p.is_done()), "worker ended without finishing ({})", p.status());
510 check!(t0.elapsed() < timeout, "timeout after {:?} ({})", timeout, p.status());
511 std::thread::sleep(Duration::from_millis(50));
512 }
513 p.error().map_or(Ok(()), Err)
514}
515
516fn px(f: &Frame, x: u32, y: u32) -> [u8; 4] {
517 let i = ((y * f.width + x) * 4) as usize;
518 f.rgba.get(i..i + 4).map(|p| [p[0], p[1], p[2], p[3]]).unwrap_or([0; 4])
519}
520
521fn rms(s: &[f32]) -> f32 {
522 (s.iter().map(|x| x * x).sum::<f32>() / s.len().max(1) as f32).sqrt()
523}
524
525fn near(a: f64, b: f64, tol: f64) -> bool {
526 (a - b).abs() <= tol
527}
528
529fn is_red(p: [u8; 4]) -> bool {
530 p[0] > 200 && p[1] < 60 && p[2] < 60
531}
532
533fn is_green(p: [u8; 4]) -> bool {
534 p[1] > 200 && p[0] < 60 && p[2] < 60
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
542 fn helpers() {
543 let mut f = Frame::new(2, 2);
544 f.rgba[4..8].copy_from_slice(&[255, 0, 0, 255]);
545 assert!(is_red(px(&f, 1, 0)));
546 assert!(!is_red(px(&f, 0, 0)));
547 assert_eq!(px(&f, 5, 5), [0; 4]); assert!((rms(&[1.0, -1.0, 1.0, -1.0]) - 1.0).abs() < 1e-6);
549 assert_eq!(rms(&[]), 0.0);
550 assert!(near(4.05, 4.0, 0.1) && !near(4.2, 4.0, 0.1));
551 }
552
553 #[test]
554 fn step_reports_err_and_panic() {
555 let mut fails = 0;
556 step(&mut fails, "ok", || Ok(()));
557 step(&mut fails, "err", || Err("nope".into()));
558 step(&mut fails, "panic", || panic!("boom"));
559 assert_eq!(fails, 2);
560 }
561
562 #[test]
563 fn probe_duration_via_ffprobe() {
564 let Some(ffmpeg) = ffpipe::ffmpeg_exe() else {
565 return;
566 };
567 let dir = std::env::temp_dir().join("simple-editor-selftest-unit");
568 std::fs::create_dir_all(&dir).unwrap();
569 let p = dir.join("one.wav").to_string_lossy().into_owned();
570 ff(&ffmpeg, &["-f", "lavfi", "-i", "sine=frequency=440:duration=1", &p]).unwrap();
571 let d = ffprobe_duration(&p).unwrap();
572 assert!(near(d, 1.0, 0.05), "{d}");
573 assert!(ffprobe_duration("C:/definitely/missing.mp4").is_err());
574 }
575}