use std::time::Duration;
use crate::cli::{self, CutArgs};
use crate::ffmpeg::{self, CutConfig};
use crate::{output, terminal};
const SHORT_CUT_TOLERANCE: Duration = Duration::from_millis(250);
pub fn run(args: CutArgs) -> anyhow::Result<()> {
if !args.source.exists() {
anyhow::bail!("Source file not found: {}", args.source.display());
}
if !args.source.is_file() {
anyhow::bail!("Source is not a file: {}", args.source.display());
}
let range = args.validate_cut_range().map_err(|e| anyhow::anyhow!(e))?;
let output_path = output::resolve_cut_output_path(&args.source, args.output.as_deref())?;
let config =
CutConfig::new(&args.source, range.start, range.length, &output_path).with_fast(args.fast);
let ffmpeg_run = ffmpeg::run_to_completion(ffmpeg::build_cut_command(&config), args.verbose);
let (status, stderr) = match ffmpeg_run {
Ok(run) => run,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
anyhow::bail!("ffmpeg not found. Install it with: brew install ffmpeg");
}
Err(e) => {
remove_partial_cut(&output_path);
return Err(e.into());
}
};
if !status.success() {
remove_partial_cut(&output_path);
if args.verbose {
anyhow::bail!("ffmpeg exited with {}", status);
}
anyhow::bail!("ffmpeg failed:\n{}", stderr);
}
if let Some(written) = parse_written_length(&stderr)
&& range.length.saturating_sub(written) > SHORT_CUT_TOLERANCE
{
terminal::print_warning(&format!(
"cut range extends past end of source (requested {}ms, wrote {}ms)",
range.length.as_millis(),
written.as_millis()
));
}
terminal::print_cut_saved(&output_path);
Ok(())
}
fn remove_partial_cut(path: &std::path::Path) {
if path.exists() {
let _ = std::fs::remove_file(path);
}
}
fn parse_written_length(stderr: &str) -> Option<Duration> {
stderr.split(['\n', '\r']).rev().find_map(|chunk| {
let token: String = chunk
.split_once("time=")?
.1
.chars()
.take_while(|c| c.is_ascii_digit() || *c == ':' || *c == '.')
.collect();
let timespec = if token.contains(':') {
token
} else {
format!("{}s", token)
};
cli::parse_timespec(×pec).ok()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_written_length_reads_hms_token() {
assert_eq!(
parse_written_length("frame= 1 time=00:00:15.000 bitrate=N/A"),
Some(Duration::from_secs(15))
);
}
#[test]
fn parse_written_length_reads_minutes_seconds_token() {
assert_eq!(
parse_written_length("time=01:30.500 speed=1x"),
Some(Duration::from_millis(90_500))
);
}
#[test]
fn parse_written_length_finds_last_of_many_newline_separated_updates() {
let stderr = "frame= 120 size= 1024kB time=00:00:05.000\n\
frame= 240 size= 2048kB time=00:00:10.000\n\
frame= 360 size= 3072kB time=00:00:15.000";
assert_eq!(parse_written_length(stderr), Some(Duration::from_secs(15)));
}
#[test]
fn parse_written_length_finds_last_of_carriage_return_separated_updates() {
let stderr = "frame= 120 time=00:00:05.00 speed=1x\r\
frame= 240 time=00:00:10.00 speed=1x\r\
frame= 360 time=00:00:16.50 speed=1x\r\
frame= 480 time=00:00:49.43 speed=1x";
assert_eq!(
parse_written_length(stderr),
Some(Duration::from_millis(49_430))
);
}
#[test]
fn parse_written_length_ignores_trailing_summary_without_progress() {
let stderr = "frame= 120 time=00:00:05.00 speed=1x\r\
frame= 240 time=00:00:12.00 speed=1x\n\
[out#0] video:1024kB audio:64kB muxing overhead: 0.4%\n";
assert_eq!(parse_written_length(stderr), Some(Duration::from_secs(12)));
}
#[test]
fn parse_written_length_without_time_token() {
assert_eq!(parse_written_length("frame= 120 fps=0.0 q=28.0"), None);
assert_eq!(parse_written_length(""), None);
}
}