1use 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 pub bitrate_kbps: u32,
29 pub crf: u32,
30 pub region: Option<(i32, i32, u32, u32)>,
32 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
67pub struct Capture {
69 progress: Arc<Progress>,
70 child: Arc<Mutex<Option<Child>>>,
72 stdin: Option<ChildStdin>,
74 start: Instant,
75 graceful: Arc<AtomicBool>,
77}
78
79impl Capture {
80 pub fn progress(&self) -> Arc<Progress> {
81 self.progress.clone()
82 }
83 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 }
93 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 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
131fn 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 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 _ 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
193pub(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 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
240pub(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
251const 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
265pub 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 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
290pub fn loopback_device() -> Option<String> {
292 audio_devices().into_iter().find(|(_, lb)| *lb).map(|(n, _)| n)
293}
294
295pub(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 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 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 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 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 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 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 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 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 #[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 #[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}