preprocessing seems to work
This commit is contained in:
parent
469fc5d663
commit
889dbbce5a
3 changed files with 124 additions and 56 deletions
83
src/main.rs
83
src/main.rs
|
@ -6,7 +6,10 @@ mod iotro;
|
||||||
mod render;
|
mod render;
|
||||||
mod time;
|
mod time;
|
||||||
|
|
||||||
use crate::time::{parse_date, parse_time, Date, Time};
|
use crate::{
|
||||||
|
render::Renderer,
|
||||||
|
time::{parse_date, parse_time, Date, Time}
|
||||||
|
};
|
||||||
use camino::Utf8PathBuf as PathBuf;
|
use camino::Utf8PathBuf as PathBuf;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use rational::Rational;
|
use rational::Rational;
|
||||||
|
@ -85,10 +88,12 @@ struct ProjectLecture {
|
||||||
struct ProjectSource {
|
struct ProjectSource {
|
||||||
files: Vec<String>,
|
files: Vec<String>,
|
||||||
|
|
||||||
#[serde_as(as = "DisplayFromStr")]
|
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||||
first_file_start: Time,
|
start: Option<Time>,
|
||||||
#[serde_as(as = "DisplayFromStr")]
|
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||||
last_file_end: Time,
|
end: Option<Time>,
|
||||||
|
#[serde_as(as = "Vec<(DisplayFromStr, DisplayFromStr)>")]
|
||||||
|
fast: Vec<(Time, Time)>,
|
||||||
|
|
||||||
metadata: Option<ProjectSourceMetadata>
|
metadata: Option<ProjectSourceMetadata>
|
||||||
}
|
}
|
||||||
|
@ -103,7 +108,8 @@ struct ProjectSourceMetadata {
|
||||||
#[serde_as(as = "DisplayFromStr")]
|
#[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,
|
#[serde_as(as = "DisplayFromStr")]
|
||||||
|
source_tbn: Rational,
|
||||||
/// The resolution of the source video.
|
/// The resolution of the source video.
|
||||||
source_res: Resolution,
|
source_res: Resolution,
|
||||||
/// The sample rate of the source audio.
|
/// The sample rate of the source audio.
|
||||||
|
@ -113,6 +119,8 @@ struct ProjectSourceMetadata {
|
||||||
#[derive(Default, Deserialize, Serialize)]
|
#[derive(Default, Deserialize, Serialize)]
|
||||||
struct ProjectProgress {
|
struct ProjectProgress {
|
||||||
preprocessed: bool,
|
preprocessed: bool,
|
||||||
|
asked_start_end: bool,
|
||||||
|
asked_fast: bool,
|
||||||
rendered: bool,
|
rendered: bool,
|
||||||
transcoded: BTreeSet<Resolution>
|
transcoded: BTreeSet<Resolution>
|
||||||
}
|
}
|
||||||
|
@ -145,7 +153,7 @@ fn main() {
|
||||||
|
|
||||||
// let's see if we need to initialise the project
|
// let's see if we need to initialise the project
|
||||||
let project_path = directory.join("project.toml");
|
let project_path = directory.join("project.toml");
|
||||||
let project = if project_path.exists() {
|
let mut project = if project_path.exists() {
|
||||||
toml::from_slice(&fs::read(&project_path).unwrap()).unwrap()
|
toml::from_slice(&fs::read(&project_path).unwrap()).unwrap()
|
||||||
} else {
|
} else {
|
||||||
let dirname = directory.file_name().unwrap();
|
let dirname = directory.file_name().unwrap();
|
||||||
|
@ -167,21 +175,13 @@ fn main() {
|
||||||
assert!(!files.is_empty());
|
assert!(!files.is_empty());
|
||||||
println!("I found the following source files: {files:?}");
|
println!("I found the following source files: {files:?}");
|
||||||
|
|
||||||
let first_file_start = ask_time(format_args!(
|
|
||||||
"Please take a look at the file {} and tell me the first second you want included",
|
|
||||||
files.first().unwrap()
|
|
||||||
));
|
|
||||||
let last_file_end = ask_time(format_args!(
|
|
||||||
"Please take a look at the file {} and tell me the last second you want included",
|
|
||||||
files.last().unwrap()
|
|
||||||
));
|
|
||||||
|
|
||||||
let project = Project {
|
let project = Project {
|
||||||
lecture: ProjectLecture { course, date },
|
lecture: ProjectLecture { course, date },
|
||||||
source: ProjectSource {
|
source: ProjectSource {
|
||||||
files,
|
files,
|
||||||
first_file_start,
|
start: None,
|
||||||
last_file_end,
|
end: None,
|
||||||
|
fast: Vec::new(),
|
||||||
metadata: None
|
metadata: None
|
||||||
},
|
},
|
||||||
progress: Default::default()
|
progress: Default::default()
|
||||||
|
@ -191,5 +191,52 @@ fn main() {
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("{}", toml::to_string(&project).unwrap());
|
println!("{}", toml::to_string(&project).unwrap());
|
||||||
|
|
||||||
|
let renderer = Renderer::new(&directory, &project).unwrap();
|
||||||
|
let recording = renderer.recording_mp4();
|
||||||
|
|
||||||
|
// preprocess the video
|
||||||
|
if !project.progress.preprocessed {
|
||||||
|
renderer.preprocess(&mut project).unwrap();
|
||||||
|
project.progress.preprocessed = true;
|
||||||
|
|
||||||
|
println!("{}", toml::to_string(&project).unwrap());
|
||||||
|
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask the user about start and end times
|
||||||
|
if !project.progress.asked_start_end {
|
||||||
|
project.source.start = Some(ask_time(format_args!(
|
||||||
|
"Please take a look at the file {recording} and tell me the first second you want included"
|
||||||
|
)));
|
||||||
|
project.source.end = Some(ask_time(format_args!(
|
||||||
|
"Please take a look at the file {recording} and tell me the last second you want included"
|
||||||
|
)));
|
||||||
|
project.progress.asked_start_end = true;
|
||||||
|
|
||||||
|
println!("{}", toml::to_string(&project).unwrap());
|
||||||
|
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask the user about fast forward times
|
||||||
|
if !project.progress.asked_fast {
|
||||||
|
loop {
|
||||||
|
let start = ask_time(format_args!(
|
||||||
|
"Please take a look at the file {recording} and tell me the first second you want fast-forwarded. You may reply with `0` if there are no more fast-forward sections"
|
||||||
|
));
|
||||||
|
if start.seconds == 0 && start.micros == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let end = ask_time(format_args!(
|
||||||
|
"Please tell me the last second you want fast-forwarded"
|
||||||
|
));
|
||||||
|
project.source.fast.push((start, end));
|
||||||
|
}
|
||||||
|
project.progress.asked_fast = true;
|
||||||
|
|
||||||
|
println!("{}", toml::to_string(&project).unwrap());
|
||||||
|
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
// render(&directory, &project).unwrap();
|
// render(&directory, &project).unwrap();
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,9 +4,9 @@ use crate::{
|
||||||
time::{format_time, Time}
|
time::{format_time, Time}
|
||||||
};
|
};
|
||||||
use anyhow::bail;
|
use anyhow::bail;
|
||||||
use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
|
use camino::Utf8PathBuf as PathBuf;
|
||||||
use rational::Rational;
|
use rational::Rational;
|
||||||
use std::{borrow::Cow, process::Command};
|
use std::{borrow::Cow, fmt::Write as _, process::Command};
|
||||||
|
|
||||||
pub(crate) struct FfmpegInput {
|
pub(crate) struct FfmpegInput {
|
||||||
pub(crate) concat: bool,
|
pub(crate) concat: bool,
|
||||||
|
@ -54,6 +54,7 @@ pub(crate) struct Ffmpeg {
|
||||||
filters: Vec<Filter>,
|
filters: Vec<Filter>,
|
||||||
filters_output: Cow<'static, str>,
|
filters_output: Cow<'static, str>,
|
||||||
loudnorm: bool,
|
loudnorm: bool,
|
||||||
|
duration: Option<Time>,
|
||||||
output: PathBuf,
|
output: PathBuf,
|
||||||
|
|
||||||
filter_idx: usize
|
filter_idx: usize
|
||||||
|
@ -66,6 +67,7 @@ impl Ffmpeg {
|
||||||
filters: Vec::new(),
|
filters: Vec::new(),
|
||||||
filters_output: "0".into(),
|
filters_output: "0".into(),
|
||||||
loudnorm: false,
|
loudnorm: false,
|
||||||
|
duration: None,
|
||||||
output,
|
output,
|
||||||
|
|
||||||
filter_idx: 0
|
filter_idx: 0
|
||||||
|
@ -97,13 +99,18 @@ impl Ffmpeg {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_duration(&mut self, duration: Time) -> &mut Self {
|
||||||
|
self.duration = Some(duration);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run(mut self) -> anyhow::Result<()> {
|
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").arg("-y");
|
||||||
|
|
||||||
// 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.is_empty();
|
||||||
let aenc = self.filters.iter().any(|f| f.is_audio_filter()) || self.loudnorm;
|
let aenc = !self.filters.is_empty() || 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();
|
||||||
|
@ -120,7 +127,7 @@ impl Ffmpeg {
|
||||||
// always try to synchronise audio
|
// always try to synchronise audio
|
||||||
cmd.arg("-async").arg("1");
|
cmd.arg("-async").arg("1");
|
||||||
|
|
||||||
// TODO apply filters
|
// apply filters
|
||||||
match (self.loudnorm, self.filters) {
|
match (self.loudnorm, self.filters) {
|
||||||
(true, f) if f.is_empty() => {
|
(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");
|
cmd.arg("-af").arg("pan=mono|c0=FR,loudnorm,pan=stereo|c0=c0|c1=c0,aformat=sample_rates=48000");
|
||||||
|
@ -133,8 +140,17 @@ impl Ffmpeg {
|
||||||
for filter in f {
|
for filter in f {
|
||||||
filter.append_to_complex_filter(&mut complex, &mut self.filter_idx);
|
filter.append_to_complex_filter(&mut complex, &mut self.filter_idx);
|
||||||
}
|
}
|
||||||
|
if vaapi {
|
||||||
|
write!(
|
||||||
|
complex,
|
||||||
|
"{}format=nv12,hwupload[v]",
|
||||||
|
channel('v', &self.filters_output)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
write!(complex, "{}null[v]", channel('v', &self.filters_output));
|
||||||
|
}
|
||||||
cmd.arg("-filter_complex").arg(complex);
|
cmd.arg("-filter_complex").arg(complex);
|
||||||
cmd.arg("-map").arg(channel('v', &self.filters_output));
|
cmd.arg("-map").arg("[v]");
|
||||||
cmd.arg("-map").arg(channel('a', &self.filters_output));
|
cmd.arg("-map").arg(channel('a', &self.filters_output));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -157,7 +173,11 @@ impl Ffmpeg {
|
||||||
cmd.arg("-c:a").arg("copy");
|
cmd.arg("-c:a").arg("copy");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(duration) = self.duration {
|
||||||
|
cmd.arg("-t").arg(format_time(duration));
|
||||||
|
}
|
||||||
cmd.arg(&self.output);
|
cmd.arg(&self.output);
|
||||||
|
|
||||||
let status = cmd.status()?;
|
let status = cmd.status()?;
|
||||||
if status.success() {
|
if status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
@ -39,7 +39,7 @@ fn cmd() -> Command {
|
||||||
.arg("-exuo")
|
.arg("-exuo")
|
||||||
.arg("pipefail")
|
.arg("pipefail")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg("exec");
|
.arg("exec \"$0\" \"${@}\"");
|
||||||
cmd
|
cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,7 +61,9 @@ fn read_output(cmd: &mut Command) -> anyhow::Result<String> {
|
||||||
out.status.code()
|
out.status.code()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
String::from_utf8(out.stdout).context("Command returned non-utf8 output")
|
String::from_utf8(out.stdout)
|
||||||
|
.context("Command returned non-utf8 output")
|
||||||
|
.map(|str| str.trim().into())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ffprobe_video(query: &str, input: &Path) -> anyhow::Result<String> {
|
fn ffprobe_video(query: &str, input: &Path) -> anyhow::Result<String> {
|
||||||
|
@ -90,23 +92,6 @@ fn ffprobe_audio(query: &str, concat_input: &Path) -> anyhow::Result<String> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ffmpeg() -> Command {
|
|
||||||
let mut cmd = cmd();
|
|
||||||
cmd.arg("ffmpeg")
|
|
||||||
.arg("-hide_banner")
|
|
||||||
.arg("-vaapi_device")
|
|
||||||
.arg("/dev/dri/renderD128");
|
|
||||||
cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_svg(fps: Rational, tbn: u32, input: &Path, duration: Time, output: &Path) {
|
|
||||||
let mut cmd = ffmpeg();
|
|
||||||
cmd.arg("-framerate").arg(fps.to_string());
|
|
||||||
cmd.arg("-loop").arg("1");
|
|
||||||
cmd.arg("-i").arg(input);
|
|
||||||
cmd.arg("-c:v").arg("libx264");
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct Renderer<'a> {
|
pub(crate) struct Renderer<'a> {
|
||||||
/// The directory with all the sources.
|
/// The directory with all the sources.
|
||||||
directory: &'a Path,
|
directory: &'a Path,
|
||||||
|
@ -128,13 +113,18 @@ fn svg2mp4(svg: PathBuf, mp4: PathBuf, duration: Time) -> anyhow::Result<()> {
|
||||||
output: "out".into()
|
output: "out".into()
|
||||||
});
|
});
|
||||||
ffmpeg.set_filter_output("out");
|
ffmpeg.set_filter_output("out");
|
||||||
|
ffmpeg.set_duration(duration);
|
||||||
ffmpeg.run()
|
ffmpeg.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn svg2png(svg: &Path, png: &Path, size: usize) -> anyhow::Result<()> {
|
fn svg2png(svg: &Path, png: &Path, size: usize) -> anyhow::Result<()> {
|
||||||
let mut cmd = cmd();
|
let mut cmd = cmd();
|
||||||
let size = size.to_string();
|
let size = size.to_string();
|
||||||
cmd.arg("inkscape").arg("-w").arg(&size).arg("-h").arg(&size);
|
cmd.arg("inkscape")
|
||||||
|
.arg("-w")
|
||||||
|
.arg(&size)
|
||||||
|
.arg("-h")
|
||||||
|
.arg(&size);
|
||||||
cmd.arg(svg).arg("-o").arg(png);
|
cmd.arg(svg).arg("-o").arg(png);
|
||||||
|
|
||||||
let status = cmd.status()?;
|
let status = cmd.status()?;
|
||||||
|
@ -153,6 +143,7 @@ impl<'a> Renderer<'a> {
|
||||||
format_date(project.lecture.date)
|
format_date(project.lecture.date)
|
||||||
);
|
);
|
||||||
let target = directory.join(&slug);
|
let target = directory.join(&slug);
|
||||||
|
fs::create_dir_all(&target)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
directory,
|
directory,
|
||||||
|
@ -161,20 +152,24 @@ impl<'a> Renderer<'a> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn recording_mp4(&self) -> PathBuf {
|
||||||
|
self.target.join("recording.mp4")
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn preprocess(&self, project: &mut Project) -> anyhow::Result<()> {
|
pub(crate) fn preprocess(&self, project: &mut Project) -> anyhow::Result<()> {
|
||||||
assert!(!project.progress.preprocessed);
|
assert!(!project.progress.preprocessed);
|
||||||
|
|
||||||
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 ...\x1B[0m");
|
||||||
let source_sample_rate =
|
let source_sample_rate =
|
||||||
ffprobe_audio("stream=sample_rate", &recording_txt)?.parse()?;
|
ffprobe_audio("stream=sample_rate", &recording_txt)?.parse()?;
|
||||||
let recording_mp4 = self.target.join("recording.mp4");
|
let recording_mp4 = self.recording_mp4();
|
||||||
let mut ffmpeg = Ffmpeg::new(recording_mp4.clone());
|
let mut ffmpeg = Ffmpeg::new(recording_mp4.clone());
|
||||||
ffmpeg.add_input(FfmpegInput {
|
ffmpeg.add_input(FfmpegInput {
|
||||||
concat: true,
|
concat: true,
|
||||||
|
@ -201,6 +196,8 @@ impl<'a> Renderer<'a> {
|
||||||
source_sample_rate
|
source_sample_rate
|
||||||
});
|
});
|
||||||
|
|
||||||
|
println!("\x1B[1m ==> Preparing assets ...\x1B[0m");
|
||||||
|
|
||||||
// render intro to svg then mp4
|
// render intro to svg then mp4
|
||||||
let intro_svg = self.target.join("intro.svg");
|
let intro_svg = self.target.join("intro.svg");
|
||||||
fs::write(
|
fs::write(
|
||||||
|
@ -225,7 +222,7 @@ impl<'a> Renderer<'a> {
|
||||||
svg2mp4(outro_svg, outro_mp4, Time {
|
svg2mp4(outro_svg, outro_mp4, Time {
|
||||||
seconds: 5,
|
seconds: 5,
|
||||||
micros: 0
|
micros: 0
|
||||||
});
|
})?;
|
||||||
|
|
||||||
// copy logo then render to png
|
// copy logo then render to png
|
||||||
let logo_svg = self.target.join("logo.svg");
|
let logo_svg = self.target.join("logo.svg");
|
||||||
|
@ -246,7 +243,11 @@ impl<'a> Renderer<'a> {
|
||||||
))
|
))
|
||||||
)?;
|
)?;
|
||||||
let fastforward_png = self.target.join("fastforward.png");
|
let fastforward_png = self.target.join("fastforward.png");
|
||||||
svg2png(&fastforward_svg, &fastforward_png, 128 * 1920 / source_res.width())?;
|
svg2png(
|
||||||
|
&fastforward_svg,
|
||||||
|
&fastforward_png,
|
||||||
|
128 * 1920 / source_res.width()
|
||||||
|
)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue