simple_editor\engine/
capture.rs

1//! Screen recording and voiceover capture, both through ffmpeg child processes (no new deps).
2//!
3//! Screen: `ffmpeg -f gdigrab -framerate FPS [-offset_x X -offset_y Y -video_size WxH] -i desktop
4//! [-f dshow -i audio="<mic>"] -c:v libx264 -preset veryfast <rate args> out.mp4`. Desktop audio needs a
5//! loopback dshow device (see `audio_devices`). `auto_on_blur` (settings) starts the recorder when the
6//! editor loses focus and stops when it regains focus, so another app can be recorded hands-free.
7//!
8//! Voiceover: `ffmpeg -f dshow -i audio="<device>" -ac N -ar 48000 out.wav`, started/stopped from the
9//! voiceover panel while playback continues; the file is imported and placed at the record start time.
10//!
11//! Both report through `engine::export::Progress` and stop cleanly (ffmpeg gets `q` on stdin, so the
12//! container is finalised).
13
14use crate::engine::export::{self, Progress};
15use crate::media::ffpipe;
16use std::io::{BufRead, Write};
17use std::path::PathBuf;
18use std::process::{Child, ChildStdin, Stdio};
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::{Arc, Mutex, OnceLock};
21use std::time::Instant;
22
23#[derive(Clone, Debug)]
24pub struct ScreenCaptureOptions {
25    pub out: PathBuf,
26    pub fps: u32,
27    /// Video bitrate in kbit/s (0 = use `crf` instead).
28    pub bitrate_kbps: u32,
29    pub crf: u32,
30    /// None = whole desktop; Some((x, y, w, h)) = a region in desktop pixels.
31    pub region: Option<(i32, i32, u32, u32)>,
32    /// dshow device name for the microphone (empty = none).
33    pub mic: String,
34    pub desktop_audio: bool,
35    pub cursor: bool,
36}
37
38impl Default for ScreenCaptureOptions {
39    fn default() -> Self {
40        Self {
41            out: PathBuf::new(),
42            fps: 30,
43            bitrate_kbps: 8000,
44            crf: 20,
45            region: None,
46            mic: String::new(),
47            desktop_audio: false,
48            cursor: true,
49        }
50    }
51}
52
53#[derive(Clone, Debug)]
54pub struct VoiceoverOptions {
55    pub out: PathBuf,
56    pub device: String,
57    pub sample_rate: u32,
58    pub channels: u32,
59}
60
61impl Default for VoiceoverOptions {
62    fn default() -> Self {
63        Self { out: PathBuf::new(), device: String::new(), sample_rate: 48000, channels: 1 }
64    }
65}
66
67/// A running capture. `stop()` finalises the file; dropping it stops too.
68pub struct Capture {
69    progress: Arc<Progress>,
70    /// Shared with the watcher thread so `stop`/`drop` can reach the process from the UI thread.
71    child: Arc<Mutex<Option<Child>>>,
72    /// Kept open for the whole recording: ffmpeg only accepts `q` while its stdin is a live pipe.
73    stdin: Option<ChildStdin>,
74    start: Instant,
75    /// Set by `stop`: the watcher then treats any exit status as success and `drop` must not kill.
76    graceful: Arc<AtomicBool>,
77}
78
79impl Capture {
80    pub fn progress(&self) -> Arc<Progress> {
81        self.progress.clone()
82    }
83    /// Ask ffmpeg to finish; the file is complete once `progress.is_done()`.
84    pub fn stop(mut self) {
85        self.graceful.store(true, Ordering::SeqCst);
86        if let Some(mut si) = self.stdin.take() {
87            let _ = si.write_all(b"q\n");
88            let _ = si.flush();
89        }
90        // `self` drops here: stdin is closed (EOF is ffmpeg's second exit cue) and the watcher thread
91        // waits for the process, finalises the container and finishes the progress.
92    }
93    /// Seconds recorded so far.
94    pub fn elapsed(&self) -> f64 {
95        self.start.elapsed().as_secs_f64()
96    }
97}
98
99impl Drop for Capture {
100    fn drop(&mut self) {
101        if self.graceful.load(Ordering::SeqCst) {
102            return;
103        }
104        self.progress.cancel.store(true, Ordering::SeqCst);
105        if let Ok(mut g) = self.child.lock() {
106            if let Some(c) = g.as_mut() {
107                let _ = c.kill();
108            }
109        }
110    }
111}
112
113pub fn start_screen(opts: ScreenCaptureOptions) -> Result<Capture, String> {
114    let loopback = if opts.desktop_audio { loopback_device() } else { None };
115    if opts.desktop_audio && loopback.is_none() {
116        // ponytail: no WASAPI loopback of our own — a dshow loopback driver is the only route
117        return Err("No desktop-audio device found. Enable \"Stereo Mix\" in Windows sound settings \
118                    (or install a loopback driver), then restart Simple Editor."
119            .into());
120    }
121    spawn(&opts.out, screen_args(&opts, loopback.as_deref()), "Recording")
122}
123
124pub fn start_voiceover(opts: VoiceoverOptions) -> Result<Capture, String> {
125    if opts.device.trim().is_empty() {
126        return Err("No input device selected.".into());
127    }
128    spawn(&opts.out, voice_args(&opts), "Recording")
129}
130
131/// Launch ffmpeg with `args` and watch it on a background thread.
132fn spawn(out: &std::path::Path, args: Vec<String>, label: &'static str) -> Result<Capture, String> {
133    if out.as_os_str().is_empty() {
134        return Err("No output file.".into());
135    }
136    if let Some(dir) = out.parent().filter(|d| !d.as_os_str().is_empty()) {
137        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
138    }
139    let exe = ffpipe::ffmpeg_exe().ok_or("ffmpeg.exe not found")?;
140    let mut cmd = ffpipe::command(&exe);
141    cmd.args(&args);
142    cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
143    let mut child = cmd.spawn().map_err(|e| format!("ffmpeg: {e}"))?;
144
145    let stdin = child.stdin.take();
146    let stdout = child.stdout.take();
147    let tail = export::stderr_tail(&mut child);
148    let progress = Progress::new();
149    let graceful = Arc::new(AtomicBool::new(false));
150    let shared = Arc::new(Mutex::new(Some(child)));
151
152    let (p, g, c) = (progress.clone(), graceful.clone(), shared.clone());
153    let watcher = std::thread::Builder::new().name("capture".into()).spawn(move || {
154        p.set(0.0, format!("{label}…"));
155        if let Some(out) = stdout {
156            // blocks until ffmpeg exits (or is killed) — no polling loop
157            for line in std::io::BufReader::new(out).lines() {
158                let Ok(line) = line else { break };
159                if let Some(us) = line.strip_prefix("out_time_us=").and_then(|v| v.trim().parse::<f64>().ok()) {
160                    p.set(0.0, format!("{label} {:.1} s", (us / 1e6).max(0.0)));
161                }
162            }
163        }
164        let status = c.lock().ok().and_then(|mut g| g.take()).map(|mut ch| ch.wait());
165        let msg = tail.and_then(|t| t.join().ok()).unwrap_or_default();
166        let graceful = g.load(Ordering::SeqCst);
167        p.finish(match status {
168            // `q` makes ffmpeg exit non-zero (255) even though the file is finalised
169            _ if graceful => None,
170            Some(Ok(st)) if st.success() => None,
171            _ if p.is_cancelled() => Some(export::CANCELLED.to_string()),
172            Some(Ok(st)) if msg.is_empty() => Some(format!("ffmpeg exited with {st}")),
173            Some(Ok(_)) => Some(msg),
174            Some(Err(e)) => Some(format!("ffmpeg: {e}")),
175            None => Some("capture process lost".into()),
176        });
177    });
178    if let Err(e) = watcher {
179        if let Ok(mut g) = shared.lock() {
180            if let Some(ch) = g.as_mut() {
181                let _ = ch.kill();
182            }
183        }
184        return Err(format!("could not start the capture thread: {e}"));
185    }
186    Ok(Capture { progress, child: shared, stdin, start: Instant::now(), graceful })
187}
188
189fn s(v: impl Into<String>) -> String {
190    v.into()
191}
192
193/// ffmpeg arguments for a screen recording. `loopback` is the dshow device used for desktop audio
194/// (already resolved by the caller); mic and loopback are mixed when both are present.
195pub(crate) fn screen_args(o: &ScreenCaptureOptions, loopback: Option<&str>) -> Vec<String> {
196    let mut a: Vec<String> =
197        ["-y", "-hide_banner", "-loglevel", "error", "-progress", "pipe:1"].iter().map(|x| s(*x)).collect();
198    a.extend([s("-f"), s("gdigrab"), s("-framerate"), o.fps.max(1).to_string()]);
199    a.extend([s("-draw_mouse"), s(if o.cursor { "1" } else { "0" })]);
200    if let Some((x, y, w, h)) = o.region {
201        // yuv420p needs even dimensions and gdigrab has no scaler
202        let (w, h) = (w.max(2) & !1, h.max(2) & !1);
203        a.extend([s("-offset_x"), x.to_string(), s("-offset_y"), y.to_string()]);
204        a.extend([s("-video_size"), format!("{w}x{h}")]);
205    }
206    a.extend([s("-i"), s("desktop")]);
207
208    let mic = o.mic.trim();
209    let loop_dev = loopback.map(str::trim).filter(|d| !d.is_empty() && o.desktop_audio);
210    let mut inputs = 0;
211    for dev in [(!mic.is_empty()).then_some(mic), loop_dev].into_iter().flatten() {
212        a.extend([s("-f"), s("dshow"), s("-i"), format!("audio={dev}")]);
213        inputs += 1;
214    }
215    match inputs {
216        0 => a.extend([s("-map"), s("0:v")]),
217        1 => a.extend([s("-map"), s("0:v"), s("-map"), s("1:a")]),
218        _ => a.extend([
219            s("-filter_complex"),
220            s("[1:a][2:a]amix=inputs=2:duration=longest[aout]"),
221            s("-map"),
222            s("0:v"),
223            s("-map"),
224            s("[aout]"),
225        ]),
226    }
227    a.extend([s("-c:v"), s("libx264"), s("-preset"), s("veryfast"), s("-pix_fmt"), s("yuv420p")]);
228    if o.bitrate_kbps > 0 {
229        a.extend([s("-b:v"), format!("{}k", o.bitrate_kbps)]);
230    } else {
231        a.extend([s("-crf"), o.crf.clamp(0, 51).to_string()]);
232    }
233    if inputs > 0 {
234        a.extend([s("-c:a"), s("aac"), s("-b:a"), s("192k")]);
235    }
236    a.push(o.out.to_string_lossy().into_owned());
237    a
238}
239
240/// ffmpeg arguments for a voiceover take (WAV, so a cut-off take is still readable).
241pub(crate) fn voice_args(o: &VoiceoverOptions) -> Vec<String> {
242    let mut a: Vec<String> =
243        ["-y", "-hide_banner", "-loglevel", "error", "-progress", "pipe:1"].iter().map(|x| s(*x)).collect();
244    a.extend([s("-f"), s("dshow"), s("-i"), format!("audio={}", o.device.trim())]);
245    a.extend([s("-ac"), o.channels.clamp(1, 2).to_string()]);
246    a.extend([s("-ar"), if o.sample_rate == 0 { s("48000") } else { o.sample_rate.to_string() }]);
247    a.push(o.out.to_string_lossy().into_owned());
248    a
249}
250
251/// Names that only ever belong to a loopback / "what you hear" input.
252const LOOPBACK_HINTS: [&str; 10] = [
253    "stereo mix",
254    "what u hear",
255    "wave out",
256    "loopback",
257    "cable output",
258    "vb-audio",
259    "voicemeeter",
260    "virtual audio",
261    "monitor of",
262    "speakers",
263];
264
265/// dshow audio input devices (`ffmpeg -list_devices true -f dshow -i dummy`), cached.
266/// The bool marks devices that look like desktop/loopback capture.
267///
268/// ponytail: probed once per process — the UI polls this every frame while the recorder/settings
269/// windows are open and each probe spawns ffmpeg (~250 ms), so it must not re-run per frame. A device
270/// plugged in (or Stereo Mix enabled) mid-session therefore needs a restart; swap the `OnceLock` for a
271/// `Mutex<Option<_>>` when there is a "Refresh" button next to the device combos to clear it.
272pub fn audio_devices() -> Vec<(String, bool)> {
273    static CACHE: OnceLock<Vec<(String, bool)>> = OnceLock::new();
274    CACHE
275        .get_or_init(|| {
276            let Some(exe) = ffpipe::ffmpeg_exe() else { return Vec::new() };
277            // listing devices always "fails" (dummy is not a real device); the list is on stderr
278            match ffpipe::command(&exe)
279                .args(["-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"])
280                .stdin(Stdio::null())
281                .output()
282            {
283                Ok(o) => parse_devices(&String::from_utf8_lossy(&o.stderr)),
284                Err(_) => Vec::new(),
285            }
286        })
287        .clone()
288}
289
290/// The first device that looks like desktop audio, if any.
291pub fn loopback_device() -> Option<String> {
292    audio_devices().into_iter().find(|(_, lb)| *lb).map(|(n, _)| n)
293}
294
295/// Audio device names out of ffmpeg's `-list_devices` output (both the modern `"name" (audio)` layout
296/// and the older `DirectShow audio devices` section headers).
297pub(crate) fn parse_devices(stderr: &str) -> Vec<(String, bool)> {
298    let mut out: Vec<(String, bool)> = Vec::new();
299    let mut in_audio = false;
300    for raw in stderr.lines() {
301        // strip the "[dshow @ 0000...] " log prefix
302        let line = match (raw.trim_start().starts_with('['), raw.find("] ")) {
303            (true, Some(i)) => &raw[i + 2..],
304            _ => raw,
305        }
306        .trim();
307        let lower = line.to_ascii_lowercase();
308        if lower.contains("directshow audio devices") {
309            in_audio = true;
310            continue;
311        }
312        if lower.contains("directshow video devices") {
313            in_audio = false;
314            continue;
315        }
316        if lower.starts_with("alternative name") {
317            continue;
318        }
319        let Some(rest) = line.strip_prefix('"') else { continue };
320        let Some(end) = rest.find('"') else { continue };
321        let name = &rest[..end];
322        let tail = rest[end + 1..].trim().to_ascii_lowercase();
323        let is_audio = if tail.contains("(audio)") {
324            true
325        } else if tail.contains("(video)") {
326            false
327        } else {
328            in_audio
329        };
330        if !is_audio || name.is_empty() || out.iter().any(|(n, _)| n == name) {
331            continue;
332        }
333        let l = name.to_ascii_lowercase();
334        out.push((name.to_string(), LOOPBACK_HINTS.iter().any(|h| l.contains(h))));
335    }
336    out
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    /// Real `ffmpeg -list_devices true -f dshow -i dummy` output (ffmpeg 7.1, Windows 11), including the
344    /// chatter some virtual-camera drivers print before the listing.
345    const LISTING: &str = r#"I2026-08-21 19:11:27.057487 (26856) [INFO] [VCAMDS] ffmpeg.exe
346E2026-08-21 19:11:27.074296 (26856)  [ERR] [VCAMDS] Failed to open NBX hive
347[dshow @ 000001e0c1b0f900] "Integrated Camera" (video)
348[dshow @ 000001e0c1b0f900]   Alternative name "@device_pnp_\\?\usb#vid_04f2&pid_b6d9&mi_00#6&1e1b7d3f&0&0000#{65e8773d-8f56-11d0-a3b9-00a0c9223196}\global"
349[dshow @ 000001e0c1b0f900] "OBS Virtual Camera" (video)
350[dshow @ 000001e0c1b0f900]   Alternative name "@device_sw_{860BB310-5D01-11D0-BD3B-00A0C911CE86}\{A3FCE0F5-3493-419F-958A-ABA1250EC20B}"
351[dshow @ 000001e0c1b0f900] "Microphone (Realtek(R) Audio)" (audio)
352[dshow @ 000001e0c1b0f900]   Alternative name "@device_cm_{33D9A762-90C8-11D0-BD43-00A0C911CE86}\wave_{B1F4F9E6-1A0C-4E0D-9C6F-2A9C3D5E7A11}"
353[dshow @ 000001e0c1b0f900] "Stereo Mix (Realtek(R) Audio)" (audio)
354[dshow @ 000001e0c1b0f900]   Alternative name "@device_cm_{33D9A762-90C8-11D0-BD43-00A0C911CE86}\wave_{2F0E1A22-7C3B-4A55-8D9E-0C1B2D3E4F50}"
355[dshow @ 000001e0c1b0f900] "CABLE Output (VB-Audio Virtual Cable)" (audio)
356[in#0 @ 000001e0c1b0e2c0] Error opening input: Immediate exit requested
357Error opening input file dummy.
358"#;
359
360    /// The pre-4.4 layout: section headers instead of a "(audio)" suffix.
361    const OLD_LISTING: &str = r#"[dshow @ 0000021d] DirectShow video devices (some may be both video and audio devices)
362[dshow @ 0000021d]  "Integrated Camera"
363[dshow @ 0000021d]     Alternative name "@device_pnp_\\?\usb#vid_04f2"
364[dshow @ 0000021d] DirectShow audio devices
365[dshow @ 0000021d]  "Microphone (2- USB Audio Device)"
366[dshow @ 0000021d]     Alternative name "@device_cm_{33D9A762}\wave_{7C0EF}"
367"#;
368
369    #[test]
370    fn devices_parse_audio_only_and_mark_loopback() {
371        let d = parse_devices(LISTING);
372        let names: Vec<&str> = d.iter().map(|(n, _)| n.as_str()).collect();
373        assert_eq!(
374            names,
375            vec![
376                "Microphone (Realtek(R) Audio)",
377                "Stereo Mix (Realtek(R) Audio)",
378                "CABLE Output (VB-Audio Virtual Cable)"
379            ]
380        );
381        assert_eq!(d.iter().map(|(_, lb)| *lb).collect::<Vec<_>>(), vec![false, true, true]);
382        // the "Alternative name" lines and both cameras are never offered as inputs
383        assert!(!names.iter().any(|n| n.contains("device_") || n.contains("Camera")));
384
385        let old = parse_devices(OLD_LISTING);
386        assert_eq!(old, vec![("Microphone (2- USB Audio Device)".to_string(), false)]);
387        assert!(parse_devices("").is_empty());
388    }
389
390    fn opts() -> ScreenCaptureOptions {
391        ScreenCaptureOptions { out: PathBuf::from(r"C:\rec\a.mp4"), ..Default::default() }
392    }
393
394    /// Args as one string, so tests can look for "-flag value" pairs.
395    fn joined(a: &[String]) -> String {
396        a.join(" ")
397    }
398
399    #[test]
400    fn screen_args_desktop_bitrate() {
401        let a = screen_args(&opts(), None);
402        let j = joined(&a);
403        assert!(j.contains("-f gdigrab -framerate 30"), "{j}");
404        assert!(j.contains("-draw_mouse 1") && j.ends_with(r"C:\rec\a.mp4"), "{j}");
405        assert!(j.contains("-i desktop") && j.contains("-map 0:v"));
406        assert!(j.contains("-b:v 8000k") && !j.contains("-crf"), "{j}");
407        assert!(!j.contains("dshow") && !j.contains("-c:a"), "no audio inputs → no audio codec: {j}");
408        assert!(j.contains("-c:v libx264 -preset veryfast -pix_fmt yuv420p"), "{j}");
409        assert!(!j.contains("-offset_x"), "whole desktop takes no offset");
410    }
411
412    #[test]
413    fn screen_args_crf_when_bitrate_zero() {
414        let o = ScreenCaptureOptions { bitrate_kbps: 0, crf: 23, cursor: false, ..opts() };
415        let j = joined(&screen_args(&o, None));
416        assert!(j.contains("-crf 23") && !j.contains("-b:v"), "{j}");
417        assert!(j.contains("-draw_mouse 0"), "{j}");
418    }
419
420    #[test]
421    fn screen_args_region_is_even_sized() {
422        let o = ScreenCaptureOptions { region: Some((100, 50, 641, 481)), ..opts() };
423        let j = joined(&screen_args(&o, None));
424        assert!(j.contains("-offset_x 100 -offset_y 50 -video_size 640x480"), "{j}");
425        // negative offsets (a monitor left of the primary) survive
426        let o = ScreenCaptureOptions { region: Some((-1920, 0, 1920, 1080)), ..opts() };
427        assert!(joined(&screen_args(&o, None)).contains("-offset_x -1920"));
428    }
429
430    #[test]
431    fn screen_args_mic_only_maps_one_audio_input() {
432        let o = ScreenCaptureOptions { mic: "Microphone (Realtek(R) Audio)".into(), ..opts() };
433        let j = joined(&screen_args(&o, None));
434        assert!(j.contains("-f dshow -i audio=Microphone (Realtek(R) Audio)"), "{j}");
435        assert!(j.contains("-map 0:v -map 1:a") && !j.contains("amix"), "{j}");
436        assert!(j.contains("-c:a aac"), "{j}");
437        // desktop audio requested but no loopback device → the mic stays the only input
438        let want = screen_args(&ScreenCaptureOptions { desktop_audio: true, ..o.clone() }, None);
439        assert_eq!(want, screen_args(&o, None));
440    }
441
442    #[test]
443    fn screen_args_mic_plus_desktop_mixes() {
444        let o = ScreenCaptureOptions { mic: "Mic".into(), desktop_audio: true, ..opts() };
445        let j = joined(&screen_args(&o, Some("Stereo Mix")));
446        assert_eq!(j.matches("-f dshow").count(), 2, "{j}");
447        assert!(j.contains("-i audio=Mic ") && j.contains("-i audio=Stereo Mix"), "{j}");
448        assert!(j.contains("[1:a][2:a]amix=inputs=2:duration=longest[aout]"), "{j}");
449        assert!(j.contains("-map 0:v -map [aout]"), "{j}");
450        // desktop audio alone is input 1, not 2
451        let j = joined(&screen_args(&ScreenCaptureOptions { mic: String::new(), ..o }, Some("Stereo Mix")));
452        assert!(j.contains("-map 0:v -map 1:a") && !j.contains("amix"), "{j}");
453    }
454
455    #[test]
456    fn voice_args_wav() {
457        let o = VoiceoverOptions { out: PathBuf::from(r"C:\rec\vo.wav"), device: " Mic ".into(), ..Default::default() };
458        let j = joined(&voice_args(&o));
459        assert!(j.contains("-f dshow -i audio=Mic"), "device name is trimmed: {j}");
460        assert!(j.contains("-ac 1 -ar 48000") && j.ends_with(r"C:\rec\vo.wav"), "{j}");
461        let clamped = voice_args(&VoiceoverOptions { channels: 7, sample_rate: 0, ..o });
462        assert!(joined(&clamped).contains("-ac 2 -ar 48000"), "channels are clamped to stereo");
463    }
464
465    /// Hardware check (`cargo test -- --ignored capture_records`): records the desktop for ~2 s and
466    /// stops it with `q`. Needs ffmpeg, a real desktop session and writes into the temp dir.
467    #[test]
468    #[ignore]
469    fn capture_records_a_real_file() {
470        let out = std::env::temp_dir().join(format!("se-capture-{}.mp4", std::process::id()));
471        let cap = start_screen(ScreenCaptureOptions {
472            out: out.clone(),
473            fps: 10,
474            region: Some((0, 0, 320, 240)),
475            ..Default::default()
476        })
477        .expect("start");
478        let prog = cap.progress();
479        std::thread::sleep(std::time::Duration::from_millis(2000));
480        assert!(cap.elapsed() >= 1.9, "elapsed tracks the recording");
481        cap.stop();
482        for _ in 0..100 {
483            if prog.is_done() {
484                break;
485            }
486            std::thread::sleep(std::time::Duration::from_millis(100));
487        }
488        assert!(prog.is_done(), "the recorder finished: {}", prog.status());
489        assert_eq!(prog.error(), None);
490        let len = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
491        let _ = std::fs::remove_file(&out);
492        assert!(len > 1000, "wrote a real mp4 ({len} bytes)");
493    }
494
495    /// No hardware, no ffmpeg needed: an empty device/output is refused before anything is spawned.
496    #[test]
497    fn empty_inputs_are_refused() {
498        assert!(start_voiceover(VoiceoverOptions::default()).is_err());
499        assert!(start_screen(ScreenCaptureOptions::default()).is_err());
500    }
501}