This commit is contained in:
Djkáťo 2023-10-14 00:08:11 +02:00
parent 129115c8c0
commit 5f8901d53e
4 changed files with 186 additions and 40 deletions

View file

@ -20,3 +20,4 @@ tokio = { version = "1.32.0", features = ["full"] }
[[bin]] [[bin]]
name="nmb" name="nmb"
path="src/main.rs"

View file

@ -2,11 +2,13 @@ use anyhow::{bail, Context};
use pbr::{Pipe, ProgressBar}; use pbr::{Pipe, ProgressBar};
use std::{path::PathBuf, process::Stdio}; use std::{path::PathBuf, process::Stdio};
use tokio::{ use tokio::{
io::{AsyncBufReadExt, BufReader, Lines}, io::{BufReader, Lines},
process::{Child, ChildStderr, ChildStdout, Command}, process::{Child, ChildStdout, Command},
}; };
use crate::VideoCodec;
const MAX_OPUS_BITRATE: f32 = 256.; //kbits const MAX_OPUS_BITRATE: f32 = 256.; //kbits
const MIN_OPUS_BITRATE: f32 = 50.; //kbits const MIN_OPUS_BITRATE: f32 = 56.; //kbits
pub struct FFMPEGCommand { pub struct FFMPEGCommand {
pub file_name: String, pub file_name: String,
@ -23,10 +25,21 @@ pub struct FFMPEGCommand {
pub progress_bar: Option<ProgressBar<Pipe>>, pub progress_bar: Option<ProgressBar<Pipe>>,
} }
struct MediaData {
resolution: Option<(u16, u16)>,
duration: f32,
old_kbit_rate: Option<u16>,
}
impl FFMPEGCommand { impl FFMPEGCommand {
pub async fn new(media_type: MediaType, path: &PathBuf, size: u16) -> anyhow::Result<Self> { pub async fn new(
media_type: MediaType,
path: &PathBuf,
size: u16,
codec: VideoCodec,
) -> anyhow::Result<Self> {
match media_type { match media_type {
MediaType::Video => Self::create_video(path, size).await, MediaType::Video => Self::create_video(path, size, codec).await,
MediaType::Audio => Self::create_audio(path, size).await, MediaType::Audio => Self::create_audio(path, size).await,
MediaType::Image => Self::create_image(path, size), MediaType::Image => Self::create_image(path, size),
MediaType::AnimatedImage => Self::create_animated_image(path), MediaType::AnimatedImage => Self::create_animated_image(path),
@ -35,11 +48,29 @@ impl FFMPEGCommand {
async fn create_audio(path: &PathBuf, size: u16) -> anyhow::Result<Self> { async fn create_audio(path: &PathBuf, size: u16) -> anyhow::Result<Self> {
let ffprobe_out = parse_ffprobe(path).await?; let ffprobe_out = parse_ffprobe(path).await?;
let duration = ffprobe_out.0.context("Duration missing")?; let duration = ffprobe_out.duration;
let max_kbit_rate = match ffprobe_out.old_kbit_rate {
let bitrate = (size as f32 * 1000. / duration) * 0.95; None => MAX_OPUS_BITRATE,
let bitrate = bitrate.clamp(MIN_OPUS_BITRATE, MAX_OPUS_BITRATE) as u16; Some(r) => {
if (r as f32) < MAX_OPUS_BITRATE {
r as f32
} else {
MAX_OPUS_BITRATE
}
}
};
let bitrate = (size as f32 * 1000. / duration) * 0.9;
let bitrate = bitrate.clamp(MIN_OPUS_BITRATE, max_kbit_rate) as u16;
/*
println!(
"{} * {} ~= {} (actually is {})",
duration,
bitrate,
size * 1000,
duration * bitrate as f32
);
*/
let mut new_path = path.clone(); let mut new_path = path.clone();
new_path.set_extension("ogg"); new_path.set_extension("ogg");
@ -76,18 +107,18 @@ impl FFMPEGCommand {
}) })
} }
async fn create_video(path: &PathBuf, size: u16) -> anyhow::Result<Self> { async fn create_video(path: &PathBuf, size: u16, codec: VideoCodec) -> anyhow::Result<Self> {
let ffprobe_out = parse_ffprobe(path).await?; let ffprobe_out = parse_ffprobe(path).await?;
let duration = ffprobe_out.0.context("Duration missing")?; let duration = ffprobe_out.duration;
let resolution = ffprobe_out.1.context("Missing resolution")?; let resolution = ffprobe_out.resolution.context("Missing resolution")?;
let mut overflown_audio_bitrate = None; let mut overflown_audio_bitrate = None;
let mut audio_bitrate = (size as f32 * 1000. / duration) * 0.95 * 0.1; let mut audio_bitrate = size as f32 * 180. / duration;
let mut video_bitrate = (size as f32 * 1000. / duration) * 0.95 * 0.9; let mut video_bitrate = size as f32 * 780. / duration;
if audio_bitrate < MIN_OPUS_BITRATE { if audio_bitrate < MIN_OPUS_BITRATE {
overflown_audio_bitrate = Some(MIN_OPUS_BITRATE - audio_bitrate); overflown_audio_bitrate = Some(audio_bitrate - MIN_OPUS_BITRATE);
audio_bitrate = MIN_OPUS_BITRATE; audio_bitrate = MIN_OPUS_BITRATE;
} }
if audio_bitrate > MAX_OPUS_BITRATE { if audio_bitrate > MAX_OPUS_BITRATE {
@ -96,6 +127,15 @@ impl FFMPEGCommand {
} }
if let Some(overflow) = overflown_audio_bitrate { if let Some(overflow) = overflown_audio_bitrate {
/*
println!(
"-b:v:{}\n-b:a:{} (ovw: {})\nsum:{}/{}",
video_bitrate,
audio_bitrate,
overflow,
video_bitrate + audio_bitrate,
size
);*/
video_bitrate = video_bitrate + overflow; video_bitrate = video_bitrate + overflow;
} }
@ -112,25 +152,56 @@ impl FFMPEGCommand {
let old_path_str = path.as_os_str().to_str().context("missing or bad path")?; let old_path_str = path.as_os_str().to_str().context("missing or bad path")?;
let mut new_path = path.clone(); let mut new_path = path.clone();
new_path.set_extension("webm");
let scale_arg = format!("scale=-1:{height}"); let scale_arg = format!("scale=-1:{height}");
let bitrate_arg = format!("{video_bitrate}k"); let bitrate_arg = format!("{}k", video_bitrate as u16);
let minrate_arg = format!("{}k", (video_bitrate * 0.5) as u16); let minrate_arg = format!("{}k", (video_bitrate * 0.5) as u16);
let maxrate_arg = format!("{}k", (video_bitrate * 1.45) as u16); let maxrate_arg = format!("{}k", (video_bitrate * 1.45) as u16);
let ba_arg = format!("{audio_bitrate}k"); let ba_arg = format!("{}k", audio_bitrate as u16);
let mut passlogfile = path.clone();
passlogfile.set_extension("");
let mut command = Command::new("ffmpeg"); let mut command = Command::new("ffmpeg");
let mut command2 = Command::new("ffmpeg"); let mut command2 = Command::new("ffmpeg");
command.args(["-progress", "-", "-nostats", "-stats_period", "50ms"]); command.args(["-progress", "-", "-nostats", "-stats_period", "50ms"]);
command2.args(["-progress", "-", "-nostats", "-stats_period", "50ms"]); command2.args(["-progress", "-", "-nostats", "-stats_period", "50ms"]);
let video_codec;
let audio_codec;
match codec {
VideoCodec::WEBM => {
video_codec = "libvpx-vp9";
audio_codec = "libopus";
new_path.set_extension("webm");
new_path.set_file_name(
"minified_".to_owned() + new_path.file_name().unwrap().to_str().unwrap(),
)
}
VideoCodec::HEVC => {
video_codec = "libx265";
audio_codec = "aac";
new_path.set_extension("mp4");
new_path.set_file_name(
"minified_".to_owned() + new_path.file_name().unwrap().to_str().unwrap(),
)
}
};
/*
println!(
"{} * ({}+{}) ~= {} (actually is {})",
duration,
video_bitrate,
audio_bitrate,
size,
(duration * ((video_bitrate + audio_bitrate) / 1000.)) as f32
);
*/
let pass = [ let pass = [
"-y", "-y",
"-i", "-i",
old_path_str, old_path_str,
"-vcodec", "-vcodec",
"libvpx-vp9", video_codec,
"-acodec", "-acodec",
"libopus", audio_codec,
"-vf", "-vf",
&scale_arg, &scale_arg,
"-deadline", "-deadline",
@ -165,10 +236,21 @@ impl FFMPEGCommand {
"60", "60",
"-g", "-g",
"240", "240",
"-passlogfile",
passlogfile
.as_os_str()
.to_str()
.context("missing or bad path")?,
]; ];
command.args(pass); command.args(pass);
command.args(["-pass", "1", "-f", "webm"]);
command.args([
"-pass",
"1",
"-f",
path.extension().unwrap().to_str().unwrap(),
]);
if cfg!(windows) { if cfg!(windows) {
command.arg("NUL"); command.arg("NUL");
} else { } else {
@ -183,7 +265,7 @@ impl FFMPEGCommand {
.to_str() .to_str()
.context("missing or bad path")?, .context("missing or bad path")?,
]); ]);
dbg!(&command2);
Ok(FFMPEGCommand { Ok(FFMPEGCommand {
file_name: path.file_name().unwrap().to_str().unwrap().to_owned(), file_name: path.file_name().unwrap().to_str().unwrap().to_owned(),
resolution: None, resolution: None,
@ -255,7 +337,7 @@ pub enum EncodingStatus {
NotStarted, NotStarted,
} }
async fn parse_ffprobe(path: &PathBuf) -> anyhow::Result<(Option<f32>, Option<(u16, u16)>)> { async fn parse_ffprobe(path: &PathBuf) -> anyhow::Result<MediaData> {
let ffprobe = Command::new("ffprobe") let ffprobe = Command::new("ffprobe")
.arg(path) .arg(path)
.stderr(Stdio::piped()) .stderr(Stdio::piped())
@ -265,17 +347,26 @@ async fn parse_ffprobe(path: &PathBuf) -> anyhow::Result<(Option<f32>, Option<(u
.status .status
.exit_ok() .exit_ok()
.context("Failed to run ffprobe. Make sure ffprobe is installed and file exists")?; .context("Failed to run ffprobe. Make sure ffprobe is installed and file exists")?;
let ffprobe_output = std::str::from_utf8(&ffprobe.stderr)?;
let mut duration = None; let text = std::str::from_utf8(&ffprobe.stderr)?;
let duration;
if let Ok(dur) = parse_duration(text) {
duration = dur
} else {
bail!("FFProbe missing duration in media. Is file corrupted or non-existent?")
}
let old_kbit_rate = parse_bitrate(text).ok();
let mut resolution = None; let mut resolution = None;
let text = ffprobe_output;
if text.contains("Duration") {
duration = Some(parse_duration(text)?);
}
if text.contains("Stream") { if text.contains("Stream") {
resolution = Some(parse_resolution(text)?); resolution = parse_resolution(text).ok();
} }
return Ok((duration, resolution)); return Ok(MediaData {
duration,
resolution,
old_kbit_rate,
});
} }
fn parse_duration(text: &str) -> anyhow::Result<f32> { fn parse_duration(text: &str) -> anyhow::Result<f32> {
@ -304,6 +395,14 @@ fn parse_duration(text: &str) -> anyhow::Result<f32> {
.parse::<f32>()?; .parse::<f32>()?;
Ok(h * 60. * 60. + m * 60. + s) Ok(h * 60. * 60. + m * 60. + s)
} }
fn parse_bitrate(text: &str) -> anyhow::Result<u16> {
let text = text[text.find("bitrate").unwrap()..].to_owned();
let bitrate_text = text[9..text.find("/").unwrap() - 2].to_owned();
Ok(bitrate_text.parse::<u16>()?)
}
fn parse_resolution(text: &str) -> anyhow::Result<(u16, u16)> { fn parse_resolution(text: &str) -> anyhow::Result<(u16, u16)> {
let text = text[text.find("Stream").unwrap()..].to_owned(); let text = text[text.find("Stream").unwrap()..].to_owned();
let sar_i = text let sar_i = text

View file

@ -1,7 +1,7 @@
#![feature(exit_status_error)] #![feature(exit_status_error)]
use anyhow::Context; use anyhow::Context;
use clap::{arg, command, value_parser}; use clap::{arg, command, value_parser, ValueEnum};
use encoder::{FFMPEGCommand, MediaType}; use encoder::{FFMPEGCommand, MediaType};
use std::{path::PathBuf, process::Stdio, sync::Arc}; use std::{path::PathBuf, process::Stdio, sync::Arc};
use tokio::{ use tokio::{
@ -14,6 +14,31 @@ use crate::encoder::EncodingStatus;
mod encoder; mod encoder;
mod ui; mod ui;
#[derive(Debug, Clone)]
pub enum VideoCodec {
WEBM,
HEVC,
}
impl std::fmt::Display for VideoCodec {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::WEBM => write!(f, "WEBM"),
Self::HEVC => write!(f, "HEVC"),
}
}
}
impl VideoCodec {
pub fn from_string(string: &str) -> Option<Self> {
match string.to_lowercase().as_str() {
"webm" => Some(Self::WEBM),
"hevc" => Some(Self::HEVC),
_ => None,
}
}
}
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let args = command!() let args = command!()
@ -24,6 +49,11 @@ async fn main() -> anyhow::Result<()> {
.default_value("25") .default_value("25")
.value_parser(value_parser!(u16)) .value_parser(value_parser!(u16))
) )
.arg(
arg!(-c --codec <CODEC> "Choose video codec between `HEVC` (H.265) and `WEBM` (vp9).")
.required(false)
.default_value("WEBM")
)
.arg( .arg(
arg!(-f --files <FILES> "Comma separated files to convert. EG: -f=<FILE>,<FILE>") arg!(-f --files <FILES> "Comma separated files to convert. EG: -f=<FILE>,<FILE>")
.required(true) .required(true)
@ -33,12 +63,17 @@ async fn main() -> anyhow::Result<()> {
).get_matches(); ).get_matches();
let size = args let size = args
.get_one::<u16>("size") .get_one::<u16>("size")
.expect("Default value dissapeared from rate"); .expect("Default value dissapeared from rate")
* 8;
let files = args let files = args
.get_many::<PathBuf>("files") .get_many::<PathBuf>("files")
.context("No files specified")? .context("No files specified")?
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let binding = "webm".to_owned();
let codec = args.get_one::<String>("codec").unwrap_or(&binding);
let codec = VideoCodec::from_string(codec).unwrap_or(VideoCodec::WEBM);
let commands: Arc<Mutex<Vec<FFMPEGCommand>>> = Arc::new(Mutex::new(vec![])); let commands: Arc<Mutex<Vec<FFMPEGCommand>>> = Arc::new(Mutex::new(vec![]));
{ {
let mut commands_mut = commands.try_lock().unwrap(); let mut commands_mut = commands.try_lock().unwrap();
@ -55,21 +90,32 @@ async fn main() -> anyhow::Result<()> {
.as_str() .as_str()
{ {
"webm" | "mp4" | "mov" | "avi" | "mpeg" | "mkv" => { "webm" | "mp4" | "mov" | "avi" | "mpeg" | "mkv" => {
command = FFMPEGCommand::new(MediaType::Video, file, size.clone()).await?; command =
FFMPEGCommand::new(MediaType::Video, file, size.clone(), codec.clone())
.await?;
} }
"mp3" | "wav" | "ogg" | "opus" | "flac" | "aiff" => { "mp3" | "wav" | "ogg" | "opus" | "flac" | "aiff" => {
command = FFMPEGCommand::new(MediaType::Audio, file, size.clone()).await?; command =
FFMPEGCommand::new(MediaType::Audio, file, size.clone(), codec.clone())
.await?;
} }
"jpg" | "png" | "webp" | "exr" | "jpeg" | "tiff" | "bpm" | "raw" | "tif" => { "jpg" | "png" | "webp" | "exr" | "jpeg" | "tiff" | "bpm" | "raw" | "tif" => {
command = FFMPEGCommand::new(MediaType::Image, file, size.clone()).await?; command =
FFMPEGCommand::new(MediaType::Image, file, size.clone(), codec.clone())
.await?;
} }
"gif" => { "gif" => {
command = command = FFMPEGCommand::new(
FFMPEGCommand::new(MediaType::AnimatedImage, file, size.clone()).await?; MediaType::AnimatedImage,
file,
size.clone(),
codec.clone(),
)
.await?;
} }
_ => break, _ => break,
} }
//dbg!(&command.command.0); dbg!(&command.command.0);
command.command.0.stdout(Stdio::piped()); command.command.0.stdout(Stdio::piped());
command.command.0.stderr(Stdio::null()); command.command.0.stderr(Stdio::null());

View file

@ -8,7 +8,7 @@ use tokio::time::interval;
pub async fn display(commands: Arc<Mutex<Vec<FFMPEGCommand>>>) { pub async fn display(commands: Arc<Mutex<Vec<FFMPEGCommand>>>) {
let mb = MultiProgress::new(); let mb = MultiProgress::new();
let sty = ProgressStyle::with_template( let sty = ProgressStyle::with_template(
"{spinner:.blue} {msg} [{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} (ms)", "{spinner:.blue} {msg} [{elapsed_precise}/{eta_precise}(eta)] {bar:40.cyan/blue} {pos:>7}/{len:7} (ms)",
) )
.unwrap() .unwrap()
.tick_strings(&[ .tick_strings(&[