prepare writing ffmpeg commands
This commit is contained in:
parent
d59ca4aceb
commit
823d8ce5dc
6 changed files with 434 additions and 11 deletions
|
@ -10,7 +10,10 @@ edition = "2021"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
camino = "1.1"
|
camino = "1.1"
|
||||||
clap = { version = "4.4", features = ["derive"] }
|
clap = { version = "4.4", features = ["derive"] }
|
||||||
ffmpeg = { package = "ffmpeg-next", version = "6.0" }
|
#ffmpeg = { package = "ffmpeg-next", version = "6.0" }
|
||||||
|
indexmap = "1.9"
|
||||||
|
rational = "1.4"
|
||||||
serde = { version = "1.0.188", features = ["derive"] }
|
serde = { version = "1.0.188", features = ["derive"] }
|
||||||
serde_with = "3.4"
|
serde_with = "3.4"
|
||||||
|
svgwriter = "0.1"
|
||||||
toml = { package = "basic-toml", version = "0.1.4" }
|
toml = { package = "basic-toml", version = "0.1.4" }
|
||||||
|
|
115
src/iotro.rs
Normal file
115
src/iotro.rs
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
//! A module for writing intros and outros
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
time::{format_date_long, Date},
|
||||||
|
Resolution
|
||||||
|
};
|
||||||
|
use svgwriter::{
|
||||||
|
tags::{Group, Rect, TagWithPresentationAttributes, Text},
|
||||||
|
Graphic
|
||||||
|
};
|
||||||
|
|
||||||
|
#[repr(u16)]
|
||||||
|
enum FontSize {
|
||||||
|
Huge = 72,
|
||||||
|
Large = 56,
|
||||||
|
Big = 44
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(u16)]
|
||||||
|
enum FontWeight {
|
||||||
|
Normal = 400,
|
||||||
|
SemiBold = 500,
|
||||||
|
Bold = 700
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Iotro {
|
||||||
|
res: Resolution,
|
||||||
|
g: Group
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iotro {
|
||||||
|
fn new(res: Resolution) -> Self {
|
||||||
|
Self {
|
||||||
|
res,
|
||||||
|
g: Group::new()
|
||||||
|
.with_fill("white")
|
||||||
|
.with_text_anchor("middle")
|
||||||
|
.with_dominant_baseline("hanging")
|
||||||
|
.with_font_family("Noto Sans")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_text<T: Into<String>>(
|
||||||
|
&mut self,
|
||||||
|
font_size: FontSize,
|
||||||
|
font_weight: FontWeight,
|
||||||
|
y_top: usize,
|
||||||
|
content: T
|
||||||
|
) {
|
||||||
|
let mut text = Text::new()
|
||||||
|
.with_x(960)
|
||||||
|
.with_y(y_top)
|
||||||
|
.with_font_size(font_size as u16)
|
||||||
|
.with_font_weight(font_weight as u16);
|
||||||
|
text.push(content.into());
|
||||||
|
self.g.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(self) -> Graphic {
|
||||||
|
let mut svg = Graphic::new();
|
||||||
|
svg.set_width(self.res.width());
|
||||||
|
svg.set_height(self.res.height());
|
||||||
|
svg.set_view_box("0 0 1920 1080");
|
||||||
|
svg.push(
|
||||||
|
Rect::new()
|
||||||
|
.with_fill("black")
|
||||||
|
.with_x(0)
|
||||||
|
.with_y(0)
|
||||||
|
.with_width(1920)
|
||||||
|
.with_height(1080)
|
||||||
|
);
|
||||||
|
svg.push(self.g);
|
||||||
|
svg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn intro(res: Resolution, date: Date) -> Graphic {
|
||||||
|
use self::{FontSize::*, FontWeight::*};
|
||||||
|
|
||||||
|
let mut intro = Iotro::new(res);
|
||||||
|
intro.add_text(Huge, Bold, 110, "Mathematische Logik II");
|
||||||
|
intro.add_text(Huge, SemiBold, 250, "Prof. E. Grädel");
|
||||||
|
intro.add_text(
|
||||||
|
Huge,
|
||||||
|
SemiBold,
|
||||||
|
460,
|
||||||
|
format!("Vorlesung vom {}", format_date_long(date))
|
||||||
|
);
|
||||||
|
intro.add_text(
|
||||||
|
Big,
|
||||||
|
Normal,
|
||||||
|
870,
|
||||||
|
"Video erstellt von der Video AG, Fachschaft I/1"
|
||||||
|
);
|
||||||
|
intro.add_text(Big, Normal, 930, "https://video.fsmpi.rwth-aachen.de");
|
||||||
|
intro.add_text(Big, Normal, 990, "video@fsmpi.rwth-aachen.de");
|
||||||
|
|
||||||
|
intro.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn outro(res: Resolution) -> Graphic {
|
||||||
|
use self::{FontSize::*, FontWeight::*};
|
||||||
|
|
||||||
|
let mut outro = Iotro::new(res);
|
||||||
|
outro.add_text(Large, SemiBold, 50, "Video erstellt von der");
|
||||||
|
outro.add_text(Huge, Bold, 210, "Video AG, Fachschaft I/1");
|
||||||
|
outro.add_text(Large, Normal, 360, "Website der Fachschaft:");
|
||||||
|
outro.add_text(Large, Normal, 430, "https://www.fsmpi.rwth-aachen.de");
|
||||||
|
outro.add_text(Large, Normal, 570, "Videos herunterladen:");
|
||||||
|
outro.add_text(Large, Normal, 640, "https://video.fsmpi.rwth-aachen.de");
|
||||||
|
outro.add_text(Large, Normal, 780, "Fragen, Vorschläge und Feedback:");
|
||||||
|
outro.add_text(Large, Normal, 850, "video@fsmpi.rwth-aachen.de");
|
||||||
|
|
||||||
|
outro.finish()
|
||||||
|
}
|
82
src/main.rs
82
src/main.rs
|
@ -1,14 +1,18 @@
|
||||||
#![warn(rust_2018_idioms)]
|
#![warn(rust_2018_idioms)]
|
||||||
#![forbid(elided_lifetimes_in_paths, unsafe_code)]
|
#![forbid(elided_lifetimes_in_paths, unsafe_code)]
|
||||||
|
|
||||||
|
mod iotro;
|
||||||
|
mod render;
|
||||||
mod time;
|
mod time;
|
||||||
|
|
||||||
use crate::time::{parse_date, parse_time, Date, Time};
|
use crate::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 serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_with::{serde_as, DisplayFromStr};
|
use serde_with::{serde_as, DisplayFromStr, FromInto};
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::BTreeSet,
|
||||||
fmt::Display,
|
fmt::Display,
|
||||||
fs,
|
fs,
|
||||||
io::{self, BufRead as _, Write}
|
io::{self, BufRead as _, Write}
|
||||||
|
@ -23,10 +27,48 @@ struct Args {
|
||||||
course: String
|
course: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
#[derive(Clone, Copy, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
||||||
|
enum Resolution {
|
||||||
|
/// 640x360
|
||||||
|
nHD,
|
||||||
|
/// 1280x720
|
||||||
|
HD,
|
||||||
|
/// 1920x1080
|
||||||
|
FullHD,
|
||||||
|
/// 2560x1440
|
||||||
|
WQHD,
|
||||||
|
/// 3840x2160
|
||||||
|
UHD
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Resolution {
|
||||||
|
fn width(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::nHD => 640,
|
||||||
|
Self::HD => 1280,
|
||||||
|
Self::FullHD => 1920,
|
||||||
|
Self::WQHD => 2560,
|
||||||
|
Self::UHD => 3840
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn height(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::nHD => 360,
|
||||||
|
Self::HD => 720,
|
||||||
|
Self::FullHD => 1080,
|
||||||
|
Self::WQHD => 1440,
|
||||||
|
Self::UHD => 2160
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
struct Project {
|
struct Project {
|
||||||
lecture: ProjectLecture,
|
lecture: ProjectLecture,
|
||||||
source: ProjectSource
|
source: ProjectSource,
|
||||||
|
progress: ProjectProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
|
@ -41,10 +83,37 @@ struct ProjectLecture {
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
struct ProjectSource {
|
struct ProjectSource {
|
||||||
files: Vec<String>,
|
files: Vec<String>,
|
||||||
|
|
||||||
#[serde_as(as = "DisplayFromStr")]
|
#[serde_as(as = "DisplayFromStr")]
|
||||||
first_file_start: Time,
|
first_file_start: Time,
|
||||||
#[serde_as(as = "DisplayFromStr")]
|
#[serde_as(as = "DisplayFromStr")]
|
||||||
last_file_end: Time
|
last_file_end: Time,
|
||||||
|
|
||||||
|
metadata: Option<ProjectSourceMetadata>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
struct ProjectSourceMetadata {
|
||||||
|
/// The duration of the source video.
|
||||||
|
#[serde_as(as = "DisplayFromStr")]
|
||||||
|
source_duration: Time,
|
||||||
|
/// The FPS of the source video.
|
||||||
|
#[serde_as(as = "FromInto<(i128, i128)>")]
|
||||||
|
source_fps: Rational,
|
||||||
|
/// The time base of the source video.
|
||||||
|
source_tbn: u32,
|
||||||
|
/// The resolution of the source video.
|
||||||
|
source_res: Resolution,
|
||||||
|
/// The sample rate of the source audio.
|
||||||
|
source_sample_rate: u32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize, Serialize)]
|
||||||
|
struct ProjectProgress {
|
||||||
|
preprocessed: bool,
|
||||||
|
rendered: bool,
|
||||||
|
transcoded: BTreeSet<Resolution>
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ask_time(question: impl Display) -> Time {
|
fn ask_time(question: impl Display) -> Time {
|
||||||
|
@ -111,12 +180,15 @@ fn main() {
|
||||||
source: ProjectSource {
|
source: ProjectSource {
|
||||||
files,
|
files,
|
||||||
first_file_start,
|
first_file_start,
|
||||||
last_file_end
|
last_file_end,
|
||||||
}
|
metadata: None
|
||||||
|
},
|
||||||
|
progress: Default::default()
|
||||||
};
|
};
|
||||||
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
||||||
project
|
project
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("{}", toml::to_string(&project).unwrap());
|
println!("{}", toml::to_string(&project).unwrap());
|
||||||
|
// render(&directory, &project).unwrap();
|
||||||
}
|
}
|
||||||
|
|
77
src/render/ffmpeg.rs
Normal file
77
src/render/ffmpeg.rs
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
use super::cmd;
|
||||||
|
use crate::time::{format_time, Time};
|
||||||
|
use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
|
||||||
|
use rational::Rational;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub(crate) struct FfmpegInput {
|
||||||
|
pub(crate) loop_input: bool,
|
||||||
|
pub(crate) fps: Option<Rational>,
|
||||||
|
pub(crate) start: Option<Time>,
|
||||||
|
pub(crate) duration: Option<Time>,
|
||||||
|
pub(crate) path: PathBuf
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FfmpegInput {
|
||||||
|
pub(crate) fn new(path: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
loop_input: false,
|
||||||
|
fps: None,
|
||||||
|
start: None,
|
||||||
|
duration: None,
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_to_cmd(self, cmd: &mut Command) {
|
||||||
|
if self.loop_input {
|
||||||
|
cmd.arg("-loop").arg("1");
|
||||||
|
}
|
||||||
|
if let Some(fps) = self.fps {
|
||||||
|
cmd.arg("-r").arg(fps.to_string());
|
||||||
|
}
|
||||||
|
if let Some(start) = self.start {
|
||||||
|
cmd.arg("-ss").arg(format_time(start));
|
||||||
|
}
|
||||||
|
if let Some(duration) = self.duration {
|
||||||
|
cmd.arg("-t").arg(format_time(duration));
|
||||||
|
}
|
||||||
|
cmd.arg("-i").arg(self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Ffmpeg {
|
||||||
|
inputs: Vec<FfmpegInput>,
|
||||||
|
output: PathBuf
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ffmpeg {
|
||||||
|
pub fn new(output: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
inputs: Vec::new(),
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(self) -> anyhow::Result<()> {
|
||||||
|
let mut cmd = cmd();
|
||||||
|
cmd.arg("ffmpeg").arg("-hide_banner");
|
||||||
|
|
||||||
|
// initialise a vaapi device if one exists
|
||||||
|
let vaapi_device: PathBuf = "/dev/dri/renderD128".into();
|
||||||
|
let vaapi = vaapi_device.exists();
|
||||||
|
if vaapi {
|
||||||
|
cmd.arg("-vaapi_device").arg(&vaapi_device);
|
||||||
|
}
|
||||||
|
|
||||||
|
// append all the inputs
|
||||||
|
for i in self.inputs {
|
||||||
|
i.append_to_cmd(&mut cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// always try to synchronise audio
|
||||||
|
cmd.arg("-async").arg("1");
|
||||||
|
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
}
|
156
src/render/mod.rs
Normal file
156
src/render/mod.rs
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
#![allow(warnings)]
|
||||||
|
|
||||||
|
pub mod ffmpeg;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
iotro::intro,
|
||||||
|
time::{format_date, Time},
|
||||||
|
Project, ProjectSourceMetadata, Resolution
|
||||||
|
};
|
||||||
|
use anyhow::{bail, Context};
|
||||||
|
use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
|
||||||
|
use rational::Rational;
|
||||||
|
use std::{
|
||||||
|
fs::{self, File},
|
||||||
|
io::Write as _,
|
||||||
|
process::{Command, Stdio}
|
||||||
|
};
|
||||||
|
|
||||||
|
const INTRO_LEN: Time = Time {
|
||||||
|
seconds: 3,
|
||||||
|
micros: 0
|
||||||
|
};
|
||||||
|
const OUTRO_LEN: Time = Time {
|
||||||
|
seconds: 5,
|
||||||
|
micros: 0
|
||||||
|
};
|
||||||
|
const TRANSITION: &str = "fadeblack";
|
||||||
|
const TRANSITION_LEN: Time = Time {
|
||||||
|
seconds: 0,
|
||||||
|
micros: 200_000
|
||||||
|
};
|
||||||
|
|
||||||
|
fn cmd() -> Command {
|
||||||
|
let mut cmd = Command::new("busybox");
|
||||||
|
cmd.arg("ash")
|
||||||
|
.arg("-exuo")
|
||||||
|
.arg("pipefail")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("exec");
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ffprobe() -> Command {
|
||||||
|
let mut cmd = cmd();
|
||||||
|
cmd.arg("ffprobe")
|
||||||
|
.arg("-v")
|
||||||
|
.arg("error")
|
||||||
|
.arg("-of")
|
||||||
|
.arg("default=noprint_wrappers=1:nokey=1");
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_output(cmd: &mut Command) -> anyhow::Result<String> {
|
||||||
|
let out = cmd.stderr(Stdio::inherit()).output()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!(
|
||||||
|
"Executed command failed with exit status {:?}",
|
||||||
|
out.status.code()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
String::from_utf8(out.stdout).context("Command returned non-utf8 output")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ffprobe_video(query: &str, input: &Path) -> anyhow::Result<String> {
|
||||||
|
read_output(
|
||||||
|
ffprobe()
|
||||||
|
.arg("-select_streams")
|
||||||
|
.arg("v:0")
|
||||||
|
.arg("-show_entries")
|
||||||
|
.arg(query)
|
||||||
|
.arg(input)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ffprobe_audio(query: &str, concat_input: &Path) -> anyhow::Result<String> {
|
||||||
|
read_output(
|
||||||
|
ffprobe()
|
||||||
|
.arg("-select_streams")
|
||||||
|
.arg("a:0")
|
||||||
|
.arg("-show_entries")
|
||||||
|
.arg(query)
|
||||||
|
.arg("-safe")
|
||||||
|
.arg("0")
|
||||||
|
.arg("-f")
|
||||||
|
.arg("concat")
|
||||||
|
.arg(concat_input)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
/// The directory with all the sources.
|
||||||
|
directory: &'a Path,
|
||||||
|
|
||||||
|
/// The slug (i.e. 23ws-malo2-231016).
|
||||||
|
slug: String,
|
||||||
|
/// The target directory.
|
||||||
|
target: PathBuf
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Renderer<'a> {
|
||||||
|
pub(crate) fn new(directory: &'a Path, project: &Project) -> anyhow::Result<Self> {
|
||||||
|
let slug = format!(
|
||||||
|
"{}-{}",
|
||||||
|
project.lecture.course,
|
||||||
|
format_date(project.lecture.date)
|
||||||
|
);
|
||||||
|
let target = directory.join(&slug);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
directory,
|
||||||
|
slug,
|
||||||
|
target
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn preprocess(&self, project: &mut Project) -> anyhow::Result<()> {
|
||||||
|
assert!(!project.progress.preprocessed);
|
||||||
|
|
||||||
|
let recording_txt = self.target.join("recording.txt");
|
||||||
|
let mut file = File::create(recording_txt)?;
|
||||||
|
for filename in &project.source.files {
|
||||||
|
writeln!(file, "file {:?}", self.directory.join(filename).to_string());
|
||||||
|
}
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
println!("\x1B[1m ==> Concatenating Video and Normalising Audio ...");
|
||||||
|
let mut ffmpeg = Ffmpeg::new();
|
||||||
|
|
||||||
|
// project.source.metadata = Some(ProjectSourceMetadata {
|
||||||
|
// source_duration: ffprobe_video("format=duration", input)?.parse()?
|
||||||
|
// });
|
||||||
|
|
||||||
|
let intro_svg = self.target.join("intro.svg");
|
||||||
|
// fs::write(&intro_svg, intro(res, date));
|
||||||
|
let intro_mp4 = self.target.join("intro.mp4");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
10
src/time.rs
10
src/time.rs
|
@ -6,9 +6,9 @@ use std::{
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct Date {
|
pub struct Date {
|
||||||
year: u16,
|
pub year: u16,
|
||||||
month: u8,
|
pub month: u8,
|
||||||
day: u8
|
pub day: u8
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for Date {
|
impl FromStr for Date {
|
||||||
|
@ -78,8 +78,8 @@ pub fn format_date_long(d: Date) -> String {
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct Time {
|
pub struct Time {
|
||||||
seconds: u32,
|
pub seconds: u32,
|
||||||
micros: u32
|
pub micros: u32
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for Time {
|
impl FromStr for Time {
|
||||||
|
|
Loading…
Reference in a new issue