1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
mod capture;
mod cli;
mod cut;
mod ffmpeg;
mod label;
mod output;
mod terminal;
use std::time::Duration;
use chrono::Local;
use clap::Parser;
use capture::{CaptureSession, RealFfmpegProcess};
use cli::{Args, Command, StartArgs};
use ffmpeg::CaptureConfig;
fn main() {
let args = Args::parse();
let result = match args.command {
Command::Start(start_args) => run_capture(start_args),
Command::Cut(cut_args) => cut::run(cut_args),
Command::Label(label_args) => label::run(label_args),
Command::Help => {
terminal::print_help();
Ok(())
}
};
if let Err(e) = result {
terminal::print_error(&e.to_string());
std::process::exit(1);
}
}
fn run_capture(args: StartArgs) -> anyhow::Result<()> {
// Resolve output directory (creating it if -o is set), generate the
// timestamped filename, and pick a non-colliding final path.
let timestamp = Local::now();
let path = output::prepare_output_path(args.output.as_deref(), ×tamp)?;
// Detect avfoundation devices and resolve BlackHole + microphone indices.
// Required for the mixed-audio capture pipeline from issue #3 (system +
// mic). If BlackHole is missing, fail fast with setup instructions rather
// than letting ffmpeg die with an opaque error.
let audio = match ffmpeg::detect_audio_setup() {
Ok(audio) => Some(audio),
Err(diag) => {
anyhow::bail!("{}\n\n{}", diag, ffmpeg::blackhole_setup_instructions());
}
};
// Build ffmpeg config
let mut ffmpeg_config = CaptureConfig::new(path.to_string_lossy().to_string())
.with_verbose(args.verbose);
if let Some(a) = audio {
ffmpeg_config = ffmpeg_config.with_audio(a);
}
let ffmpeg_config = match args.duration {
Some(d) => ffmpeg_config.with_duration(d),
None => ffmpeg_config,
};
let ffmpeg_config = match args.every {
Some(e) => ffmpeg_config.with_interval(e),
None => ffmpeg_config,
};
// Create and start capture session
let process = RealFfmpegProcess::new(ffmpeg_config);
let mut session = CaptureSession::new(Box::new(process), args.duration);
session.start()?;
// Keep raw mode active for the whole session. The listener's Drop
// implementation restores the terminal even when capture returns an error.
let key_listener = terminal::StopKeyListener::new();
// Render the first status line. Subsequent ticks overwrite it in-place via
// a carriage return; the line already includes the "press s to stop"
// prompt so a separate static "Capturing..." line is no longer needed.
terminal::print_capture_status(session.elapsed(), session.duration());
// Poll for 's' key or duration expiry.
loop {
if key_listener.wait_for_stop_key(Duration::from_millis(100))? {
// User pressed 's' - send SIGINT to ffmpeg so it can finalize MP4.
session.stop()?;
break;
}
if session.check_and_stop_if_expired()? {
// Duration expired - ffmpeg should exit naturally, just wait.
break;
}
if session.has_exited() {
// Do not wait forever if ffmpeg failed before the user pressed 's'.
break;
}
// Refresh the on-screen elapsed/remaining counter on each tick.
terminal::print_capture_status(session.elapsed(), session.duration());
}
// Move to a fresh line so the final "Saved to ..." message doesn't land
// mid-status-line when the terminal overwrites with carriage returns.
eprintln!();
// Wait for ffmpeg to finish and check its exit code
session.finish()?;
if args.every.is_some() {
// `path` is the .mp4 base name; ffmpeg actually wrote a family of
// files following the segment pattern. Show a shell-glob so the
// user can copy/paste it (e.g. `ls ${Saved to ...}`).
let display = output::segment_display_pattern(&path);
terminal::print_saved(&display);
} else {
terminal::print_saved(&path);
}
Ok(())
}