v1!
This commit is contained in:
parent
129115c8c0
commit
5f8901d53e
4 changed files with 186 additions and 40 deletions
|
@ -20,3 +20,4 @@ tokio = { version = "1.32.0", features = ["full"] }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name="nmb"
|
name="nmb"
|
||||||
|
path="src/main.rs"
|
||||||
|
|
161
src/encoder.rs
161
src/encoder.rs
|
@ -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
|
||||||
|
|
62
src/main.rs
62
src/main.rs
|
@ -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());
|
||||||
|
|
|
@ -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(&[
|
||||||
|
|
Loading…
Reference in a new issue