Merge pull request #78 from crunchy-labs/feature/ffmpeg-optimizations

Add optional ffmpeg optimizations
This commit is contained in:
ByteDream 2022-12-08 14:44:46 +01:00 committed by GitHub
commit 5c3f49e9f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 272 additions and 43 deletions

View file

@ -1,5 +1,5 @@
use crate::cli::log::tab_info;
use crate::cli::utils::{download_segments, find_resolution};
use crate::cli::utils::{download_segments, FFmpegPreset, find_resolution};
use crate::utils::context::Context;
use crate::utils::format::{format_string, Format};
use crate::utils::log::progress;
@ -8,9 +8,10 @@ use crate::utils::parse::{parse_url, UrlFilter};
use crate::utils::sort::{sort_formats_after_seasons, sort_seasons_after_number};
use crate::Execute;
use anyhow::{bail, Result};
use chrono::NaiveTime;
use crunchyroll_rs::media::{Resolution, StreamSubtitle};
use crunchyroll_rs::{Locale, Media, MediaCollection, Series};
use log::{debug, error, info};
use log::{debug, error, info, warn};
use regex::Regex;
use std::collections::BTreeMap;
use std::io::Write;
@ -25,13 +26,15 @@ pub enum MergeBehavior {
Video,
}
fn parse_merge_behavior(s: &str) -> Result<MergeBehavior, String> {
Ok(match s.to_lowercase().as_str() {
"auto" => MergeBehavior::Auto,
"audio" => MergeBehavior::Audio,
"video" => MergeBehavior::Video,
_ => return Err(format!("'{}' is not a valid merge behavior", s)),
})
impl MergeBehavior {
fn parse(s: &str) -> Result<MergeBehavior, String> {
Ok(match s.to_lowercase().as_str() {
"auto" => MergeBehavior::Auto,
"audio" => MergeBehavior::Audio,
"video" => MergeBehavior::Video,
_ => return Err(format!("'{}' is not a valid merge behavior", s)),
})
}
}
#[derive(Debug, clap::Parser)]
@ -87,9 +90,19 @@ pub struct Archive {
Valid options are 'audio' (stores one video and all other languages as audio only), 'video' (stores the video + audio for every language) and 'auto' (detects if videos differ in length: if so, behave like 'video' else like 'audio')"
)]
#[arg(short, long, default_value = "auto")]
#[arg(value_parser = parse_merge_behavior)]
#[arg(value_parser = MergeBehavior::parse)]
merge: MergeBehavior,
#[arg(help = format!("Presets for video converting. Can be used multiple times. \
Available presets: \n {}", FFmpegPreset::all().into_iter().map(|p| format!("{}: {}", p.to_string(), p.description())).collect::<Vec<String>>().join("\n ")))]
#[arg(long_help = format!("Presets for video converting. Can be used multiple times. \
Generally used to minify the file size with keeping (nearly) the same quality. \
It is recommended to only use this if you archive videos with high resolutions since low resolution videos tend to result in a larger file with any of the provided presets. \
Available presets: \n {}", FFmpegPreset::all().into_iter().map(|p| format!("{}: {}", p.to_string(), p.description())).collect::<Vec<String>>().join("\n ")))]
#[arg(long)]
#[arg(value_parser = FFmpegPreset::parse)]
ffmpeg_preset: Vec<FFmpegPreset>,
#[arg(
help = "Set which subtitle language should be set as default / auto shown when starting a video"
)]
@ -113,9 +126,18 @@ impl Execute for Archive {
fn pre_check(&self) -> Result<()> {
if !has_ffmpeg() {
bail!("FFmpeg is needed to run this command")
} else if PathBuf::from(&self.output).extension().unwrap_or_default().to_string_lossy() != "mkv" {
} else if PathBuf::from(&self.output)
.extension()
.unwrap_or_default()
.to_string_lossy()
!= "mkv"
{
bail!("File extension is not '.mkv'. Currently only matroska / '.mkv' files are supported")
}
let _ = FFmpegPreset::ffmpeg_presets(self.ffmpeg_preset.clone())?;
if self.ffmpeg_preset.len() == 1 && self.ffmpeg_preset.get(0).unwrap() == &FFmpegPreset::Nvidia {
warn!("Skipping 'nvidia' hardware acceleration preset since no other codec preset was specified")
}
Ok(())
}
@ -276,9 +298,13 @@ impl Execute for Archive {
}
}
let (primary_video, _) = video_paths.get(0).unwrap();
let primary_video_length = get_video_length(primary_video.to_path_buf()).unwrap();
for subtitle in subtitles {
subtitle_paths
.push((download_subtitle(&self, subtitle.clone()).await?, subtitle))
subtitle_paths.push((
download_subtitle(&self, subtitle.clone(), primary_video_length).await?,
subtitle,
))
}
generate_mkv(&self, path, video_paths, audio_paths, subtitle_paths)?
@ -387,7 +413,9 @@ async fn download_video(ctx: &Context, format: &Format, only_audio: bool) -> Res
.stdout(Stdio::null())
.stderr(Stdio::piped())
.arg("-y")
.args(["-f", "mpegts", "-i", "pipe:"])
.args(["-f", "mpegts"])
.args(["-i", "pipe:"])
.args(["-c", "copy"])
.args(if only_audio { vec!["-vn"] } else { vec![] })
.arg(path.to_str().unwrap())
.spawn()?;
@ -403,15 +431,20 @@ async fn download_video(ctx: &Context, format: &Format, only_audio: bool) -> Res
Ok(path)
}
async fn download_subtitle(archive: &Archive, subtitle: StreamSubtitle) -> Result<TempPath> {
async fn download_subtitle(
archive: &Archive,
subtitle: StreamSubtitle,
max_length: NaiveTime,
) -> Result<TempPath> {
let tempfile = tempfile(".ass")?;
let (mut file, path) = tempfile.into_parts();
let mut buf = vec![];
subtitle.write_to(&mut buf).await?;
if !archive.no_subtitle_optimizations {
buf = fix_subtitle(buf)
buf = fix_subtitle_look_and_feel(buf)
}
buf = fix_subtitle_length(buf, max_length);
file.write_all(buf.as_slice())?;
@ -421,7 +454,7 @@ async fn download_subtitle(archive: &Archive, subtitle: StreamSubtitle) -> Resul
/// Add `ScaledBorderAndShadows: yes` to subtitles; without it they look very messy on some video
/// players. See [crunchy-labs/crunchy-cli#66](https://github.com/crunchy-labs/crunchy-cli/issues/66)
/// for more information.
fn fix_subtitle(raw: Vec<u8>) -> Vec<u8> {
fn fix_subtitle_look_and_feel(raw: Vec<u8>) -> Vec<u8> {
let mut script_info = false;
let mut new = String::new();
@ -439,6 +472,70 @@ fn fix_subtitle(raw: Vec<u8>) -> Vec<u8> {
new.into_bytes()
}
/// Fix the length of subtitles to a specified maximum amount. This is required because sometimes
/// subtitles have an unnecessary entry long after the actual video ends with artificially extends
/// the video length on some video players. To prevent this, the video length must be hard set. See
/// [crunchy-labs/crunchy-cli#32](https://github.com/crunchy-labs/crunchy-cli/issues/32) for more
/// information.
fn fix_subtitle_length(raw: Vec<u8>, max_length: NaiveTime) -> Vec<u8> {
let re =
Regex::new(r#"^Dialogue:\s\d+,(?P<start>\d+:\d+:\d+\.\d+),(?P<end>\d+:\d+:\d+\.\d+),"#)
.unwrap();
// chrono panics if we try to format NaiveTime with `%2f` and the nano seconds has more than 2
// digits so them have to be reduced manually to avoid the panic
fn format_naive_time(native_time: NaiveTime) -> String {
let formatted_time = native_time.format("%f").to_string();
format!(
"{}.{}",
native_time.format("%T"),
if formatted_time.len() <= 2 {
native_time.format("%2f").to_string()
} else {
formatted_time.split_at(2).0.parse().unwrap()
}
)
}
let length_as_string = format_naive_time(max_length);
let mut new = String::new();
for line in String::from_utf8_lossy(raw.as_slice()).split('\n') {
if let Some(capture) = re.captures(line) {
let start = capture.name("start").map_or(NaiveTime::default(), |s| {
NaiveTime::parse_from_str(s.as_str(), "%H:%M:%S.%f").unwrap()
});
let end = capture.name("end").map_or(NaiveTime::default(), |s| {
NaiveTime::parse_from_str(s.as_str(), "%H:%M:%S.%f").unwrap()
});
if start > max_length {
continue;
} else if end > max_length {
new.push_str(
re.replace(
line,
format!(
"Dialogue: {},{},",
format_naive_time(start),
&length_as_string
),
)
.to_string()
.as_str(),
)
} else {
new.push_str(line)
}
} else {
new.push_str(line)
}
new.push('\n')
}
new.into_bytes()
}
fn generate_mkv(
archive: &Archive,
target: PathBuf,
@ -450,8 +547,6 @@ fn generate_mkv(
let mut maps = vec![];
let mut metadata = vec![];
let mut video_length = (0, 0, 0, 0);
for (i, (video_path, format)) in video_paths.iter().enumerate() {
input.extend(["-i".to_string(), video_path.to_string_lossy().to_string()]);
maps.extend(["-map".to_string(), i.to_string()]);
@ -471,11 +566,6 @@ fn generate_mkv(
format!("-metadata:s:a:{}", i),
format!("title={}", format.audio.to_human_readable()),
]);
let vid_len = get_video_length(video_path.to_path_buf())?;
if vid_len > video_length {
video_length = vid_len
}
}
for (i, (audio_path, format)) in audio_paths.iter().enumerate() {
input.extend(["-i".to_string(), audio_path.to_string_lossy().to_string()]);
@ -508,7 +598,11 @@ fn generate_mkv(
]);
}
let (input_presets, output_presets) =
FFmpegPreset::ffmpeg_presets(archive.ffmpeg_preset.clone())?;
let mut command_args = vec!["-y".to_string()];
command_args.extend(input_presets);
command_args.extend(input);
command_args.extend(maps);
command_args.extend(metadata);
@ -528,9 +622,8 @@ fn generate_mkv(
command_args.extend(["-disposition:s:0".to_string(), "0".to_string()])
}
command_args.extend(output_presets);
command_args.extend([
"-c".to_string(),
"copy".to_string(),
"-f".to_string(),
"matroska".to_string(),
target.to_string_lossy().to_string(),
@ -552,11 +645,11 @@ fn generate_mkv(
/// Get the length of a video. This is required because sometimes subtitles have an unnecessary entry
/// long after the actual video ends with artificially extends the video length on some video players.
/// To prevent this, the video length must be hard set with ffmpeg. See
/// To prevent this, the video length must be hard set. See
/// [crunchy-labs/crunchy-cli#32](https://github.com/crunchy-labs/crunchy-cli/issues/32) for more
/// information.
fn get_video_length(path: PathBuf) -> Result<(u32, u32, u32, u32)> {
let video_length = Regex::new(r"Duration:\s?(\d+):(\d+):(\d+).(\d+),")?;
fn get_video_length(path: PathBuf) -> Result<NaiveTime> {
let video_length = Regex::new(r"Duration:\s(?P<time>\d+:\d+:\d+\.\d+),")?;
let ffmpeg = Command::new("ffmpeg")
.stdout(Stdio::null())
@ -567,10 +660,5 @@ fn get_video_length(path: PathBuf) -> Result<(u32, u32, u32, u32)> {
let ffmpeg_output = String::from_utf8(ffmpeg.stderr)?;
let caps = video_length.captures(ffmpeg_output.as_str()).unwrap();
Ok((
caps[1].parse()?,
caps[2].parse()?,
caps[3].parse()?,
caps[4].parse()?,
))
Ok(NaiveTime::parse_from_str(caps.name("time").unwrap().as_str(), "%H:%M:%S%.f").unwrap())
}