diff --git a/.forgejo/workflows/webhook.yml b/.forgejo/workflows/webhook.yml index 6f923dd..6a5ecc0 100644 --- a/.forgejo/workflows/webhook.yml +++ b/.forgejo/workflows/webhook.yml @@ -2,6 +2,7 @@ name: Trigger quay.io Webhook on: push: + branches: [main] jobs: run: diff --git a/Cargo.toml b/Cargo.toml index d73faad..e405c72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,4 @@ toml = { package = "basic-toml", version = "0.1.4" } [features] default = ["mem_limit"] mem_limit = [] +vaapi = [] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d457a56 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +**ACHTUNG!** This repository might be mirrored at different places, but the main repository is and remains at [msrd0.dev/msrd0/render_video](https://msrd0.dev/msrd0/render_video). Please redirect all issues and pull requests there. + +# render_video + +This "script" is an extremely fancy wrapper around ffmpeg to cut/render videos for the [VideoAG](https://video.fsmpi.rwth-aachen.de) of the [Fachschaft I/1 der RWTH Aachen University](https://fsmpi.rwth-aachen.de). + +You can find a ready-to-use docker image at [`quay.io/msrd0/render_video`](https://quay.io/msrd0/render_video). + +## Features + + - **Extract a single audio channel from stereo recording.** We use that with one of our cameras that supports plugging a lavalier microphone (mono source) into one channel of the stereo recording, and using the camera microphone (mono source) for the other channel of the stereo recording. + - **Cut away before/after the lecture.** We don't hit the start record button the exact time that the lecture starts, and don't hit the stop button exactly when the lecture ends, so we need to cut away those unwanted bits. + - **Fast-forward through _Tafelwischpausen_.** Sometimes docents still use blackboards and need to wipe those, which can be fast-forwarded by this tool. + - **Overlay questions from the audience.** Sometimes people in the audience have questions, and those are usually poorly understood on the lavalier microphones. Therefore you can subtitle these using the other microphones in the room that don't make it into the final video and have those overlayed. + - **Add intro and outro.** We add intro and outro slides at the start/end at all lectures, which this tool can do for you. + - **Add our logo watermark.** We add a logo watermark in the bottom right corner of all videos, which this tool can do for you. + - **Rescale to lower resolutions.** We usually published videos at different resolutions, and this tool can rescale your video for all resolutions you want. diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..9320c6f --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,54 @@ +//! This module contains helper functions for implementing CLI/TUI. + +use crate::time::{parse_time, Time}; +use console::style; +use std::{ + fmt::Display, + io::{self, BufRead as _, Write as _} +}; + +pub fn ask(question: impl Display) -> String { + let mut stdout = io::stdout().lock(); + let mut stdin = io::stdin().lock(); + + write!( + stdout, + "{} {} ", + style(question).bold().magenta(), + style(">").cyan() + ) + .unwrap(); + stdout.flush().unwrap(); + let mut line = String::new(); + stdin.read_line(&mut line).unwrap(); + line.trim().to_owned() +} + +pub fn ask_time(question: impl Display + Copy) -> Time { + let mut stdout = io::stdout().lock(); + let mut stdin = io::stdin().lock(); + + let mut line = String::new(); + loop { + line.clear(); + write!( + stdout, + "{} {} ", + style(question).bold().magenta(), + style(">").cyan() + ) + .unwrap(); + stdout.flush().unwrap(); + stdin.read_line(&mut line).unwrap(); + let line = line.trim(); + match parse_time(line) { + Ok(time) => return time, + Err(err) => writeln!( + stdout, + "{} {line:?}: {err}", + style("Invalid Input").bold().red() + ) + .unwrap() + } + } +} diff --git a/src/iotro.rs b/src/iotro.rs index a4eeccb..e37045e 100644 --- a/src/iotro.rs +++ b/src/iotro.rs @@ -1,6 +1,9 @@ //! A module for writing intros and outros -use crate::{time::Date, ProjectLecture, Resolution}; +use crate::{ + project::{ProjectLecture, Resolution}, + time::Date +}; use anyhow::anyhow; use std::{ fmt::{self, Debug, Display, Formatter}, diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..998054a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,17 @@ +#![allow(clippy::manual_range_contains)] +#![warn(clippy::unreadable_literal, rust_2018_idioms)] +#![forbid(elided_lifetimes_in_paths, unsafe_code)] + +pub mod cli; +pub mod iotro; +pub mod preset; +pub mod project; +pub mod question; +pub mod render; +pub mod time; + +#[cfg(feature = "mem_limit")] +use std::sync::RwLock; + +#[cfg(feature = "mem_limit")] +pub static MEM_LIMIT: RwLock = RwLock::new(String::new()); diff --git a/src/main.rs b/src/main.rs index 1a5355a..7d0a56b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,32 +2,17 @@ #![warn(clippy::unreadable_literal, rust_2018_idioms)] #![forbid(elided_lifetimes_in_paths, unsafe_code)] -mod iotro; -mod preset; -mod project; -mod question; -mod render; -mod time; - -use self::{ - project::{Project, ProjectLecture, ProjectSource, Resolution}, - render::Renderer, - time::{parse_date, parse_time, Time} -}; -use crate::preset::Preset; use camino::Utf8PathBuf as PathBuf; use clap::Parser; use console::style; -#[cfg(feature = "mem_limit")] -use std::sync::RwLock; -use std::{ - fmt::Display, - fs, - io::{self, BufRead as _, Write} +use render_video::{ + cli::{ask, ask_time}, + preset::Preset, + project::{Project, ProjectLecture, ProjectSource, Resolution}, + render::Renderer, + time::parse_date }; - -#[cfg(feature = "mem_limit")] -static MEM_LIMIT: RwLock = RwLock::new(String::new()); +use std::{collections::BTreeSet, fs}; #[derive(Debug, Parser)] struct Args { @@ -60,58 +45,12 @@ struct Args { stereo: bool } -fn ask(question: impl Display) -> String { - let mut stdout = io::stdout().lock(); - let mut stdin = io::stdin().lock(); - - write!( - stdout, - "{} {} ", - style(question).bold().magenta(), - style(">").cyan() - ) - .unwrap(); - stdout.flush().unwrap(); - let mut line = String::new(); - stdin.read_line(&mut line).unwrap(); - line.trim().to_owned() -} - -fn ask_time(question: impl Display + Copy) -> Time { - let mut stdout = io::stdout().lock(); - let mut stdin = io::stdin().lock(); - - let mut line = String::new(); - loop { - line.clear(); - write!( - stdout, - "{} {} ", - style(question).bold().magenta(), - style(">").cyan() - ) - .unwrap(); - stdout.flush().unwrap(); - stdin.read_line(&mut line).unwrap(); - let line = line.trim(); - match parse_time(line) { - Ok(time) => return time, - Err(err) => writeln!( - stdout, - "{} {line:?}: {err}", - style("Invalid Input").bold().red() - ) - .unwrap() - } - } -} - fn main() { let args = Args::parse(); #[cfg(feature = "mem_limit")] { - *(MEM_LIMIT.write().unwrap()) = args.mem_limit; + *(render_video::MEM_LIMIT.write().unwrap()) = args.mem_limit; } // process arguments @@ -135,7 +74,8 @@ fn main() { let lower = name.to_ascii_lowercase(); if (lower.ends_with(".mp4") || lower.ends_with(".mts") - || lower.ends_with(".mkv")) + || lower.ends_with(".mkv") + || lower.ends_with(".mov")) && !entry.file_type().unwrap().is_dir() { files.push(String::from(name)); @@ -182,7 +122,7 @@ fn main() { project }; - let renderer = Renderer::new(&directory, &project).unwrap(); + let mut renderer = Renderer::new(&directory, &project).unwrap(); let recording = renderer.recording_mkv(); // preprocess the video @@ -275,13 +215,17 @@ fn main() { // rescale the video if let Some(lowest_res) = args.transcode.or(preset.transcode) { - for res in Resolution::values().into_iter().rev() { - if res > project.source.metadata.as_ref().unwrap().source_res - || res > args.transcode_start.unwrap_or(preset.transcode_start) - || res < lowest_res - { - continue; - } + let highest_res = args.transcode_start.unwrap_or(preset.transcode_start); + let source_res = project.source.metadata.as_ref().unwrap().source_res; + let mut resolutions = Resolution::STANDARD_RESOLUTIONS + .into_iter() + .collect::>(); + resolutions.retain(|res| { + *res <= source_res && *res <= highest_res && *res >= lowest_res + }); + resolutions.insert(highest_res); + resolutions.insert(lowest_res); + for res in resolutions { if !project.progress.transcoded.contains(&res) { videos.push(renderer.rescale(&project.lecture, res).unwrap()); project.progress.transcoded.insert(res); diff --git a/src/preset.rs b/src/preset.rs index 85f37c6..507401a 100644 --- a/src/preset.rs +++ b/src/preset.rs @@ -11,57 +11,59 @@ use std::{fs, io}; #[serde_as] #[derive(Deserialize, Serialize)] -pub(crate) struct Preset { +pub struct Preset { // options for the intro slide - pub(crate) course: String, - pub(crate) label: String, - pub(crate) docent: String, + pub course: String, + pub label: String, + pub docent: String, /// Course language - #[serde(default = "Default::default")] + #[serde(default)] #[serde_as(as = "DisplayFromStr")] - pub(crate) lang: Language<'static>, + pub lang: Language<'static>, // coding options - pub(crate) transcode_start: Resolution, - pub(crate) transcode: Option + #[serde_as(as = "DisplayFromStr")] + pub transcode_start: Resolution, + #[serde_as(as = "Option")] + pub transcode: Option } -fn preset_23ws_malo2() -> Preset { +pub fn preset_23ws_malo2() -> Preset { Preset { course: "23ws-malo2".into(), label: "Mathematische Logik II".into(), docent: "Prof. E. Grädel".into(), lang: GERMAN, - transcode_start: Resolution::WQHD, - transcode: Some(Resolution::nHD) + transcode_start: "1440p".parse().unwrap(), + transcode: Some("360p".parse().unwrap()) } } -fn preset_24ss_algomod() -> Preset { +pub fn preset_24ss_algomod() -> Preset { Preset { course: "24ss-algomod".into(), label: "Algorithmische Modelltheorie".into(), docent: "Prof. E. Grädel".into(), lang: GERMAN, - transcode_start: Resolution::WQHD, - transcode: Some(Resolution::HD) + transcode_start: "1440p".parse().unwrap(), + transcode: Some("720p".parse().unwrap()) } } -fn preset_24ss_qc() -> Preset { +pub fn preset_24ss_qc() -> Preset { Preset { course: "24ss-qc".into(), label: "Introduction to Quantum Computing".into(), docent: "Prof. D. Unruh".into(), lang: BRITISH, - transcode_start: Resolution::WQHD, - transcode: Some(Resolution::HD) + transcode_start: "1440p".parse().unwrap(), + transcode: Some("720p".parse().unwrap()) } } impl Preset { - pub(crate) fn find(name: &str) -> anyhow::Result { + pub fn find(name: &str) -> anyhow::Result { match fs::read(name) { Ok(buf) => return Ok(toml::from_slice(&buf)?), Err(err) if err.kind() == io::ErrorKind::NotFound => {}, diff --git a/src/project.rs b/src/project.rs index 7dbda1f..82eba5d 100644 --- a/src/project.rs +++ b/src/project.rs @@ -8,157 +8,222 @@ use crate::{ use rational::Rational; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; -use std::{collections::BTreeSet, str::FromStr}; +use std::{ + cmp, + collections::BTreeSet, + fmt::{self, Display, Formatter}, + str::FromStr +}; -macro_rules! resolutions { - ($($res:ident: $width:literal x $height:literal at $bitrate:literal in $format:ident),+) => { - #[allow(non_camel_case_types, clippy::upper_case_acronyms)] - #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] - pub(crate) enum Resolution { - $( - #[doc = concat!(stringify!($width), "x", stringify!($height))] - $res - ),+ +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub struct Resolution(u32, u32); + +impl Resolution { + pub fn new(width: u32, height: u32) -> Self { + Self(width, height) + } + + pub fn width(self) -> u32 { + self.0 + } + + pub fn height(self) -> u32 { + self.1 + } + + pub(crate) fn bitrate(self) -> u64 { + // 640 * 360: 500k + if self.width() <= 640 { + 500_000 } - - const NUM_RESOLUTIONS: usize = { - let mut num = 0; - $(num += 1; stringify!($res);)+ - num - }; - - impl Resolution { - pub(crate) fn values() -> [Self; NUM_RESOLUTIONS] { - [$(Self::$res),+] - } - - pub(crate) fn width(self) -> usize { - match self { - $(Self::$res => $width),+ - } - } - - pub(crate) fn height(self) -> usize { - match self { - $(Self::$res => $height),+ - } - } - - pub(crate) fn bitrate(self) -> u64 { - match self { - $(Self::$res => $bitrate),+ - } - } - - pub(crate) fn format(self) -> FfmpegOutputFormat { - match self { - $(Self::$res => FfmpegOutputFormat::$format),+ - } - } + // 1280 * 720: 1M + else if self.width() <= 1280 { + 1_000_000 } - - impl FromStr for Resolution { - type Err = anyhow::Error; - - fn from_str(s: &str) -> anyhow::Result { - Ok(match s { - $(concat!(stringify!($height), "p") => Self::$res,)+ - _ => anyhow::bail!("Unknown Resolution: {s:?}") - }) - } + // 1920 * 1080: 2M + else if self.width() <= 1920 { + 2_000_000 } + // 2560 * 1440: 3M + else if self.width() <= 2560 { + 3_000_000 + } + // 3840 * 2160: 4M + // TODO die bitrate von 4M ist absolut an den haaren herbeigezogen + else if self.width() <= 3840 { + 4_000_000 + } + // we'll cap everything else at 5M for no apparent reason + else { + 5_000_000 + } + } + + pub(crate) fn default_codec(self) -> FfmpegOutputFormat { + if self.width() > 1920 { + FfmpegOutputFormat::Av1Opus + } else { + FfmpegOutputFormat::AvcAac + } + } + + pub const STANDARD_RESOLUTIONS: [Self; 5] = [ + Self(640, 360), + Self(1280, 720), + Self(1920, 1080), + Self(2560, 1440), + Self(3840, 2160) + ]; +} + +impl Display for Resolution { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}p", self.height()) } } -resolutions! { - nHD: 640 x 360 at 500_000 in AvcAac, - HD: 1280 x 720 at 1_000_000 in AvcAac, - FullHD: 1920 x 1080 at 750_000 in Av1Opus, - WQHD: 2560 x 1440 at 1_000_000 in Av1Opus, - // TODO qsx muss mal sagen wieviel bitrate für 4k - UHD: 3840 x 2160 at 2_000_000 in Av1Opus +impl FromStr for Resolution { + type Err = anyhow::Error; + + fn from_str(s: &str) -> anyhow::Result { + Ok(match s.to_lowercase().as_str() { + "360p" | "nhd" => Self(640, 360), + "540p" | "qhd" => Self(960, 540), + "720p" | "hd" => Self(1280, 720), + "900p" | "hd+" => Self(1600, 900), + "1080p" | "fhd" | "fullhd" => Self(1920, 1080), + "1440p" | "wqhd" => Self(2560, 1440), + "2160p" | "4k" | "uhd" => Self(3840, 2160), + lower => { + let mut parts = lower.split('x'); + match ( + parts.next().map(|width| width.parse()), + parts.next().map(|height| height.parse()), + parts.next() + ) { + (Some(Ok(width)), Some(Ok(height)), None) => Self(width, height), + _ => anyhow::bail!("Unknown Resolution: {s:?}") + } + } + }) + } } -#[derive(Deserialize, Serialize)] -pub(crate) struct Project { - pub(crate) lecture: ProjectLecture, - pub(crate) source: ProjectSource, - pub(crate) progress: ProjectProgress +impl Ord for Resolution { + fn cmp(&self, other: &Self) -> cmp::Ordering { + (self.0 * self.1).cmp(&(other.0 * other.1)) + } +} + +impl Eq for Resolution {} + +impl PartialOrd for Resolution { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialEq for Resolution { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == cmp::Ordering::Equal + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Project { + pub lecture: ProjectLecture, + pub source: ProjectSource, + pub progress: ProjectProgress } #[serde_as] -#[derive(Deserialize, Serialize)] -pub(crate) struct ProjectLecture { - pub(crate) course: String, - pub(crate) label: String, - pub(crate) docent: String, +#[derive(Debug, Deserialize, Serialize)] +pub struct ProjectLecture { + pub course: String, + pub label: String, + pub docent: String, #[serde_as(as = "DisplayFromStr")] - pub(crate) date: Date, + pub date: Date, #[serde(default = "Default::default")] #[serde_as(as = "DisplayFromStr")] - pub(crate) lang: Language<'static> + pub lang: Language<'static> } #[serde_as] -#[derive(Deserialize, Serialize)] -pub(crate) struct ProjectSource { - pub(crate) files: Vec, - pub(crate) stereo: bool, +#[derive(Debug, Deserialize, Serialize)] +pub struct ProjectSource { + pub files: Vec, + pub stereo: bool, #[serde_as(as = "Option")] - pub(crate) start: Option