almost ready for preprocessing
This commit is contained in:
parent
a0f1caa34b
commit
45b9173c56
5 changed files with 136 additions and 16 deletions
|
@ -17,3 +17,6 @@ serde = { version = "1.0.188", features = ["derive"] }
|
||||||
serde_with = "3.4"
|
serde_with = "3.4"
|
||||||
svgwriter = "0.1"
|
svgwriter = "0.1"
|
||||||
toml = { package = "basic-toml", version = "0.1.4" }
|
toml = { package = "basic-toml", version = "0.1.4" }
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
rational = { git = "https://github.com/msrd0/rational", branch = "error" }
|
||||||
|
|
|
@ -11,7 +11,7 @@ use camino::Utf8PathBuf as PathBuf;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use rational::Rational;
|
use rational::Rational;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_with::{serde_as, DisplayFromStr, FromInto};
|
use serde_with::{serde_as, DisplayFromStr};
|
||||||
use std::{
|
use std::{
|
||||||
collections::BTreeSet,
|
collections::BTreeSet,
|
||||||
fmt::Display,
|
fmt::Display,
|
||||||
|
@ -100,7 +100,7 @@ struct ProjectSourceMetadata {
|
||||||
#[serde_as(as = "DisplayFromStr")]
|
#[serde_as(as = "DisplayFromStr")]
|
||||||
source_duration: Time,
|
source_duration: Time,
|
||||||
/// The FPS of the source video.
|
/// The FPS of the source video.
|
||||||
#[serde_as(as = "FromInto<(i128, i128)>")]
|
#[serde_as(as = "DisplayFromStr")]
|
||||||
source_fps: Rational,
|
source_fps: Rational,
|
||||||
/// The time base of the source video.
|
/// The time base of the source video.
|
||||||
source_tbn: u32,
|
source_tbn: u32,
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
use super::{cmd, filter::Filter};
|
use super::{cmd, filter::Filter};
|
||||||
use crate::time::{format_time, Time};
|
use crate::{
|
||||||
|
render::filter::channel,
|
||||||
|
time::{format_time, Time}
|
||||||
|
};
|
||||||
|
use anyhow::bail;
|
||||||
use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
|
use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
|
||||||
use rational::Rational;
|
use rational::Rational;
|
||||||
use std::{borrow::Cow, process::Command};
|
use std::{borrow::Cow, process::Command};
|
||||||
|
@ -48,6 +52,8 @@ impl FfmpegInput {
|
||||||
pub(crate) struct Ffmpeg {
|
pub(crate) struct Ffmpeg {
|
||||||
inputs: Vec<FfmpegInput>,
|
inputs: Vec<FfmpegInput>,
|
||||||
filters: Vec<Filter>,
|
filters: Vec<Filter>,
|
||||||
|
filters_output: Cow<'static, str>,
|
||||||
|
loudnorm: bool,
|
||||||
output: PathBuf,
|
output: PathBuf,
|
||||||
|
|
||||||
filter_idx: usize
|
filter_idx: usize
|
||||||
|
@ -58,19 +64,46 @@ impl Ffmpeg {
|
||||||
Self {
|
Self {
|
||||||
inputs: Vec::new(),
|
inputs: Vec::new(),
|
||||||
filters: Vec::new(),
|
filters: Vec::new(),
|
||||||
|
filters_output: "0".into(),
|
||||||
|
loudnorm: false,
|
||||||
output,
|
output,
|
||||||
|
|
||||||
filter_idx: 0
|
filter_idx: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(self) -> anyhow::Result<()> {
|
pub fn add_input(&mut self, input: FfmpegInput) -> &mut Self {
|
||||||
|
self.inputs.push(input);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_filter(&mut self, filter: Filter) -> &mut Self {
|
||||||
|
assert!(!self.loudnorm);
|
||||||
|
self.filters.push(filter);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_filter_output<T: Into<Cow<'static, str>>>(
|
||||||
|
&mut self,
|
||||||
|
output: T
|
||||||
|
) -> &mut Self {
|
||||||
|
self.filters_output = output.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enable_loudnorm(&mut self) -> &mut Self {
|
||||||
|
assert!(self.filters.is_empty());
|
||||||
|
self.loudnorm = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(mut self) -> anyhow::Result<()> {
|
||||||
let mut cmd = cmd();
|
let mut cmd = cmd();
|
||||||
cmd.arg("ffmpeg").arg("-hide_banner");
|
cmd.arg("ffmpeg").arg("-hide_banner");
|
||||||
|
|
||||||
// determine whether the video need to be re-encoded
|
// determine whether the video need to be re-encoded
|
||||||
let venc = self.filters.iter().any(|f| f.is_video_filter());
|
let venc = self.filters.iter().any(|f| f.is_video_filter());
|
||||||
let aenc = self.filters.iter().any(|f| f.is_audio_filter());
|
let aenc = self.filters.iter().any(|f| f.is_audio_filter()) || self.loudnorm;
|
||||||
|
|
||||||
// initialise a vaapi device if one exists
|
// initialise a vaapi device if one exists
|
||||||
let vaapi_device: PathBuf = "/dev/dri/renderD128".into();
|
let vaapi_device: PathBuf = "/dev/dri/renderD128".into();
|
||||||
|
@ -88,6 +121,23 @@ impl Ffmpeg {
|
||||||
cmd.arg("-async").arg("1");
|
cmd.arg("-async").arg("1");
|
||||||
|
|
||||||
// TODO apply filters
|
// TODO apply filters
|
||||||
|
match (self.loudnorm, self.filters) {
|
||||||
|
(true, f) if f.is_empty() => {
|
||||||
|
cmd.arg("-af").arg("pan=mono|c0=FR,loudnorm,pan=stereo|c0=c0|c1=c0,aformat=sample_rates=48000");
|
||||||
|
},
|
||||||
|
(true, _) => panic!("Filters and loudnorm at the same time is not supported"),
|
||||||
|
|
||||||
|
(false, f) if f.is_empty() => {},
|
||||||
|
(false, f) => {
|
||||||
|
let mut complex = String::new();
|
||||||
|
for filter in f {
|
||||||
|
filter.append_to_complex_filter(&mut complex, &mut self.filter_idx);
|
||||||
|
}
|
||||||
|
cmd.arg("-filter_complex").arg(complex);
|
||||||
|
cmd.arg("-map").arg(channel('v', &self.filters_output));
|
||||||
|
cmd.arg("-map").arg(channel('a', &self.filters_output));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// append encoding options
|
// append encoding options
|
||||||
if vaapi {
|
if vaapi {
|
||||||
|
@ -107,6 +157,12 @@ impl Ffmpeg {
|
||||||
cmd.arg("-c:a").arg("copy");
|
cmd.arg("-c:a").arg("copy");
|
||||||
}
|
}
|
||||||
|
|
||||||
unimplemented!()
|
cmd.arg(&self.output);
|
||||||
|
let status = cmd.status()?;
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
bail!("ffmpeg failed with exit code {:?}", status.code())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,7 +71,11 @@ impl Filter {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_to_complex_filter(&self, complex: &mut String, filter_idx: &mut usize) {
|
pub(crate) fn append_to_complex_filter(
|
||||||
|
&self,
|
||||||
|
complex: &mut String,
|
||||||
|
filter_idx: &mut usize
|
||||||
|
) {
|
||||||
match self {
|
match self {
|
||||||
Self::Trim {
|
Self::Trim {
|
||||||
input,
|
input,
|
||||||
|
@ -191,7 +195,7 @@ impl Filter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn channel(channel: char, id: &str) -> String {
|
pub(super) fn channel(channel: char, id: &str) -> String {
|
||||||
if id.chars().any(|ch| !ch.is_digit(10)) {
|
if id.chars().any(|ch| !ch.is_digit(10)) {
|
||||||
format!("[{channel}_{id}]")
|
format!("[{channel}_{id}]")
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -3,9 +3,10 @@
|
||||||
pub mod ffmpeg;
|
pub mod ffmpeg;
|
||||||
mod filter;
|
mod filter;
|
||||||
|
|
||||||
|
use self::filter::Filter;
|
||||||
use crate::{
|
use crate::{
|
||||||
iotro::intro,
|
iotro::{intro, outro},
|
||||||
render::ffmpeg::Ffmpeg,
|
render::ffmpeg::{Ffmpeg, FfmpegInput},
|
||||||
time::{format_date, Time},
|
time::{format_date, Time},
|
||||||
Project, ProjectSourceMetadata, Resolution
|
Project, ProjectSourceMetadata, Resolution
|
||||||
};
|
};
|
||||||
|
@ -116,6 +117,20 @@ pub(crate) struct Renderer<'a> {
|
||||||
target: PathBuf
|
target: PathBuf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn svg2mp4(svg: PathBuf, mp4: PathBuf, duration: Time) -> anyhow::Result<()> {
|
||||||
|
let mut ffmpeg = Ffmpeg::new(mp4);
|
||||||
|
ffmpeg.add_input(FfmpegInput {
|
||||||
|
loop_input: true,
|
||||||
|
..FfmpegInput::new(svg)
|
||||||
|
});
|
||||||
|
ffmpeg.add_filter(Filter::GenerateSilence {
|
||||||
|
video: "0".into(),
|
||||||
|
output: "out".into()
|
||||||
|
});
|
||||||
|
ffmpeg.set_filter_output("out");
|
||||||
|
ffmpeg.run()
|
||||||
|
}
|
||||||
|
|
||||||
impl<'a> Renderer<'a> {
|
impl<'a> Renderer<'a> {
|
||||||
pub(crate) fn new(directory: &'a Path, project: &Project) -> anyhow::Result<Self> {
|
pub(crate) fn new(directory: &'a Path, project: &Project) -> anyhow::Result<Self> {
|
||||||
let slug = format!(
|
let slug = format!(
|
||||||
|
@ -150,23 +165,65 @@ impl<'a> Renderer<'a> {
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let recording_txt = self.target.join("recording.txt");
|
let recording_txt = self.target.join("recording.txt");
|
||||||
let mut file = File::create(recording_txt)?;
|
let mut file = File::create(&recording_txt)?;
|
||||||
for filename in &project.source.files {
|
for filename in &project.source.files {
|
||||||
writeln!(file, "file {:?}", self.directory.join(filename).to_string());
|
writeln!(file, "file {:?}", self.directory.join(filename).to_string());
|
||||||
}
|
}
|
||||||
drop(file);
|
drop(file);
|
||||||
|
|
||||||
println!("\x1B[1m ==> Concatenating Video and Normalising Audio ...");
|
println!("\x1B[1m ==> Concatenating Video and Normalising Audio ...");
|
||||||
|
let source_sample_rate =
|
||||||
|
ffprobe_audio("stream=sample_rate", &recording_txt)?.parse()?;
|
||||||
let recording_mp4 = self.target.join("recording.mp4");
|
let recording_mp4 = self.target.join("recording.mp4");
|
||||||
let mut ffmpeg = Ffmpeg::new(recording_mp4);
|
let mut ffmpeg = Ffmpeg::new(recording_mp4.clone());
|
||||||
|
ffmpeg.add_input(FfmpegInput {
|
||||||
|
concat: true,
|
||||||
|
..FfmpegInput::new(recording_txt)
|
||||||
|
});
|
||||||
|
ffmpeg.enable_loudnorm();
|
||||||
|
ffmpeg.run()?;
|
||||||
|
|
||||||
// project.source.metadata = Some(ProjectSourceMetadata {
|
let width = ffprobe_video("stream=width", &recording_mp4)?.parse()?;
|
||||||
// source_duration: ffprobe_video("format=duration", input)?.parse()?
|
let height = ffprobe_video("stream=height", &recording_mp4)?.parse()?;
|
||||||
// });
|
let source_res = match (width, height) {
|
||||||
|
(3840, 2160) => Resolution::UHD,
|
||||||
|
(2560, 1440) => Resolution::WQHD,
|
||||||
|
(1920, 1080) => Resolution::FullHD,
|
||||||
|
(1280, 720) => Resolution::HD,
|
||||||
|
(640, 360) => Resolution::nHD,
|
||||||
|
(width, height) => bail!("Unknown resolution: {width}x{height}")
|
||||||
|
};
|
||||||
|
project.source.metadata = Some(ProjectSourceMetadata {
|
||||||
|
source_duration: ffprobe_video("format=duration", &recording_mp4)?.parse()?,
|
||||||
|
source_fps: ffprobe_video("stream=r_frame_rate", &recording_mp4)?.parse()?,
|
||||||
|
source_tbn: ffprobe_video("stream=time_base", &recording_mp4)?.parse()?,
|
||||||
|
source_res,
|
||||||
|
source_sample_rate
|
||||||
|
});
|
||||||
|
|
||||||
let intro_svg = self.target.join("intro.svg");
|
let intro_svg = self.target.join("intro.svg");
|
||||||
// fs::write(&intro_svg, intro(res, date));
|
fs::write(
|
||||||
|
&intro_svg,
|
||||||
|
intro(source_res, project.lecture.date)
|
||||||
|
.to_string_pretty()
|
||||||
|
.into_bytes()
|
||||||
|
)?;
|
||||||
let intro_mp4 = self.target.join("intro.mp4");
|
let intro_mp4 = self.target.join("intro.mp4");
|
||||||
|
svg2mp4(intro_svg, intro_mp4, Time {
|
||||||
|
seconds: 3,
|
||||||
|
micros: 0
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let outro_svg = self.target.join("outro.svg");
|
||||||
|
fs::write(
|
||||||
|
&outro_svg,
|
||||||
|
outro(source_res).to_string_pretty().into_bytes()
|
||||||
|
)?;
|
||||||
|
let outro_mp4 = self.target.join("outro.mp4");
|
||||||
|
svg2mp4(outro_svg, outro_mp4, Time {
|
||||||
|
seconds: 5,
|
||||||
|
micros: 0
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue