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