#![allow(warnings)] pub mod ffmpeg; mod filter; use self::filter::Filter; use crate::{ iotro::{intro, outro}, render::ffmpeg::{Ffmpeg, FfmpegInput}, time::{format_date, Time}, Project, ProjectSourceMetadata, Resolution }; use anyhow::{bail, Context}; use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf}; use rational::Rational; use std::{ fs::{self, File}, io::Write as _, process::{Command, Stdio} }; const INTRO_LEN: Time = Time { seconds: 3, micros: 0 }; const OUTRO_LEN: Time = Time { seconds: 5, micros: 0 }; const TRANSITION: &str = "fadeblack"; const TRANSITION_LEN: Time = Time { seconds: 0, micros: 200_000 }; fn cmd() -> Command { let mut cmd = Command::new("busybox"); cmd.arg("ash") .arg("-exuo") .arg("pipefail") .arg("-c") .arg("exec"); cmd } fn ffprobe() -> Command { let mut cmd = cmd(); cmd.arg("ffprobe") .arg("-v") .arg("error") .arg("-of") .arg("default=noprint_wrappers=1:nokey=1"); cmd } fn read_output(cmd: &mut Command) -> anyhow::Result { let out = cmd.stderr(Stdio::inherit()).output()?; if !out.status.success() { bail!( "Executed command failed with exit status {:?}", out.status.code() ); } String::from_utf8(out.stdout).context("Command returned non-utf8 output") } fn ffprobe_video(query: &str, input: &Path) -> anyhow::Result { read_output( ffprobe() .arg("-select_streams") .arg("v:0") .arg("-show_entries") .arg(query) .arg(input) ) } fn ffprobe_audio(query: &str, concat_input: &Path) -> anyhow::Result { read_output( ffprobe() .arg("-select_streams") .arg("a:0") .arg("-show_entries") .arg(query) .arg("-safe") .arg("0") .arg("-f") .arg("concat") .arg(concat_input) ) } fn ffmpeg() -> Command { let mut cmd = cmd(); cmd.arg("ffmpeg") .arg("-hide_banner") .arg("-vaapi_device") .arg("/dev/dri/renderD128"); cmd } fn render_svg(fps: Rational, tbn: u32, input: &Path, duration: Time, output: &Path) { let mut cmd = ffmpeg(); cmd.arg("-framerate").arg(fps.to_string()); cmd.arg("-loop").arg("1"); cmd.arg("-i").arg(input); cmd.arg("-c:v").arg("libx264"); } pub(crate) struct Renderer<'a> { /// The directory with all the sources. directory: &'a Path, /// The slug (i.e. 23ws-malo2-231016). slug: String, /// The target directory. target: PathBuf } fn svg2mp4(svg: PathBuf, mp4: PathBuf, duration: Time) -> anyhow::Result<()> { let mut ffmpeg = Ffmpeg::new(mp4); ffmpeg.add_input(FfmpegInput { loop_input: true, ..FfmpegInput::new(svg) }); ffmpeg.add_filter(Filter::GenerateSilence { video: "0".into(), output: "out".into() }); ffmpeg.set_filter_output("out"); ffmpeg.run() } fn svg2png(svg: &Path, png: &Path, size: usize) -> anyhow::Result<()> { let mut cmd = cmd(); let size = size.to_string(); cmd.arg("inkscape").arg("-w").arg(&size).arg("-h").arg(&size); cmd.arg(svg).arg("-o").arg(png); let status = cmd.status()?; if status.success() { Ok(()) } else { bail!("inkscape failed with exit code {:?}", status.code()) } } impl<'a> Renderer<'a> { pub(crate) fn new(directory: &'a Path, project: &Project) -> anyhow::Result { let slug = format!( "{}-{}", project.lecture.course, format_date(project.lecture.date) ); let target = directory.join(&slug); Ok(Self { directory, slug, target }) } pub(crate) fn preprocess(&self, project: &mut Project) -> anyhow::Result<()> { assert!(!project.progress.preprocessed); let recording_txt = self.target.join("recording.txt"); let mut file = File::create(&recording_txt)?; for filename in &project.source.files { writeln!(file, "file {:?}", self.directory.join(filename).to_string()); } drop(file); println!("\x1B[1m ==> Concatenating Video and Normalising Audio ..."); let source_sample_rate = ffprobe_audio("stream=sample_rate", &recording_txt)?.parse()?; let recording_mp4 = self.target.join("recording.mp4"); let mut ffmpeg = Ffmpeg::new(recording_mp4.clone()); ffmpeg.add_input(FfmpegInput { concat: true, ..FfmpegInput::new(recording_txt) }); ffmpeg.enable_loudnorm(); ffmpeg.run()?; let width = ffprobe_video("stream=width", &recording_mp4)?.parse()?; let height = ffprobe_video("stream=height", &recording_mp4)?.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}") }; project.source.metadata = Some(ProjectSourceMetadata { source_duration: ffprobe_video("format=duration", &recording_mp4)?.parse()?, source_fps: ffprobe_video("stream=r_frame_rate", &recording_mp4)?.parse()?, source_tbn: ffprobe_video("stream=time_base", &recording_mp4)?.parse()?, source_res, source_sample_rate }); // render intro to svg then mp4 let intro_svg = self.target.join("intro.svg"); fs::write( &intro_svg, intro(source_res, project.lecture.date) .to_string_pretty() .into_bytes() )?; let intro_mp4 = self.target.join("intro.mp4"); svg2mp4(intro_svg, intro_mp4, Time { seconds: 3, micros: 0 })?; // render outro to svg then mp4 let outro_svg = self.target.join("outro.svg"); fs::write( &outro_svg, outro(source_res).to_string_pretty().into_bytes() )?; let outro_mp4 = self.target.join("outro.mp4"); svg2mp4(outro_svg, outro_mp4, Time { seconds: 5, micros: 0 }); // copy logo then render to png let logo_svg = self.target.join("logo.svg"); fs::write( &logo_svg, include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/logo.svg")) )?; let logo_png = self.target.join("logo.png"); svg2png(&logo_svg, &logo_png, 150 * 1920 / source_res.width())?; // copy fastforward then render to png let fastforward_svg = self.target.join("fastforward.svg"); fs::write( &fastforward_svg, include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), "/assets/fastforward.svg" )) )?; let fastforward_png = self.target.join("fastforward.png"); svg2png(&fastforward_svg, &fastforward_png, 128 * 1920 / source_res.width())?; Ok(()) } }