Compare commits

...

11 commits

Author SHA1 Message Date
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
10 changed files with 331 additions and 251 deletions

View file

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

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,
collections::BTreeSet,
fmt::{self, Display, Formatter},
str::FromStr
};
macro_rules! resolutions { #[derive(Clone, Copy, Debug, Deserialize, Serialize)]
($($res:ident: $width:literal x $height:literal at $bitrate:literal in $format:ident),+) => { pub struct Resolution(u32, u32);
#[allow(non_camel_case_types, clippy::upper_case_acronyms)]
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] impl Resolution {
pub(crate) enum Resolution { pub fn new(width: u32, height: u32) -> Self {
$( Self(width, height)
#[doc = concat!(stringify!($width), "x", stringify!($height))]
$res
),+
} }
const NUM_RESOLUTIONS: usize = { pub fn width(self) -> u32 {
let mut num = 0; self.0
$(num += 1; stringify!($res);)+
num
};
impl Resolution {
pub(crate) fn values() -> [Self; NUM_RESOLUTIONS] {
[$(Self::$res),+]
} }
pub(crate) fn width(self) -> usize { pub fn height(self) -> u32 {
match self { self.1
$(Self::$res => $width),+
}
}
pub(crate) fn height(self) -> usize {
match self {
$(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
} }
} }
impl FromStr for Resolution { 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 {
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"
}); });

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);