diff --git a/src/cli.rs b/src/cli.rs
new file mode 100644
index 0000000000000000000000000000000000000000..9320c6f0ee10cf5efb80f282916bbc21e406c97c
--- /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 a4eeccbc83ac437b2fdd2c6ecd814b1b5dce5af8..cdbd1b28ad707fbedd313f3ceaa12a01be343162 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},
@@ -188,8 +191,8 @@ impl Iotro {
 
 	fn finish(self) -> Graphic {
 		let mut svg = Graphic::new();
-		svg.set_width(self.res.width());
-		svg.set_height(self.res.height());
+		svg.set_width(self.res.width);
+		svg.set_height(self.res.height);
 		svg.set_view_box("0 0 1920 1080");
 		svg.push(
 			Rect::new()
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000000000000000000000000000000000000..998054a2cbf3ced77f748995e2a249d82cc0acc6
--- /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<String> = RwLock::new(String::new());
diff --git a/src/main.rs b/src/main.rs
index 67d9ce08c3c9ceb251d7314e88d2907f787940e3..53df6eaec6b923c258ce5fbdd3b74fa5b8881bee 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<String> = RwLock::new(String::new());
+use std::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
@@ -276,7 +215,7 @@ fn main() {
 
 	// rescale the video
 	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
 				|| res > args.transcode_start.unwrap_or(preset.transcode_start)
 				|| res < lowest_res
diff --git a/src/preset.rs b/src/preset.rs
index 85f37c604e32091f1748552fdab65c88b44784d3..507401a219da7c2e59dffba1dce3abb5cf7c266c 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<Resolution>
+	#[serde_as(as = "DisplayFromStr")]
+	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 {
 		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<Self> {
+	pub fn find(name: &str) -> anyhow::Result<Self> {
 		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 7dbda1fc23f182b73dda6455b63fe28065c38448..a16da81faaff76e77571212637c81e731db0663d 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -8,157 +8,226 @@ use crate::{
 use rational::Rational;
 use serde::{Deserialize, Serialize};
 use serde_with::{serde_as, DisplayFromStr};
-use std::{collections::BTreeSet, 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
-			),+
-		}
+use std::{
+	cmp,
+	collections::BTreeSet,
+	fmt::{self, Display, Formatter},
+	str::FromStr
+};
+
+#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
+pub struct Resolution {
+	pub width: u32,
+	pub height: u32
+}
 
-		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),+
-				}
-			}
+impl Resolution {
+	pub(crate) fn bitrate(self) -> u64 {
+		// 640 * 360: 500k
+		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
+		}
+	}
 
-		impl FromStr for Resolution {
-			type Err = anyhow::Error;
+	pub(crate) fn default_codec(self) -> FfmpegOutputFormat {
+		if self.width <= 1920 {
+			FfmpegOutputFormat::Av1Opus
+		} else {
+			FfmpegOutputFormat::AvcAac
+		}
+	}
 
-			fn from_str(s: &str) -> anyhow::Result<Self> {
-				Ok(match s {
-					$(concat!(stringify!($height), "p") => Self::$res,)+
-					_ => anyhow::bail!("Unknown Resolution: {s:?}")
-				})
-			}
+	pub const STANDARD_RESOLUTIONS: [Self; 5] = [
+		Self {
+			width: 640,
+			height: 360
+		},
+		Self {
+			width: 1280,
+			height: 720
+		},
+		Self {
+			width: 1920,
+			height: 1080
+		},
+		Self {
+			width: 2560,
+			height: 1440
+		},
+		Self {
+			width: 3840,
+			height: 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<Self> {
+		Ok(match s.to_lowercase().as_str() {
+			"360p" | "nhd" => Self {
+				width: 640,
+				height: 360
+			},
+			"540p" | "qhd" => Self {
+				width: 960,
+				height: 540
+			},
+			"720p" | "hd" => Self {
+				width: 1280,
+				height: 720
+			},
+			"900p" | "hd+" => Self {
+				width: 1600,
+				height: 900
+			},
+			"1080p" | "fhd" | "fullhd" => Self {
+				width: 1920,
+				height: 1080
+			},
+			"1440p" | "wqhd" => Self {
+				width: 2560,
+				height: 1440
+			},
+			"2160p" | "4k" | "uhd" => Self {
+				width: 3840,
+				height: 2160
+			},
+			_ => anyhow::bail!("Unknown Resolution: {s:?}")
+		})
+	}
+}
+
+impl Ord for Resolution {
+	fn cmp(&self, other: &Self) -> cmp::Ordering {
+		(self.width * self.height).cmp(&(other.width * other.height))
+	}
+}
+
+impl Eq for Resolution {}
+
+impl PartialOrd for Resolution {
+	fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
+		Some(self.cmp(other))
+	}
+}
+
+impl PartialEq for Resolution {
+	fn eq(&self, other: &Self) -> bool {
+		self.cmp(other) == cmp::Ordering::Equal
+	}
 }
 
 #[derive(Deserialize, Serialize)]
-pub(crate) struct Project {
-	pub(crate) lecture: ProjectLecture,
-	pub(crate) source: ProjectSource,
-	pub(crate) progress: ProjectProgress
+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,
+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<String>,
-	pub(crate) stereo: bool,
+pub struct ProjectSource {
+	pub files: Vec<String>,
+	pub stereo: bool,
 
 	#[serde_as(as = "Option<DisplayFromStr>")]
-	pub(crate) start: Option<Time>,
+	pub start: Option<Time>,
 	#[serde_as(as = "Option<DisplayFromStr>")]
-	pub(crate) end: Option<Time>,
+	pub end: Option<Time>,
 
 	#[serde(default)]
 	#[serde_as(as = "Vec<(DisplayFromStr, DisplayFromStr)>")]
-	pub(crate) fast: Vec<(Time, Time)>,
+	pub fast: Vec<(Time, Time)>,
 
 	#[serde(default)]
 	#[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]
 #[derive(Deserialize, Serialize)]
-pub(crate) struct ProjectSourceMetadata {
+pub struct ProjectSourceMetadata {
 	/// The duration of the source video.
 	#[serde_as(as = "DisplayFromStr")]
-	pub(crate) source_duration: Time,
+	pub source_duration: Time,
 	/// The FPS of the source video.
 	#[serde_as(as = "DisplayFromStr")]
-	pub(crate) source_fps: Rational,
+	pub source_fps: Rational,
 	/// The time base of the source video.
 	#[serde_as(as = "DisplayFromStr")]
-	pub(crate) source_tbn: Rational,
+	pub source_tbn: Rational,
 	/// The resolution of the source video.
-	pub(crate) source_res: Resolution,
+	pub source_res: Resolution,
 	/// The sample rate of the source audio.
-	pub(crate) source_sample_rate: u32
+	pub source_sample_rate: u32
 }
 
+#[serde_as]
 #[derive(Default, Deserialize, Serialize)]
-pub(crate) struct ProjectProgress {
+pub struct ProjectProgress {
 	#[serde(default)]
-	pub(crate) preprocessed: bool,
+	pub preprocessed: bool,
 
 	#[serde(default)]
-	pub(crate) asked_start_end: bool,
+	pub asked_start_end: bool,
 
 	#[serde(default)]
-	pub(crate) asked_fast: bool,
+	pub asked_fast: bool,
 
 	#[serde(default)]
-	pub(crate) asked_questions: bool,
+	pub asked_questions: bool,
 
 	#[serde(default)]
-	pub(crate) rendered_assets: bool,
+	pub rendered_assets: bool,
 
 	#[serde(default)]
-	pub(crate) rendered: bool,
+	pub rendered: bool,
 
+	#[serde_as(as = "BTreeSet<DisplayFromStr>")]
 	#[serde(default)]
-	pub(crate) transcoded: BTreeSet<Resolution>
+	pub transcoded: BTreeSet<Resolution>
 }
diff --git a/src/question.rs b/src/question.rs
index 53908f9360e050debdb8ecf9ebdad78d5180afaf..0f61a1c96eb55134d5db277863f9e99bf85c39f0 100644
--- a/src/question.rs
+++ b/src/question.rs
@@ -1,4 +1,4 @@
-use crate::{iotro::Language, Resolution};
+use crate::{iotro::Language, project::Resolution};
 use fontconfig::Fontconfig;
 use harfbuzz_rs::{Face, Font, Owned, UnicodeBuffer};
 use std::sync::OnceLock;
@@ -131,8 +131,8 @@ impl Question {
 
 	pub(crate) fn finish(self) -> Graphic {
 		let mut svg = Graphic::new();
-		svg.set_width(self.res.width());
-		svg.set_height(self.res.height());
+		svg.set_width(self.res.width);
+		svg.set_height(self.res.height);
 		svg.set_view_box("0 0 1920 1080");
 		svg.push(self.g);
 		svg
diff --git a/src/render/ffmpeg.rs b/src/render/ffmpeg.rs
index 9cc0ab7fa751a8d93c3a8e01f757ae38f4eb51bf..e619168ecc1881c0b70d4be654f6bb28da437399 100644
--- a/src/render/ffmpeg.rs
+++ b/src/render/ffmpeg.rs
@@ -1,8 +1,8 @@
 use super::{cmd, filter::Filter};
 use crate::{
+	project::Resolution,
 	render::filter::channel,
-	time::{format_time, Time},
-	Resolution
+	time::{format_time, Time}
 };
 use anyhow::bail;
 use camino::Utf8PathBuf as PathBuf;
@@ -347,9 +347,9 @@ impl Ffmpeg {
 			},
 			FfmpegFilter::Rescale(res) => {
 				cmd.arg("-vf").arg(if vaapi {
-					format!("scale_vaapi=w={}:h={}", res.width(), res.height())
+					format!("scale_vaapi=w={}:h={}", res.width, res.height)
 				} else {
-					format!("scale=w={}:h={}", res.width(), res.height())
+					format!("scale=w={}:h={}", res.width, res.height)
 				});
 			}
 		}
diff --git a/src/render/mod.rs b/src/render/mod.rs
index 2fa4c15c1f8ed55beee9839da4660f7e3de077e5..1e361cc04db534165e8512f008e33f2a116731b6 100644
--- a/src/render/mod.rs
+++ b/src/render/mod.rs
@@ -41,8 +41,8 @@ const QUESTION_FADE_LEN: Time = Time {
 };
 const FF_MULTIPLIER: usize = 8;
 // logo sizes at full hd, will be scaled to source resolution
-const FF_LOGO_SIZE: usize = 128;
-const LOGO_SIZE: usize = 96;
+const FF_LOGO_SIZE: u32 = 128;
+const LOGO_SIZE: u32 = 96;
 
 fn cmd() -> Command {
 	#[cfg(feature = "mem_limit")]
@@ -119,7 +119,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.
 	directory: &'a Path,
 
@@ -157,7 +157,7 @@ fn svg2mkv(
 	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();
 	cmd.arg("inkscape")
 		.arg("-w")
@@ -175,7 +175,7 @@ fn svg2png(svg: &Path, png: &Path, width: usize, height: usize) -> anyhow::Resul
 }
 
 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!(
 			"{}-{}",
 			project.lecture.course,
@@ -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")
 	}
 
@@ -230,7 +230,7 @@ impl<'a> Renderer<'a> {
 		self.target.join(format!("question{q_idx}.png"))
 	}
 
-	pub(crate) fn preprocess(&self, project: &mut Project) -> anyhow::Result<()> {
+	pub fn preprocess(&self, project: &mut Project) -> anyhow::Result<()> {
 		assert!(!project.progress.preprocessed);
 
 		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 height = ffprobe_video("stream=height", &recording_mkv)?.parse()?;
-		let source_res = match (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}")
-		};
+		let source_res = Resolution { width, height };
 		project.source.metadata = Some(ProjectSourceMetadata {
 			source_duration: ffprobe_video("format=duration", &recording_mkv)?.parse()?,
 			source_fps: ffprobe_video("stream=r_frame_rate", &recording_mkv)?.parse()?,
@@ -283,7 +276,7 @@ impl<'a> Renderer<'a> {
 	}
 
 	/// 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();
 
 		println!();
@@ -322,7 +315,7 @@ impl<'a> Renderer<'a> {
 			include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/logo.svg"))
 		)?;
 		let logo_png = self.target.join("logo.png");
-		let logo_size = LOGO_SIZE * metadata.source_res.width() / 1920;
+		let logo_size = LOGO_SIZE * metadata.source_res.width / 1920;
 		svg2png(&logo_svg, &logo_png, logo_size, logo_size)?;
 
 		// copy fastforward then render to png
@@ -335,7 +328,7 @@ impl<'a> Renderer<'a> {
 			))
 		)?;
 		let fastforward_png = self.target.join("fastforward.png");
-		let ff_logo_size = FF_LOGO_SIZE * metadata.source_res.width() / 1920;
+		let ff_logo_size = FF_LOGO_SIZE * metadata.source_res.width / 1920;
 		svg2png(
 			&fastforward_svg,
 			&fastforward_png,
@@ -355,8 +348,8 @@ impl<'a> Renderer<'a> {
 			svg2png(
 				&q_svg,
 				&q_png,
-				metadata.source_res.width(),
-				metadata.source_res.height()
+				metadata.source_res.width,
+				metadata.source_res.height
 			)?;
 		}
 
@@ -365,21 +358,21 @@ impl<'a> Renderer<'a> {
 
 	/// Get the video file for a specific resolution, completely finished.
 	fn video_file_res(&self, res: Resolution) -> PathBuf {
-		let extension = match res.format() {
+		let extension = match res.default_codec() {
 			FfmpegOutputFormat::Av1Flac => "mkv",
 			FfmpegOutputFormat::Av1Opus => "webm",
 			FfmpegOutputFormat::AvcAac => "mp4"
 		};
 		self.target
-			.join(format!("{}-{}p.{extension}", self.slug, res.height()))
+			.join(format!("{}-{}p.{extension}", self.slug, res.height))
 	}
 
 	/// 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))
 	}
 
-	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 output = self.video_file_output();
@@ -627,8 +620,8 @@ impl<'a> Renderer<'a> {
 			output: logoalpha.into()
 		});
 		let overlay = "overlay";
-		let overlay_off_x = 130 * source_res.width() / 3840;
-		let overlay_off_y = 65 * source_res.height() / 2160;
+		let overlay_off_x = 130 * source_res.width / 3840;
+		let overlay_off_y = 65 * source_res.height / 2160;
 		ffmpeg.add_filter(Filter::Overlay {
 			video_input: concat.into(),
 			overlay_input: logoalpha.into(),
@@ -657,7 +650,7 @@ impl<'a> Renderer<'a> {
 		println!(
 			" {} {}",
 			style("==>").bold().cyan(),
-			style(format!("Rescaling to {}p", res.height())).bold()
+			style(format!("Rescaling to {}p", res.height)).bold()
 		);
 
 		let mut ffmpeg = Ffmpeg::new(FfmpegOutput {
@@ -675,7 +668,7 @@ impl<'a> Renderer<'a> {
 			comment: Some(lecture.lang.video_created_by_us.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.rescale_video(res);