Something went wrong. Try again.
twitter voice plus plus
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318use 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>, #[command(flatten)] default_video: Option<RenderVideoArgs>,}
#[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<PathBuf>, /// 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<PathBuf>,}
#[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<Encoder> 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<Command> { 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<Composition> { 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<Image> { 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<f32, String> { let parsed = value .parse::<f32>() .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<f32, String> { 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"))); }}