From 823d8ce5dce7290ad03a6cea7334bbedd38c28e8 Mon Sep 17 00:00:00 2001 From: Dominic <git@msrd0.de> Date: Sat, 28 Oct 2023 23:38:17 +0200 Subject: [PATCH] prepare writing ffmpeg commands --- Cargo.toml | 5 +- src/iotro.rs | 115 +++++++++++++++++++++++++++++++ src/main.rs | 82 +++++++++++++++++++++-- src/render/ffmpeg.rs | 77 +++++++++++++++++++++ src/render/mod.rs | 156 +++++++++++++++++++++++++++++++++++++++++++ src/time.rs | 10 +-- 6 files changed, 434 insertions(+), 11 deletions(-) create mode 100644 src/iotro.rs create mode 100644 src/render/ffmpeg.rs create mode 100644 src/render/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 4f6a213..a9832d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,10 @@ edition = "2021" anyhow = "1.0" camino = "1.1" 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_with = "3.4" +svgwriter = "0.1" toml = { package = "basic-toml", version = "0.1.4" } diff --git a/src/iotro.rs b/src/iotro.rs new file mode 100644 index 0000000..f430656 --- /dev/null +++ b/src/iotro.rs @@ -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() +} diff --git a/src/main.rs b/src/main.rs index d17dcbf..a9ea623 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,18 @@ #![warn(rust_2018_idioms)] #![forbid(elided_lifetimes_in_paths, unsafe_code)] +mod iotro; +mod render; mod time; use crate::time::{parse_date, parse_time, Date, Time}; use camino::Utf8PathBuf as PathBuf; use clap::Parser; +use rational::Rational; use serde::{Deserialize, Serialize}; -use serde_with::{serde_as, DisplayFromStr}; +use serde_with::{serde_as, DisplayFromStr, FromInto}; use std::{ + collections::BTreeSet, fmt::Display, fs, io::{self, BufRead as _, Write} @@ -23,10 +27,48 @@ struct Args { 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)] struct Project { lecture: ProjectLecture, - source: ProjectSource + source: ProjectSource, + progress: ProjectProgress } #[serde_as] @@ -41,10 +83,37 @@ struct ProjectLecture { #[derive(Deserialize, Serialize)] struct ProjectSource { files: Vec<String>, + #[serde_as(as = "DisplayFromStr")] first_file_start: Time, #[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 { @@ -111,12 +180,15 @@ fn main() { source: ProjectSource { files, 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(); project }; println!("{}", toml::to_string(&project).unwrap()); + // render(&directory, &project).unwrap(); } diff --git a/src/render/ffmpeg.rs b/src/render/ffmpeg.rs new file mode 100644 index 0000000..1c94319 --- /dev/null +++ b/src/render/ffmpeg.rs @@ -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!() + } +} diff --git a/src/render/mod.rs b/src/render/mod.rs new file mode 100644 index 0000000..28126a5 --- /dev/null +++ b/src/render/mod.rs @@ -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(()) + } +} diff --git a/src/time.rs b/src/time.rs index 4b52ea4..d6f29bd 100644 --- a/src/time.rs +++ b/src/time.rs @@ -6,9 +6,9 @@ use std::{ #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Date { - year: u16, - month: u8, - day: u8 + pub year: u16, + pub month: u8, + pub day: u8 } impl FromStr for Date { @@ -78,8 +78,8 @@ pub fn format_date_long(d: Date) -> String { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Time { - seconds: u32, - micros: u32 + pub seconds: u32, + pub micros: u32 } impl FromStr for Time { -- GitLab