use anyhow::{anyhow, Context};
use duct::cmd;
use lazy_static::lazy_static;
use log::*;
use regex::Regex;
use scopeguard::defer;
use std::{
convert::Into,
ffi::OsString,
fmt::{self, Display},
fs::{self, File},
io::Write,
path::{Path, PathBuf},
process::Output,
str::{self, from_utf8},
};
use super::{
info::Metadata,
interval::{Boundary, Frame, Interval, Time},
};
const CUTTING_DIR_PREFIX: &str = "cutting";
macro_rules! interval_file_name_tmpl {
() => {
"part-{:03}-{}.{}"
};
}
pub enum SubIntervalID {
Pre,
Main,
Post,
}
impl Display for SubIntervalID {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
match self {
SubIntervalID::Pre => 1,
SubIntervalID::Main => 2,
SubIntervalID::Post => 3,
}
)
}
}
lazy_static! {
static ref RE_INTERVAL_FILE_NAME: Regex =
Regex::new(r"^part-\d{3}-\d\..*$").unwrap();
}
pub fn cut<I, O, T, B>(
in_video: I,
out_video: O,
tmp_dir: T,
intervals: std::slice::Iter<'_, Interval<B>>,
metadata: &Metadata,
) -> anyhow::Result<()>
where
I: AsRef<Path>,
O: AsRef<Path>,
T: AsRef<Path>,
B: Boundary,
{
let err_msg = "Could not cut video with ffmpeg";
let cutting_dir = create_cutting_dir(&in_video, tmp_dir).context(err_msg)?;
defer! {
fs::remove_dir_all(cutting_dir.clone()).unwrap_or_else(|_| panic!("Cannot remove cutting directory"));
trace!("Removed cutting directory \"{}\"", cutting_dir.display());
}
for (i, interval) in (1..).zip(intervals) {
trace!("Processing interval no {} ...", i);
extract_interval(&in_video, &cutting_dir, metadata, interval, i).context(err_msg)?;
}
concatenate_intervals(&out_video, &cutting_dir).context(err_msg)?;
Ok(())
}
fn create_cutting_dir<P, T>(path: P, tmp_dir: T) -> anyhow::Result<PathBuf>
where
P: AsRef<Path>,
T: AsRef<Path>,
{
let file_name = path.as_ref().file_name().unwrap().to_str().unwrap();
let cutting_dir = tmp_dir
.as_ref()
.join(format!("{}-{}", CUTTING_DIR_PREFIX, file_name));
fs::create_dir_all(&cutting_dir).context(format!(
"Could not create cutting directory for \"{}\"",
file_name
))?;
trace!("Created cutting directory \"{}\"", cutting_dir.display());
Ok(cutting_dir)
}
fn extract_interval<I, C, B>(
in_video: I,
cutting_dir: C,
metadata: &Metadata,
interval: &Interval<B>,
interval_no: usize,
) -> anyhow::Result<()>
where
I: AsRef<Path>,
C: AsRef<Path>,
B: Boundary,
{
trace!("Extracting interval {} ...", interval);
let err_msg = format!("Could not extract interval {}", interval);
if metadata.has_frames() {
let interval_f = interval.to_frames(metadata).context(err_msg.clone())?;
trace!("Converted interval to frames: {}", interval_f);
if let Some(interval_kf) = interval_f.to_key_frames(metadata) {
trace!("Converted interval to key frames: {}", interval_kf,);
if interval_f.from() < interval_kf.from() {
let interval =
Interval::<Frame>::from_from_to(interval_f.from(), interval_kf.from() - 1);
if !interval.is_empty() {
encode_interval(
&in_video,
&cutting_dir,
metadata,
interval.to_times(metadata)?,
interval_no,
SubIntervalID::Pre,
)
.context(err_msg.clone())?;
}
}
copy_interval(
&in_video,
&cutting_dir,
interval_kf.to_times(metadata).context(err_msg.clone())?,
interval_no,
)
.context(err_msg.clone())?;
if interval_f.to() > interval_kf.to() {
let interval =
Interval::<Frame>::from_from_to(interval_kf.to() + 1, interval_f.to());
if !interval.is_empty() {
encode_interval(
&in_video,
&cutting_dir,
metadata,
interval.to_times(metadata).context(err_msg.clone())?,
interval_no,
SubIntervalID::Post,
)
.context(err_msg.clone())?;
}
}
} else {
encode_interval(
&in_video,
&cutting_dir,
metadata,
interval.to_times(metadata).context(err_msg.clone())?,
interval_no,
SubIntervalID::Main,
)
.context(err_msg)?;
}
} else {
copy_interval(
&in_video,
&cutting_dir,
interval.to_times(metadata).unwrap(),
interval_no,
)
.context(err_msg)?;
}
trace!("Extracted interval");
Ok(())
}
fn copy_interval<I, C>(
in_video: I,
cutting_dir: C,
interval: Interval<Time>,
interval_no: usize,
) -> anyhow::Result<()>
where
I: AsRef<Path>,
C: AsRef<Path>,
{
trace!("Copying interval {} ...", interval);
let output: Output = cmd!(
"ffmpeg",
"-y",
"-i",
in_video.as_ref().as_os_str(),
"-ss",
format!("{}", interval.from()),
"-t",
format!("{}", interval.len()),
"-c",
"copy",
cutting_dir
.as_ref()
.join(format!(
interval_file_name_tmpl!(),
interval_no,
SubIntervalID::Main,
in_video.as_ref().extension().unwrap().to_str().unwrap()
))
.as_os_str()
)
.stdout_null()
.stderr_capture()
.unchecked()
.run()
.context(format!(
"Could not execute ffmpeg to copy interval {}",
interval
))?;
if !output.status.success() {
return Err(anyhow!("ffmpeg: {}", from_utf8(&output.stderr).unwrap())
.context(format!("Could not copy interval {}", interval)));
}
trace!("Copied interval");
Ok(())
}
fn encode_interval<I, C>(
in_video: I,
cutting_dir: C,
metadata: &Metadata,
interval: Interval<Time>,
interval_no: usize,
sub_interval_id: SubIntervalID,
) -> anyhow::Result<()>
where
I: AsRef<Path>,
C: AsRef<Path>,
{
trace!("Re-encoding interval {} ...", interval);
let mut args: Vec<OsString> = vec![
"-y".into(),
"-i".into(),
in_video.as_ref().into(),
"-ss".into(),
format!("{}", interval.from()).into(),
"-t".into(),
format!("{}", interval.len()).into(),
];
for stream in metadata.streams() {
args.push(format!("-c:{}", stream.index()).into());
let encoding: OsString = if stream.codec().is_none() {
"copy".to_string()
} else {
stream.codec().unwrap()
}
.into();
args.push(encoding);
}
args.push(
cutting_dir
.as_ref()
.join(format!(
interval_file_name_tmpl!(),
interval_no,
sub_interval_id,
in_video.as_ref().extension().unwrap().to_str().unwrap()
))
.into(),
);
let output: Output = cmd("ffmpeg", args)
.stdout_null()
.stderr_capture()
.unchecked()
.run()
.context("Could not execute ffmpeg to re-encode interval")?;
if !output.status.success() {
return Err(anyhow!("ffmpeg: {}", from_utf8(&output.stderr).unwrap())
.context("Could not re-encode interval"));
}
trace!("Re-encoded interval");
Ok(())
}
fn concatenate_intervals<O, C>(out_video: O, cutting_dir: C) -> anyhow::Result<()>
where
O: AsRef<Path>,
C: AsRef<Path>,
{
trace!("Concatenating interval files ...");
let mut parts: Vec<_> = fs::read_dir(&cutting_dir)
.context(format!(
"Cannot read cutting directory \"{}\"",
cutting_dir.as_ref().display()
))?
.filter_map(|entry| {
if let Ok(entry) = entry {
if let Ok(file_type) = entry.file_type() {
if file_type.is_file() {
let file_name = entry.file_name().into_string().unwrap();
if RE_INTERVAL_FILE_NAME.is_match(&file_name) {
debug!("Found interval file \"{}\"", entry.path().display());
Some(file_name)
} else {
None
}
} else {
None
}
} else {
None
}
} else {
error!(
"Error reading entry from cutting directory \"{}\"",
cutting_dir.as_ref().display()
);
None
}
})
.collect();
parts.sort();
match parts.len() {
0 => {
Err(anyhow!("No interval files found. Nothing to concatenate"))
}
1 => {
fs::rename(cutting_dir.as_ref().join(&parts[0]), out_video).context(format!(
"Cannot move cut internal \"{}\" to cut directory",
&parts[0]
))?;
Ok(())
}
_ => {
let index_file_path = cutting_dir.as_ref().join("index.txt");
let mut index_file: File =
File::create(&index_file_path).context("Cannot create index file")?;
for file_name in parts {
writeln!(index_file, "file '{}'", file_name)
.context(format!("Cannot write \"{}\" to index file", file_name))?;
}
let output: Output = cmd!(
"ffmpeg",
"-y",
"-f",
"concat",
"-i",
index_file_path.as_os_str(),
"-c",
"copy",
out_video.as_ref().as_os_str()
)
.stdout_null()
.stderr_capture()
.unchecked()
.run()
.context("Could not execute ffmpeg to concatenate files")?;
if !output.status.success() {
Err(anyhow!("ffmpeg: {}", from_utf8(&output.stderr).unwrap())
.context("Could not concatenate files"))
} else {
Ok(())
}
}
}
}