simple_editor/
selftest.rs

1//! `simple-editor --selftest [dir]` — headless end-to-end check (debug builds print to the console).
2//! Generates synthetic media with ffmpeg (solid colour segments + two sine audio streams), then verifies:
3//! probe → project layout; video decode at known times (colour & seek accuracy) for both backends;
4//! audio decode RMS; compositor (blend/opacity/text layer); mixer; waveform peaks; export (mp4) →
5//! ffprobe duration; lossless cut; xmeml is well-formed. Prints PASS/FAIL lines, returns 0 on success.
6//!
7//! Round 3 adds headless checks that need no GL context: shape rasterising, the effect stack on the CPU,
8//! the mixer's bus graph with a filter, an xmeml export imported back, the pre-render cache and
9//! markers / labels / paste-attributes.
10//!
11//! Every step runs under `catch_unwind`, so a panic in one module is reported (FAIL — or SKIP when the
12//! module is still an unimplemented stub) and the remaining steps still run.
13
14use 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
28/// Last panic message + location, recorded by the quiet panic hook and printed in the FAIL line.
29static 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    // 1. media: red 0–2 s, green 2–4 s, 320x240 30 fps h264; sine 440 Hz (eng) + 880 Hz (Music), both AAC.
63    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    // 2. + 3. probe / video / audio per backend.
87    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    // 4. project + compositor + mixer (shared state; later steps fail with a reason if earlier ones did).
140    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    // 5. waveform peaks (background compute).
209    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    // 6. export (re-encode) mp4 + wav of [0.5, 3.5).
229    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    // 7. lossless cut of [1.0, 3.0) — lands on keyframes, so only a loose duration check.
273    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    // 8. xmeml.
290    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    // 9. round-3 engine pieces. None of these need a GL context; a module that is still a `todo!()`
299    // stub is reported as SKIP (see `step`) so the rest of the check still runs.
300    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        // ShapeStyle.w/h are HALF sizes (see model::ShapeStyle), so the layer is twice that.
309        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        // a second call at the same size/time is served from the cache (same pixels)
317        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        // geometric effects move the layer instead of touching pixels; GPU-only kinds are no-ops on the CPU
339        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); // a sub-bus adds into its output bus (Main)
362        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        // markers: project + clip, listed together in timeline order
402        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        // labels: add, use, remove — users of a removed label fall back to "none"
415        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        // copy / paste attributes: only the ticked fields, never the timing
423        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
455/// Run one step. A `todo!()` in a module that is not written yet is reported as SKIP (it is a gap, not
456/// a defect); everything else — a returned error or any other panic — is a FAIL.
457fn 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
484/// Run ffmpeg with `-y -v error` + args; Err = last stderr text.
485fn 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
504/// Poll until the worker finishes; Err on timeout, on a reported error, or when the worker thread
505/// vanished without finishing (its Arc clone dropped → nothing can ever set done).
506fn 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]); // out of range → zeros, no panic
548        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}