Compare commits

...

13 commits
240527 ... main

Author SHA1 Message Date
d5bb7a4bdc
only use vaapi when feature is enabled
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 4s
2024-07-08 13:18:26 +02:00
5e1f5e8829
add readme
[skip ci] no need
2024-06-29 11:21:06 +02:00
5808bff395
fix still defaulting to av1, allow aac to be paired with flac
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 4s
2024-06-28 18:19:08 +02:00
330515d6b4
fix toml serialiser being stupid
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 5s
2024-06-28 16:56:07 +02:00
7662150b89
fix codec comparison; encode with higher quality for intermediate results
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 5s
2024-06-26 12:05:40 +02:00
b6fb0fa184
uncommitted stuff: some bitrate changes [skip ci] 2024-06-23 23:59:23 +02:00
6e56452f78
limit webhook ci to main branch
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 5s
2024-06-23 17:54:18 +02:00
680ea8f4e5
Refactor the code into a binary and library (#1)
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 5s
Reviewed-on: #1
Co-authored-by: Dominic <git@msrd0.de>
Co-committed-by: Dominic <git@msrd0.de>
2024-06-23 15:53:45 +00:00
13c03559d0
set svt-av1 preset to 7 and quality to 28
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 6s
2024-06-13 15:53:40 +02:00
f9129b2351
we are using av1 for 1080p
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 5s
2024-06-04 09:51:45 +02:00
9a58e39bf8
fix typo, thanks Dorian
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 7s
This code path is currently unused given that we accumulate the input
into a recording.mkv file that doesn't have the cursed mp4/mov behaviour.
2024-06-04 09:47:58 +02:00
2fdb653496
seek_streams_individually can also happen with .mov files
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 6s
2024-05-30 11:13:12 +02:00
cbdf55335a
detect .mov files
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 6s
2024-05-27 20:05:41 +02:00
12 changed files with 350 additions and 252 deletions

View file

@ -2,6 +2,7 @@ name: Trigger quay.io Webhook
on: on:
push: push:
branches: [main]
jobs: jobs:
run: run:

View file

@ -24,3 +24,4 @@ toml = { package = "basic-toml", version = "0.1.4" }
[features] [features]
default = ["mem_limit"] default = ["mem_limit"]
mem_limit = [] mem_limit = []
vaapi = []

17
README.md Normal file
View file

@ -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.

54
src/cli.rs Normal file
View file

@ -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()
}
}
}

View file

@ -1,6 +1,9 @@
//! A module for writing intros and outros //! A module for writing intros and outros
use crate::{time::Date, ProjectLecture, Resolution}; use crate::{
project::{ProjectLecture, Resolution},
time::Date
};
use anyhow::anyhow; use anyhow::anyhow;
use std::{ use std::{
fmt::{self, Debug, Display, Formatter}, fmt::{self, Debug, Display, Formatter},

17
src/lib.rs Normal file
View file

@ -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<String> = RwLock::new(String::new());

View file

@ -2,32 +2,17 @@
#![warn(clippy::unreadable_literal, rust_2018_idioms)] #![warn(clippy::unreadable_literal, rust_2018_idioms)]
#![forbid(elided_lifetimes_in_paths, unsafe_code)] #![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 camino::Utf8PathBuf as PathBuf;
use clap::Parser; use clap::Parser;
use console::style; use console::style;
#[cfg(feature = "mem_limit")] use render_video::{
use std::sync::RwLock; cli::{ask, ask_time},
use std::{ preset::Preset,
fmt::Display, project::{Project, ProjectLecture, ProjectSource, Resolution},
fs, render::Renderer,
io::{self, BufRead as _, Write} time::parse_date
}; };
use std::fs;
#[cfg(feature = "mem_limit")]
static MEM_LIMIT: RwLock<String> = RwLock::new(String::new());
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
struct Args { struct Args {
@ -60,58 +45,12 @@ struct Args {
stereo: bool 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() { fn main() {
let args = Args::parse(); let args = Args::parse();
#[cfg(feature = "mem_limit")] #[cfg(feature = "mem_limit")]
{ {
*(MEM_LIMIT.write().unwrap()) = args.mem_limit; *(render_video::MEM_LIMIT.write().unwrap()) = args.mem_limit;
} }
// process arguments // process arguments
@ -135,7 +74,8 @@ fn main() {
let lower = name.to_ascii_lowercase(); let lower = name.to_ascii_lowercase();
if (lower.ends_with(".mp4") if (lower.ends_with(".mp4")
|| lower.ends_with(".mts") || lower.ends_with(".mts")
|| lower.ends_with(".mkv")) || lower.ends_with(".mkv")
|| lower.ends_with(".mov"))
&& !entry.file_type().unwrap().is_dir() && !entry.file_type().unwrap().is_dir()
{ {
files.push(String::from(name)); files.push(String::from(name));
@ -182,7 +122,7 @@ fn main() {
project project
}; };
let renderer = Renderer::new(&directory, &project).unwrap(); let mut renderer = Renderer::new(&directory, &project).unwrap();
let recording = renderer.recording_mkv(); let recording = renderer.recording_mkv();
// preprocess the video // preprocess the video
@ -275,7 +215,7 @@ fn main() {
// rescale the video // rescale the video
if let Some(lowest_res) = args.transcode.or(preset.transcode) { if let Some(lowest_res) = args.transcode.or(preset.transcode) {
for res in Resolution::values().into_iter().rev() { for res in Resolution::STANDARD_RESOLUTIONS.into_iter().rev() {
if res > project.source.metadata.as_ref().unwrap().source_res if res > project.source.metadata.as_ref().unwrap().source_res
|| res > args.transcode_start.unwrap_or(preset.transcode_start) || res > args.transcode_start.unwrap_or(preset.transcode_start)
|| res < lowest_res || res < lowest_res

View file

@ -11,57 +11,59 @@ use std::{fs, io};
#[serde_as] #[serde_as]
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub(crate) struct Preset { pub struct Preset {
// options for the intro slide // options for the intro slide
pub(crate) course: String, pub course: String,
pub(crate) label: String, pub label: String,
pub(crate) docent: String, pub docent: String,
/// Course language /// Course language
#[serde(default = "Default::default")] #[serde(default)]
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "DisplayFromStr")]
pub(crate) lang: Language<'static>, pub lang: Language<'static>,
// coding options // coding options
pub(crate) transcode_start: Resolution, #[serde_as(as = "DisplayFromStr")]
pub(crate) transcode: Option<Resolution> pub transcode_start: Resolution,
#[serde_as(as = "Option<DisplayFromStr>")]
pub transcode: Option<Resolution>
} }
fn preset_23ws_malo2() -> Preset { pub fn preset_23ws_malo2() -> Preset {
Preset { Preset {
course: "23ws-malo2".into(), course: "23ws-malo2".into(),
label: "Mathematische Logik II".into(), label: "Mathematische Logik II".into(),
docent: "Prof. E. Grädel".into(), docent: "Prof. E. Grädel".into(),
lang: GERMAN, lang: GERMAN,
transcode_start: Resolution::WQHD, transcode_start: "1440p".parse().unwrap(),
transcode: Some(Resolution::nHD) transcode: Some("360p".parse().unwrap())
} }
} }
fn preset_24ss_algomod() -> Preset { pub fn preset_24ss_algomod() -> Preset {
Preset { Preset {
course: "24ss-algomod".into(), course: "24ss-algomod".into(),
label: "Algorithmische Modelltheorie".into(), label: "Algorithmische Modelltheorie".into(),
docent: "Prof. E. Grädel".into(), docent: "Prof. E. Grädel".into(),
lang: GERMAN, lang: GERMAN,
transcode_start: Resolution::WQHD, transcode_start: "1440p".parse().unwrap(),
transcode: Some(Resolution::HD) transcode: Some("720p".parse().unwrap())
} }
} }
fn preset_24ss_qc() -> Preset { pub fn preset_24ss_qc() -> Preset {
Preset { Preset {
course: "24ss-qc".into(), course: "24ss-qc".into(),
label: "Introduction to Quantum Computing".into(), label: "Introduction to Quantum Computing".into(),
docent: "Prof. D. Unruh".into(), docent: "Prof. D. Unruh".into(),
lang: BRITISH, lang: BRITISH,
transcode_start: Resolution::WQHD, transcode_start: "1440p".parse().unwrap(),
transcode: Some(Resolution::HD) transcode: Some("720p".parse().unwrap())
} }
} }
impl Preset { impl Preset {
pub(crate) fn find(name: &str) -> anyhow::Result<Self> { pub fn find(name: &str) -> anyhow::Result<Self> {
match fs::read(name) { match fs::read(name) {
Ok(buf) => return Ok(toml::from_slice(&buf)?), Ok(buf) => return Ok(toml::from_slice(&buf)?),
Err(err) if err.kind() == io::ErrorKind::NotFound => {}, Err(err) if err.kind() == io::ErrorKind::NotFound => {},

View file

@ -8,157 +8,199 @@ use crate::{
use rational::Rational; use rational::Rational;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DisplayFromStr}; use serde_with::{serde_as, DisplayFromStr};
use std::{collections::BTreeSet, str::FromStr}; use std::{
cmp,
macro_rules! resolutions { collections::BTreeSet,
($($res:ident: $width:literal x $height:literal at $bitrate:literal in $format:ident),+) => { fmt::{self, Display, Formatter},
#[allow(non_camel_case_types, clippy::upper_case_acronyms)] str::FromStr
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
pub(crate) enum Resolution {
$(
#[doc = concat!(stringify!($width), "x", stringify!($height))]
$res
),+
}
const NUM_RESOLUTIONS: usize = {
let mut num = 0;
$(num += 1; stringify!($res);)+
num
}; };
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
pub struct Resolution(u32, u32);
impl Resolution { impl Resolution {
pub(crate) fn values() -> [Self; NUM_RESOLUTIONS] { pub fn new(width: u32, height: u32) -> Self {
[$(Self::$res),+] Self(width, height)
} }
pub(crate) fn width(self) -> usize { pub fn width(self) -> u32 {
match self { self.0
$(Self::$res => $width),+
}
} }
pub(crate) fn height(self) -> usize { pub fn height(self) -> u32 {
match self { self.1
$(Self::$res => $height),+
}
} }
pub(crate) fn bitrate(self) -> u64 { pub(crate) fn bitrate(self) -> u64 {
match self { // 640 * 360: 500k
$(Self::$res => $bitrate),+ if self.width() <= 640 {
500_000
}
// 1280 * 720: 1M
else if self.width() <= 1280 {
1_000_000
}
// 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 format(self) -> FfmpegOutputFormat { pub(crate) fn default_codec(self) -> FfmpegOutputFormat {
match self { if self.width() > 1920 {
$(Self::$res => FfmpegOutputFormat::$format),+ 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())
}
} }
impl FromStr for Resolution { impl FromStr for Resolution {
type Err = anyhow::Error; type Err = anyhow::Error;
fn from_str(s: &str) -> anyhow::Result<Self> { fn from_str(s: &str) -> anyhow::Result<Self> {
Ok(match s { Ok(match s.to_lowercase().as_str() {
$(concat!(stringify!($height), "p") => Self::$res,)+ "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),
_ => anyhow::bail!("Unknown Resolution: {s:?}") _ => anyhow::bail!("Unknown Resolution: {s:?}")
}) })
} }
} }
impl Ord for Resolution {
fn cmp(&self, other: &Self) -> cmp::Ordering {
(self.0 * self.1).cmp(&(other.0 * other.1))
} }
} }
resolutions! { impl Eq for Resolution {}
nHD: 640 x 360 at 500_000 in AvcAac,
HD: 1280 x 720 at 1_000_000 in AvcAac, impl PartialOrd for Resolution {
FullHD: 1920 x 1080 at 750_000 in Av1Opus, fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
WQHD: 2560 x 1440 at 1_000_000 in Av1Opus, Some(self.cmp(other))
// TODO qsx muss mal sagen wieviel bitrate für 4k }
UHD: 3840 x 2160 at 2_000_000 in Av1Opus
} }
#[derive(Deserialize, Serialize)] impl PartialEq for Resolution {
pub(crate) struct Project { fn eq(&self, other: &Self) -> bool {
pub(crate) lecture: ProjectLecture, self.cmp(other) == cmp::Ordering::Equal
pub(crate) source: ProjectSource, }
pub(crate) progress: ProjectProgress }
#[derive(Debug, Deserialize, Serialize)]
pub struct Project {
pub lecture: ProjectLecture,
pub source: ProjectSource,
pub progress: ProjectProgress
} }
#[serde_as] #[serde_as]
#[derive(Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub(crate) struct ProjectLecture { pub struct ProjectLecture {
pub(crate) course: String, pub course: String,
pub(crate) label: String, pub label: String,
pub(crate) docent: String, pub docent: String,
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "DisplayFromStr")]
pub(crate) date: Date, pub date: Date,
#[serde(default = "Default::default")] #[serde(default = "Default::default")]
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "DisplayFromStr")]
pub(crate) lang: Language<'static> pub lang: Language<'static>
} }
#[serde_as] #[serde_as]
#[derive(Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub(crate) struct ProjectSource { pub struct ProjectSource {
pub(crate) files: Vec<String>, pub files: Vec<String>,
pub(crate) stereo: bool, pub stereo: bool,
#[serde_as(as = "Option<DisplayFromStr>")] #[serde_as(as = "Option<DisplayFromStr>")]
pub(crate) start: Option<Time>, pub start: Option<Time>,
#[serde_as(as = "Option<DisplayFromStr>")] #[serde_as(as = "Option<DisplayFromStr>")]
pub(crate) end: Option<Time>, pub end: Option<Time>,
#[serde(default)] #[serde(default)]
#[serde_as(as = "Vec<(DisplayFromStr, DisplayFromStr)>")] #[serde_as(as = "Vec<(DisplayFromStr, DisplayFromStr)>")]
pub(crate) fast: Vec<(Time, Time)>, pub fast: Vec<(Time, Time)>,
#[serde(default)] #[serde(default)]
#[serde_as(as = "Vec<(DisplayFromStr, DisplayFromStr, _)>")] #[serde_as(as = "Vec<(DisplayFromStr, DisplayFromStr, _)>")]
pub(crate) questions: Vec<(Time, Time, String)>, pub questions: Vec<(Time, Time, String)>,
pub(crate) metadata: Option<ProjectSourceMetadata> pub metadata: Option<ProjectSourceMetadata>
} }
#[serde_as] #[serde_as]
#[derive(Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub(crate) struct ProjectSourceMetadata { pub struct ProjectSourceMetadata {
/// The duration of the source video. /// The duration of the source video.
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "DisplayFromStr")]
pub(crate) source_duration: Time, pub source_duration: Time,
/// The FPS of the source video. /// The FPS of the source video.
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "DisplayFromStr")]
pub(crate) source_fps: Rational, pub source_fps: Rational,
/// The time base of the source video. /// The time base of the source video.
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "DisplayFromStr")]
pub(crate) source_tbn: Rational, pub source_tbn: Rational,
/// The resolution of the source video. /// The resolution of the source video.
pub(crate) source_res: Resolution, pub source_res: Resolution,
/// The sample rate of the source audio. /// The sample rate of the source audio.
pub(crate) source_sample_rate: u32 pub source_sample_rate: u32
} }
#[derive(Default, Deserialize, Serialize)] #[serde_as]
pub(crate) struct ProjectProgress { #[derive(Debug, Default, Deserialize, Serialize)]
pub struct ProjectProgress {
#[serde(default)] #[serde(default)]
pub(crate) preprocessed: bool, pub preprocessed: bool,
#[serde(default)] #[serde(default)]
pub(crate) asked_start_end: bool, pub asked_start_end: bool,
#[serde(default)] #[serde(default)]
pub(crate) asked_fast: bool, pub asked_fast: bool,
#[serde(default)] #[serde(default)]
pub(crate) asked_questions: bool, pub asked_questions: bool,
#[serde(default)] #[serde(default)]
pub(crate) rendered_assets: bool, pub rendered_assets: bool,
#[serde(default)] #[serde(default)]
pub(crate) rendered: bool, pub rendered: bool,
#[serde_as(as = "BTreeSet<DisplayFromStr>")]
#[serde(default)] #[serde(default)]
pub(crate) transcoded: BTreeSet<Resolution> pub transcoded: BTreeSet<Resolution>
} }

View file

@ -1,4 +1,4 @@
use crate::{iotro::Language, Resolution}; use crate::{iotro::Language, project::Resolution};
use fontconfig::Fontconfig; use fontconfig::Fontconfig;
use harfbuzz_rs::{Face, Font, Owned, UnicodeBuffer}; use harfbuzz_rs::{Face, Font, Owned, UnicodeBuffer};
use std::sync::OnceLock; use std::sync::OnceLock;

View file

@ -1,8 +1,8 @@
use super::{cmd, filter::Filter}; use super::{cmd, filter::Filter};
use crate::{ use crate::{
project::Resolution,
render::filter::channel, render::filter::channel,
time::{format_time, Time}, time::{format_time, Time}
Resolution
}; };
use anyhow::bail; use anyhow::bail;
use camino::Utf8PathBuf as PathBuf; use camino::Utf8PathBuf as PathBuf;
@ -41,8 +41,8 @@ impl FfmpegInput {
cmd.arg("-r").arg(fps.to_string()); cmd.arg("-r").arg(fps.to_string());
} }
if let Some(start) = self.start { if let Some(start) = self.start {
if self.path.ends_with(".mp4") { if self.path.ends_with(".mp4") || self.path.ends_with(".mov") {
cmd.arg("-seek_streams_individualy").arg("false"); cmd.arg("-seek_streams_individually").arg("false");
} }
cmd.arg("-ss").arg(format_time(start)); cmd.arg("-ss").arg(format_time(start));
} }
@ -59,12 +59,30 @@ pub(crate) enum FfmpegOutputFormat {
Av1Flac, Av1Flac,
/// AV1 / OPUS /// AV1 / OPUS
Av1Opus, Av1Opus,
/// AVC (H.264) / FLAC
AvcFlac,
/// AVC (H.264) / AAC /// AVC (H.264) / AAC
AvcAac AvcAac
} }
impl FfmpegOutputFormat {
pub(crate) fn with_flac_audio(self) -> Self {
match self {
Self::Av1Flac | Self::AvcFlac => self,
Self::Av1Opus => Self::Av1Flac,
Self::AvcAac => Self::AvcFlac
}
}
}
pub(crate) enum FfmpegOutputQuality {
Default,
VisuallyLossless
}
pub(crate) struct FfmpegOutput { pub(crate) struct FfmpegOutput {
pub(crate) format: FfmpegOutputFormat, pub(crate) format: FfmpegOutputFormat,
pub(crate) quality: FfmpegOutputQuality,
pub(crate) audio_bitrate: Option<u64>, pub(crate) audio_bitrate: Option<u64>,
pub(crate) video_bitrate: Option<u64>, pub(crate) video_bitrate: Option<u64>,
@ -89,6 +107,7 @@ impl FfmpegOutput {
pub(crate) fn new(format: FfmpegOutputFormat, path: PathBuf) -> Self { pub(crate) fn new(format: FfmpegOutputFormat, path: PathBuf) -> Self {
Self { Self {
format, format,
quality: FfmpegOutputQuality::Default,
audio_bitrate: None, audio_bitrate: None,
video_bitrate: None, video_bitrate: None,
@ -118,41 +137,48 @@ impl FfmpegOutput {
} }
fn append_to_cmd(self, cmd: &mut Command, venc: bool, _aenc: bool, vaapi: bool) { fn append_to_cmd(self, cmd: &mut Command, venc: bool, _aenc: bool, vaapi: bool) {
// select codec and bitrate // select codec and bitrate/crf
const QUALITY: &str = "18";
if venc { if venc {
let vcodec = match (self.format, vaapi) { let vcodec = match (self.format, vaapi) {
(FfmpegOutputFormat::Av1Flac, false) (FfmpegOutputFormat::Av1Flac, false)
| (FfmpegOutputFormat::Av1Opus, false) => "libsvtav1", | (FfmpegOutputFormat::Av1Opus, false) => "libsvtav1",
(FfmpegOutputFormat::Av1Flac, true) (FfmpegOutputFormat::Av1Flac, true)
| (FfmpegOutputFormat::Av1Opus, true) => "av1_vaapi", | (FfmpegOutputFormat::Av1Opus, true) => "av1_vaapi",
(FfmpegOutputFormat::AvcAac, false) => "h264", (FfmpegOutputFormat::AvcAac, false)
(FfmpegOutputFormat::AvcAac, true) => "h264_vaapi" | (FfmpegOutputFormat::AvcFlac, false) => "h264",
(FfmpegOutputFormat::AvcAac, true)
| (FfmpegOutputFormat::AvcFlac, true) => "h264_vaapi"
}; };
cmd.arg("-c:v").arg(vcodec); cmd.arg("-c:v").arg(vcodec);
if vcodec == "libsvtav1" { if vcodec == "libsvtav1" {
cmd.arg("-svtav1-params").arg("fast-decode=1"); cmd.arg("-svtav1-params").arg("fast-decode=1");
cmd.arg("-preset").arg("8"); cmd.arg("-preset").arg("7");
cmd.arg("-crf").arg(match self.quality {
FfmpegOutputQuality::Default => "28",
FfmpegOutputQuality::VisuallyLossless => "18"
});
} else if vcodec == "h264" {
match self.quality {
FfmpegOutputQuality::Default => {
cmd.arg("-preset").arg("slow");
cmd.arg("-crf").arg("21");
},
FfmpegOutputQuality::VisuallyLossless => {
// the quality is not impacted by speed, only the bitrate, and
// for this setting we don't really care about bitrate
cmd.arg("-preset").arg("veryfast");
cmd.arg("-crf").arg("17");
} }
}
match self.video_bitrate { } else if let Some(bv) = self.video_bitrate {
Some(bv) if vcodec != "libsvtav1" => {
cmd.arg("-b:v").arg(bv.to_string()); cmd.arg("-b:v").arg(bv.to_string());
},
None if vaapi => {
cmd.arg("-rc_mode").arg("CQP");
cmd.arg("-global_quality").arg(QUALITY);
},
_ => {
cmd.arg("-crf").arg(QUALITY);
}
} }
} else { } else {
cmd.arg("-c:v").arg("copy"); cmd.arg("-c:v").arg("copy");
} }
cmd.arg("-c:a").arg(match self.format { cmd.arg("-c:a").arg(match self.format {
FfmpegOutputFormat::Av1Flac => "flac", FfmpegOutputFormat::Av1Flac | FfmpegOutputFormat::AvcFlac => "flac",
FfmpegOutputFormat::Av1Opus => "libopus", FfmpegOutputFormat::Av1Opus => "libopus",
FfmpegOutputFormat::AvcAac => "aac" FfmpegOutputFormat::AvcAac => "aac"
}); });
@ -296,7 +322,7 @@ impl Ffmpeg {
// 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();
let vaapi = vaapi_device.exists(); let vaapi = cfg!(feature = "vaapi") && vaapi_device.exists();
if vaapi && venc { if vaapi && venc {
if vdec { if vdec {
cmd.arg("-hwaccel").arg("vaapi"); cmd.arg("-hwaccel").arg("vaapi");

View file

@ -15,6 +15,7 @@ use crate::{
use anyhow::{bail, Context}; use anyhow::{bail, Context};
use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf}; use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
use console::style; use console::style;
use ffmpeg::FfmpegOutputQuality;
use std::{ use std::{
borrow::Cow, borrow::Cow,
collections::VecDeque, collections::VecDeque,
@ -41,8 +42,8 @@ const QUESTION_FADE_LEN: Time = Time {
}; };
const FF_MULTIPLIER: usize = 8; const FF_MULTIPLIER: usize = 8;
// logo sizes at full hd, will be scaled to source resolution // logo sizes at full hd, will be scaled to source resolution
const FF_LOGO_SIZE: usize = 128; const FF_LOGO_SIZE: u32 = 128;
const LOGO_SIZE: usize = 96; const LOGO_SIZE: u32 = 96;
fn cmd() -> Command { fn cmd() -> Command {
#[cfg(feature = "mem_limit")] #[cfg(feature = "mem_limit")]
@ -119,7 +120,7 @@ fn ffprobe_audio(query: &str, concat_input: &Path) -> anyhow::Result<String> {
) )
} }
pub(crate) struct Renderer<'a> { pub struct Renderer<'a> {
/// The directory with all the sources. /// The directory with all the sources.
directory: &'a Path, directory: &'a Path,
@ -139,6 +140,7 @@ fn svg2mkv(
duration: Time duration: Time
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let mut ffmpeg = Ffmpeg::new(FfmpegOutput { let mut ffmpeg = Ffmpeg::new(FfmpegOutput {
quality: FfmpegOutputQuality::VisuallyLossless,
duration: Some(duration), duration: Some(duration),
time_base: Some(meta.source_tbn), time_base: Some(meta.source_tbn),
fps_mode_vfr: true, fps_mode_vfr: true,
@ -157,7 +159,7 @@ fn svg2mkv(
ffmpeg.run() ffmpeg.run()
} }
fn svg2png(svg: &Path, png: &Path, width: usize, height: usize) -> anyhow::Result<()> { fn svg2png(svg: &Path, png: &Path, width: u32, height: u32) -> anyhow::Result<()> {
let mut cmd = cmd(); let mut cmd = cmd();
cmd.arg("inkscape") cmd.arg("inkscape")
.arg("-w") .arg("-w")
@ -175,7 +177,7 @@ fn svg2png(svg: &Path, png: &Path, width: usize, height: usize) -> anyhow::Resul
} }
impl<'a> Renderer<'a> { impl<'a> Renderer<'a> {
pub(crate) fn new(directory: &'a Path, project: &Project) -> anyhow::Result<Self> { pub fn new(directory: &'a Path, project: &Project) -> anyhow::Result<Self> {
let slug = format!( let slug = format!(
"{}-{}", "{}-{}",
project.lecture.course, project.lecture.course,
@ -184,23 +186,21 @@ impl<'a> Renderer<'a> {
let target = directory.join(&slug); let target = directory.join(&slug);
fs::create_dir_all(&target)?; fs::create_dir_all(&target)?;
let first: PathBuf = directory.join( // Ensure we have at least one input file.
project project
.source .source
.files .files
.first() .first()
.context("No source files present")? .context("No source files present")?;
);
let height: u32 = ffprobe_video("stream=height", &first)? // In case we don't have a resolution yet, we'll asign this after preprocessing.
.split('\n') let format = project
.next() .source
.unwrap() .metadata
.parse()?; .as_ref()
let format = if height <= 1080 { .map(|meta| meta.source_res.default_codec())
FfmpegOutputFormat::AvcAac .unwrap_or(FfmpegOutputFormat::Av1Flac)
} else { .with_flac_audio();
FfmpegOutputFormat::Av1Flac
};
Ok(Self { Ok(Self {
directory, directory,
@ -210,7 +210,7 @@ impl<'a> Renderer<'a> {
}) })
} }
pub(crate) fn recording_mkv(&self) -> PathBuf { pub fn recording_mkv(&self) -> PathBuf {
self.target.join("recording.mkv") self.target.join("recording.mkv")
} }
@ -230,7 +230,7 @@ impl<'a> Renderer<'a> {
self.target.join(format!("question{q_idx}.png")) self.target.join(format!("question{q_idx}.png"))
} }
pub(crate) fn preprocess(&self, project: &mut Project) -> anyhow::Result<()> { pub fn preprocess(&mut 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");
@ -263,14 +263,7 @@ impl<'a> Renderer<'a> {
let width = ffprobe_video("stream=width", &recording_mkv)?.parse()?; let width = ffprobe_video("stream=width", &recording_mkv)?.parse()?;
let height = ffprobe_video("stream=height", &recording_mkv)?.parse()?; let height = ffprobe_video("stream=height", &recording_mkv)?.parse()?;
let source_res = match (width, height) { let source_res = Resolution::new(width, height);
(3840, 2160) => Resolution::UHD,
(2560, 1440) => Resolution::WQHD,
(1920, 1080) => Resolution::FullHD,
(1280, 720) => Resolution::HD,
(640, 360) => Resolution::nHD,
(width, height) => bail!("Unknown resolution: {width}x{height}")
};
project.source.metadata = Some(ProjectSourceMetadata { project.source.metadata = Some(ProjectSourceMetadata {
source_duration: ffprobe_video("format=duration", &recording_mkv)?.parse()?, source_duration: ffprobe_video("format=duration", &recording_mkv)?.parse()?,
source_fps: ffprobe_video("stream=r_frame_rate", &recording_mkv)?.parse()?, source_fps: ffprobe_video("stream=r_frame_rate", &recording_mkv)?.parse()?,
@ -278,12 +271,13 @@ impl<'a> Renderer<'a> {
source_res, source_res,
source_sample_rate source_sample_rate
}); });
self.format = source_res.default_codec().with_flac_audio();
Ok(()) Ok(())
} }
/// Prepare assets like intro, outro and questions. /// Prepare assets like intro, outro and questions.
pub(crate) fn render_assets(&self, project: &Project) -> anyhow::Result<()> { pub fn render_assets(&self, project: &Project) -> anyhow::Result<()> {
let metadata = project.source.metadata.as_ref().unwrap(); let metadata = project.source.metadata.as_ref().unwrap();
println!(); println!();
@ -365,8 +359,8 @@ impl<'a> Renderer<'a> {
/// Get the video file for a specific resolution, completely finished. /// Get the video file for a specific resolution, completely finished.
fn video_file_res(&self, res: Resolution) -> PathBuf { fn video_file_res(&self, res: Resolution) -> PathBuf {
let extension = match res.format() { let extension = match res.default_codec() {
FfmpegOutputFormat::Av1Flac => "mkv", FfmpegOutputFormat::Av1Flac | FfmpegOutputFormat::AvcFlac => "mkv",
FfmpegOutputFormat::Av1Opus => "webm", FfmpegOutputFormat::Av1Opus => "webm",
FfmpegOutputFormat::AvcAac => "mp4" FfmpegOutputFormat::AvcAac => "mp4"
}; };
@ -375,15 +369,16 @@ impl<'a> Renderer<'a> {
} }
/// Get the video file directly outputed to further transcode. /// Get the video file directly outputed to further transcode.
pub(crate) fn video_file_output(&self) -> PathBuf { pub fn video_file_output(&self) -> PathBuf {
self.target.join(format!("{}.mkv", self.slug)) self.target.join(format!("{}.mkv", self.slug))
} }
pub(crate) fn render(&self, project: &mut Project) -> anyhow::Result<PathBuf> { pub fn render(&self, project: &mut Project) -> anyhow::Result<PathBuf> {
let source_res = project.source.metadata.as_ref().unwrap().source_res; let source_res = project.source.metadata.as_ref().unwrap().source_res;
let output = self.video_file_output(); let output = self.video_file_output();
let mut ffmpeg = Ffmpeg::new(FfmpegOutput { let mut ffmpeg = Ffmpeg::new(FfmpegOutput {
quality: FfmpegOutputQuality::VisuallyLossless,
video_bitrate: Some(source_res.bitrate() * 3), video_bitrate: Some(source_res.bitrate() * 3),
..FfmpegOutput::new(self.format, output.clone()) ..FfmpegOutput::new(self.format, output.clone())
}); });
@ -675,7 +670,7 @@ impl<'a> Renderer<'a> {
comment: Some(lecture.lang.video_created_by_us.into()), comment: Some(lecture.lang.video_created_by_us.into()),
language: Some(lecture.lang.lang.into()), language: Some(lecture.lang.lang.into()),
..FfmpegOutput::new(res.format(), output.clone()).enable_faststart() ..FfmpegOutput::new(res.default_codec(), output.clone()).enable_faststart()
}); });
ffmpeg.add_input(FfmpegInput::new(input)); ffmpeg.add_input(FfmpegInput::new(input));
ffmpeg.rescale_video(res); ffmpeg.rescale_video(res);