use std::{ fs, io::BufWriter, path::{Path, PathBuf}, }; use anyhow::{bail, Context, Result}; use bskyvoice_renderer::{ extract_background_color, video::{render_video_with_options, VideoEncoder, VideoOptions}, Composition, MeterFrame, Renderer, }; use clap::{Args, Parser, Subcommand, ValueEnum}; use skia_canvas::prelude::*; #[derive(Debug, Parser)] #[command( name = "twtvc", version, about = "Render Twitter voice audio visualizations", arg_required_else_help = true, args_conflicts_with_subcommands = true )] struct Cli { #[command(subcommand)] command: Option, #[command(flatten)] default_video: Option, } #[derive(Debug, Subcommand)] enum Command { /// Render an audio file into an MP4 video. #[command(name = "render-video", alias = "video")] RenderVideo(RenderVideoArgs), /// Render a representative still frame as a PNG. #[command(name = "render-frame", alias = "frame")] RenderFrame(RenderFrameArgs), } #[derive(Debug, Args)] struct RenderVideoArgs { /// Audio input, such as WAV, FLAC, or MP3. #[arg(value_name = "AUDIO")] audio: PathBuf, /// MP4 output path. #[arg(value_name = "OUTPUT")] output: PathBuf, /// Optional square artwork image used for the portrait and background color. #[arg(value_name = "ARTWORK")] artwork: Option, /// Video encoder to use. #[arg(long, value_enum, default_value = "x264")] encoder: Encoder, /// Frames per second. #[arg(long, default_value_t = 30.0, value_parser = parse_positive_f32)] fps: f32, /// Internal render scale from 0 to 1 before final 720p output. #[arg(long, default_value_t = 1.0, value_parser = parse_render_scale)] render_scale: f32, } #[derive(Debug, Args)] struct RenderFrameArgs { /// PNG output path. #[arg(value_name = "OUTPUT")] output: PathBuf, /// Optional square artwork image used for the portrait and background color. #[arg(value_name = "ARTWORK")] artwork: Option, } #[derive(Clone, Copy, Debug, ValueEnum)] enum Encoder { #[value(name = "x264")] X264, #[value(name = "videotoolbox")] VideoToolbox, #[value(name = "nvenc")] Nvenc, #[value(name = "auto")] Auto, } impl From for VideoEncoder { fn from(encoder: Encoder) -> Self { match encoder { Encoder::X264 => Self::X264, Encoder::VideoToolbox => Self::VideoToolbox, Encoder::Nvenc => Self::Nvenc, Encoder::Auto => Self::Auto, } } } pub fn run() -> Result<()> { match default_command(Cli::parse())? { Command::RenderVideo(args) => render_video(args), Command::RenderFrame(args) => render_frame(args), } } fn default_command(cli: Cli) -> Result { match (cli.command, cli.default_video) { (Some(command), None) => Ok(command), (None, Some(video)) => Ok(Command::RenderVideo(video)), (None, None) => bail!("a command or audio and output path are required"), (Some(_), Some(_)) => bail!("a subcommand cannot be combined with default video arguments"), } } fn render_video(args: RenderVideoArgs) -> Result<()> { let output = args.output; if output.exists() { bail!("output file already exists: {}", output.display()); } render_video_with_options( &args.audio, &output, args.artwork.as_deref(), Composition::default(), args.fps, VideoOptions { render_scale: args.render_scale, encoder: args.encoder.into(), }, )?; println!("rendered {}", output.display()); Ok(()) } fn render_frame(args: RenderFrameArgs) -> Result<()> { let composition = composition_with_artwork(args.artwork.as_deref())?; let backend = Backend::new(); let mut surface = backend.create_surface( composition.width, composition.height, SurfaceOptions { engine: RenderEngine::Cpu, ..SurfaceOptions::default() }, )?; let frame = Renderer::new().render_frame( &mut surface, &composition, MeterFrame { rms: 0.42, bass: 0.55, mid: 0.32, high: 0.24, }, 0.0, )?; write_png(&args.output, &frame)?; println!("rendered {}", args.output.display()); Ok(()) } fn composition_with_artwork(artwork_path: Option<&Path>) -> Result { let mut composition = Composition::default(); let Some(path) = artwork_path else { return Ok(composition); }; let artwork = load_artwork(path)?; composition.background = extract_background_color(&artwork)?; composition.artwork = Some(artwork); Ok(composition) } fn write_png(path: &Path, frame: &ExportedPixels) -> Result<()> { let output = fs::File::create(path) .with_context(|| format!("could not create PNG: {}", path.display()))?; let writer = BufWriter::new(output); let mut encoder = png::Encoder::new(writer, frame.width(), frame.height()); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); let mut png_writer = encoder.write_header()?; png_writer.write_image_data(frame.pixels())?; Ok(()) } fn load_artwork(path: &Path) -> Result { let bytes = fs::read(path).with_context(|| format!("could not read artwork: {}", path.display()))?; Image::from_encoded(&bytes) .map_err(anyhow::Error::from) .with_context(|| format!("could not decode artwork: {}", path.display())) } fn parse_positive_f32(value: &str) -> Result { let parsed = value .parse::() .map_err(|_| format!("'{value}' is not a valid number"))?; if parsed.is_finite() && parsed > 0.0 { Ok(parsed) } else { Err(format!("'{value}' must be greater than zero")) } } fn parse_render_scale(value: &str) -> Result { let parsed = parse_positive_f32(value)?; if parsed <= 1.0 { Ok(parsed) } else { Err(format!("'{value}' must be at most 1.0")) } } #[cfg(test)] mod tests { use super::*; #[test] fn parses_video_defaults() { let cli = Cli::try_parse_from(["twtvc", "render-video", "input.flac", "output.mp4"]) .expect("video arguments should parse"); let Command::RenderVideo(args) = default_command(cli).expect("command should resolve") else { panic!("expected render-video command"); }; assert_eq!(args.audio, PathBuf::from("input.flac")); assert_eq!(args.output, PathBuf::from("output.mp4")); assert!(args.artwork.is_none()); assert!(matches!(args.encoder, Encoder::X264)); assert_eq!(args.fps, 30.0); assert_eq!(args.render_scale, 1.0); } #[test] fn parses_video_options_and_artwork() { let cli = Cli::try_parse_from([ "twtvc", "video", "input.flac", "output.mp4", "artwork.png", "--encoder", "auto", "--fps", "24", "--render-scale", "0.75", ]) .expect("video options should parse"); let Command::RenderVideo(args) = default_command(cli).expect("command should resolve") else { panic!("expected render-video command"); }; assert_eq!(args.artwork, Some(PathBuf::from("artwork.png"))); assert!(matches!(args.encoder, Encoder::Auto)); assert_eq!(args.fps, 24.0); assert_eq!(args.render_scale, 0.75); } #[test] fn rejects_invalid_video_values() { assert!(Cli::try_parse_from([ "twtvc", "render-video", "input.flac", "output.mp4", "--encoder", "metal", ]) .is_err()); assert!(Cli::try_parse_from([ "twtvc", "render-video", "input.flac", "output.mp4", "--render-scale", "1.1", ]) .is_err()); assert!(Cli::try_parse_from([ "twtvc", "render-video", "input.flac", "output.mp4", "--fps", "0", ]) .is_err()); } #[test] fn parses_frame_arguments() { let cli = Cli::try_parse_from(["twtvc", "frame", "frame.png", "artwork.png"]) .expect("frame arguments should parse"); let Command::RenderFrame(args) = default_command(cli).expect("command should resolve") else { panic!("expected render-frame command"); }; assert_eq!(args.output, PathBuf::from("frame.png")); assert_eq!(args.artwork, Some(PathBuf::from("artwork.png"))); } #[test] fn uses_video_as_the_default_command() { let cli = Cli::try_parse_from(["twtvc", "input.flac", "output.mp4", "artwork.png"]) .expect("bare video arguments should parse"); let Command::RenderVideo(args) = default_command(cli).expect("command should resolve") else { panic!("expected default render-video command"); }; assert_eq!(args.audio, PathBuf::from("input.flac")); assert_eq!(args.output, PathBuf::from("output.mp4")); assert_eq!(args.artwork, Some(PathBuf::from("artwork.png"))); } }