#![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::{ borrow::Cow, 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 \"$0\" \"${@}\""); 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") .map(|str| str.trim().into()) } 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) ) } 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.set_duration(duration); 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); fs::create_dir_all(&target)?; Ok(Self { directory, slug, target }) } pub(crate) fn recording_mp4(&self) -> PathBuf { self.target.join("recording.mp4") } 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 ...\x1B[0m"); let source_sample_rate = ffprobe_audio("stream=sample_rate", &recording_txt)?.parse()?; let recording_mp4 = self.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 }); println!("\x1B[1m ==> Preparing assets ...\x1B[0m"); // 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(()) } pub(crate) fn render(&self, project: &mut Project) -> anyhow::Result { let mut output = self.target.join(format!( "{}-{}p.mp4", self.slug, project.source.metadata.as_ref().unwrap().source_res.width() )); let mut ffmpeg = Ffmpeg::new(output.clone()); // add all of our inputs let intro = ffmpeg.add_input(FfmpegInput::new(self.target.join("intro.mp4"))); let rec = ffmpeg.add_input(FfmpegInput::new(self.target.join("recording.mp4"))); let outro = ffmpeg.add_input(FfmpegInput::new(self.target.join("outro.mp4"))); let logo = ffmpeg.add_input(FfmpegInput::new(self.target.join("logo.png"))); let ff = ffmpeg.add_input(FfmpegInput::new(self.target.join("fastforward.png"))); let mut part1: Cow<'static, str> = intro.into(); let mut part2: Cow<'static, str> = rec.into(); let mut part3: Cow<'static, str> = outro.into(); // trim the recording let rectrim = "rectrim"; let start = project.source.start.unwrap(); let duration = project.source.end.unwrap() - start; ffmpeg.add_filter(Filter::Trim { input: part2, start: Some(start), duration: Some(duration), output: rectrim.into() }); part2 = rectrim.into(); // TODO ff // TODO fade // concatenate everything let concat = "concat"; ffmpeg.add_filter(Filter::Concat { inputs: vec![part1, part2, part3], n: 3, output: concat.into() }); // overlay the logo let overlay = "overlay"; ffmpeg.add_filter(Filter::Overlay { video_input: concat.into(), overlay_input: logo.into(), x: "main_w-overlay_w-130".into(), y: "main_h-overlay_h-65".into(), output: overlay.into() }); // we're done :) ffmpeg.set_filter_output(overlay); ffmpeg.run()?; Ok(output) } }