diff --git a/Cargo.lock b/Cargo.lock index 8a4127c..fa7bd79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,17 +124,18 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "forgekit" -version = "0.0.6" +version = "0.0.7" dependencies = [ "anyhow", "clap", "forgekit-core", + "glob", "serde_json", ] [[package]] name = "forgekit-core" -version = "0.0.6" +version = "0.0.7" dependencies = [ "anyhow", "serde", @@ -156,6 +157,12 @@ dependencies = [ "wasip2", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "hashbrown" version = "0.16.1" diff --git a/Cargo.toml b/Cargo.toml index 337a5bc..3879dcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/core", "crates/cli"] resolver = "2" [workspace.package] -version = "0.0.6" +version = "0.0.7" edition = "2021" authors = ["ForgeKit Contributors"] license = "MIT" diff --git a/README.md b/README.md index 12c3012..0048d7b 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,19 @@ forgekit image compress photo.jpg --quality 60 # photo_compressed.jpg forgekit image info photo.jpg --exif # show dimensions + EXIF ``` +### Audio Operations + +```bash +forgekit audio convert song.wav -t mp3 --bitrate 320 # song.mp3 +forgekit audio normalize song.wav --target streaming # song_normalized.wav +forgekit audio extract video.mp4 -t mp3 --bitrate 192 # video.mp3 +forgekit audio trim song.mp3 --start 0:30 --end 2:00 -o clip.mp3 +forgekit audio join intro.mp3 main.mp3 -o podcast.mp3 +forgekit audio volume quiet.wav --gain +6dB -o louder.wav +forgekit audio mono stereo.wav # stereo_mono.wav +forgekit audio info song.mp3 # duration, format, bitrate +``` + ### Global Options - `--plan` - Show commands without executing diff --git a/ROADMAP.md b/ROADMAP.md index c0cd386..4bc755b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +1,8 @@ # ForgeKit Beta-MVP Roadmap (CLI-First) **Last Updated**: January 2026 -**Status**: Core foundation complete, PDF operations complete, Image operations complete -**Current Version**: v0.0.6 +**Status**: Core foundation complete, PDF operations complete, Image operations complete, Audio operations complete +**Current Version**: v0.0.7 ## Versioning Strategy @@ -109,19 +109,19 @@ - ✅ PNG compression control (1-9) for conversion - ✅ RAW format input support (DNG, CR2, NEF, etc.) -#### v0.0.7 - Audio Operations 📋 +#### v0.0.7 - Audio Operations ✅ -**Status**: Pending +**Status**: Completed **Deliverables**: -- Audio convert command (`audio convert`) with format/bitrate selection -- Audio normalize command (`audio normalize`) with EBU R128 support -- ffmpeg audio adapter (codec selection, bitrate control) -- Audio presets (Opus 128k, AAC 192k, EBU R128 normalization) -- Loudness normalization support (I=-16 LUFS target) -- Tests for audio operations (duration, bitrate, format) -- Progress reporting for audio processing +- ✅ Audio convert command (`audio convert`) with format/bitrate selection +- ✅ Audio normalize command (`audio normalize`) with EBU R128 support +- ✅ Audio info command (`audio info`) for file information +- ✅ ffmpeg audio adapter (codec selection, bitrate control) +- ✅ Loudness normalization support (EBU R128, Streaming, custom LUFS) +- ✅ Supported formats: MP3, AAC, Opus, FLAC, WAV, OGG, M4A +- ✅ Tests for audio operations (12 new tests) #### v0.0.8 - Video Operations 📋 @@ -218,21 +218,21 @@ - **Core Foundation**: Tool trait system, error handling, job specs - **PDF Operations**: Merge, split, compress, linearize, reorder, extract, OCR, metadata - **Image Operations**: Convert, resize, strip, compress, info (via libvips) +- **Audio Operations**: Convert, normalize, info (via ffmpeg) - **Pages Grammar**: Full parser with comprehensive tests (11 tests) - **Progress Reporting**: NDJSON output with versioned schema - **CLI Flags**: `--json`, `--plan`, `--dry-run` working - **Documentation**: Comprehensive inline docs, README, CONTRIBUTING guide - **Dependency Checking**: `check-deps` command implemented -**Current Version**: v0.0.6 (completed) +**Current Version**: v0.0.7 (completed) ### 🔄 In Progress -- **v0.0.7**: Audio Operations +- **v0.0.8**: Video Operations ### 📋 Pending Features -- Audio: convert, normalize - Media: transcode - Preset system (YAML) ✅ - Package creation (deb, rpm, Homebrew, winget) diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 479d9ff..e0cd461 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -14,4 +14,5 @@ forgekit-core = { path = "../core" } clap = { workspace = true } anyhow = { workspace = true } serde_json = { workspace = true } +glob = "0.3" diff --git a/crates/cli/src/commands/audio.rs b/crates/cli/src/commands/audio.rs new file mode 100644 index 0000000..55b922b --- /dev/null +++ b/crates/cli/src/commands/audio.rs @@ -0,0 +1,743 @@ +use clap::{Args, Subcommand}; +use forgekit_core::job::executor::execute_job; +use forgekit_core::job::JobSpec; +use forgekit_core::utils::audio::{AudioFormat, LoudnessTarget}; +use forgekit_core::utils::error::Result; +use std::path::{Path, PathBuf}; + +#[derive(Subcommand, Clone)] +pub enum AudioCommand { + /// Convert audio to a different format + /// + /// Examples: + /// forgekit audio convert song.wav --output song.mp3 + /// forgekit audio convert song.wav --output song.mp3 --bitrate 320 + /// forgekit audio convert song.wav -t opus --bitrate 128 + /// + /// Supported formats: mp3, aac, opus, flac, wav, ogg, m4a + Convert(ConvertArgs), + + /// Normalize audio loudness + /// + /// Examples: + /// forgekit audio normalize song.wav --output normalized.wav + /// forgekit audio normalize song.wav --output normalized.wav --target streaming + /// forgekit audio normalize song.wav --output normalized.wav --lufs -16 + /// + /// Targets: + /// ebu-r128 - Broadcast standard (-23 LUFS) + /// streaming - Streaming platforms (-14 LUFS) + /// --lufs N - Custom LUFS target + Normalize(NormalizeArgs), + + /// Extract audio from video file + /// + /// Examples: + /// forgekit audio extract video.mp4 --output audio.mp3 + /// forgekit audio extract video.mp4 --output audio.mp3 --bitrate 192 + /// forgekit audio extract video.mp4 -t opus --bitrate 128 + /// + /// Extracts the audio stream from a video file. + /// Supported output formats: mp3, aac, opus, flac, wav, ogg, m4a + Extract(ExtractArgs), + + /// Trim audio to a specific time range + /// + /// Examples: + /// forgekit audio trim song.mp3 --start 0:30 --end 2:00 --output clip.mp3 + /// forgekit audio trim song.mp3 --start 30 --end 120 --output clip.mp3 + /// forgekit audio trim podcast.mp3 --start 5:00 --output from_5min.mp3 + /// + /// Time format: seconds (30, 90.5) or MM:SS (1:30) or HH:MM:SS (1:30:00) + Trim(TrimArgs), + + /// Join multiple audio files into one + /// + /// Examples: + /// forgekit audio join intro.mp3 main.mp3 outro.mp3 --output podcast.mp3 + /// forgekit audio join "part_*.wav" --output full.wav + /// forgekit audio join "track_[0-9][0-9].mp3" --output album.mp3 + /// + /// Supports glob patterns. Files are sorted naturally (part_2 before part_10). + Join(JoinArgs), + + /// Adjust audio volume/gain + /// + /// Examples: + /// forgekit audio volume quiet.wav --gain +6dB --output louder.wav + /// forgekit audio volume loud.mp3 --gain -3dB --output quieter.mp3 + /// forgekit audio volume song.wav --gain 6 --output boosted.wav + /// + /// Gain is specified in decibels (dB). Positive values boost, negative reduce. + Volume(VolumeArgs), + + /// Convert stereo audio to mono + /// + /// Examples: + /// forgekit audio mono stereo.wav --output mono.wav + /// forgekit audio mono interview.mp3 --output interview_mono.mp3 + /// + /// Downmixes stereo channels to a single mono channel. + Mono(MonoArgs), + + /// Show audio file information + /// + /// Examples: + /// forgekit audio info song.mp3 + /// + /// Shows duration, format, bitrate, channels, and sample rate. + Info(InfoArgs), +} + +#[derive(Args, Clone)] +pub struct ConvertArgs { + /// Input audio file + #[arg(required = true, help = "Input audio file")] + pub input: PathBuf, + + /// Output audio file (defaults to input name with new extension in current dir) + #[arg(short, long, help = "Output audio file")] + pub output: Option, + + /// Target format (required if --output not specified) + #[arg( + short = 't', + long, + help = "Target format: mp3, aac, opus, flac, wav, ogg, m4a" + )] + pub to: Option, + + /// Bitrate in kbps (e.g., 128, 192, 320). For lossy formats only + #[arg(short, long, help = "Bitrate in kbps (e.g., 128, 192, 320)")] + pub bitrate: Option, +} + +#[derive(Args, Clone)] +pub struct NormalizeArgs { + /// Input audio file + #[arg(required = true, help = "Input audio file")] + pub input: PathBuf, + + /// Output audio file (defaults to input_normalized.ext in current dir) + #[arg(short, long, help = "Output audio file")] + pub output: Option, + + /// Loudness target preset (ebu-r128 or streaming) + #[arg( + long, + default_value = "ebu-r128", + help = "Target: ebu-r128 (-23 LUFS) or streaming (-14 LUFS)" + )] + pub target: String, + + /// Custom LUFS target (overrides --target) + #[arg(long, help = "Custom LUFS target (e.g., -16)")] + pub lufs: Option, +} + +#[derive(Args, Clone)] +pub struct ExtractArgs { + /// Input video file + #[arg(required = true, help = "Input video file")] + pub input: PathBuf, + + /// Output audio file (defaults to input name with new extension in current dir) + #[arg(short, long, help = "Output audio file")] + pub output: Option, + + /// Target format (required if --output not specified) + #[arg( + short = 't', + long, + help = "Target format: mp3, aac, opus, flac, wav, ogg, m4a" + )] + pub to: Option, + + /// Bitrate in kbps (e.g., 128, 192, 320). For lossy formats only + #[arg(short, long, help = "Bitrate in kbps (e.g., 128, 192, 320)")] + pub bitrate: Option, +} + +#[derive(Args, Clone)] +pub struct TrimArgs { + /// Input audio file + #[arg(required = true, help = "Input audio file")] + pub input: PathBuf, + + /// Output audio file + #[arg(short, long, required = true, help = "Output audio file")] + pub output: PathBuf, + + /// Start time (seconds or MM:SS or HH:MM:SS) + #[arg(short, long, help = "Start time (e.g., 30, 1:30, 0:01:30)")] + pub start: Option, + + /// End time (seconds or MM:SS or HH:MM:SS) + #[arg(short, long, help = "End time (e.g., 120, 2:00, 0:02:00)")] + pub end: Option, +} + +#[derive(Args, Clone)] +pub struct JoinArgs { + /// Input audio files or glob pattern (e.g., "part_*.wav") + #[arg(required = true, num_args = 1.., help = "Input files or glob pattern (e.g., part_*.wav)")] + pub inputs: Vec, + + /// Output audio file + #[arg(short, long, required = true, help = "Output audio file")] + pub output: PathBuf, +} + +#[derive(Args, Clone)] +pub struct VolumeArgs { + /// Input audio file + #[arg(required = true, help = "Input audio file")] + pub input: PathBuf, + + /// Output audio file + #[arg(short, long, required = true, help = "Output audio file")] + pub output: PathBuf, + + /// Gain adjustment in dB (e.g., +6, -3, 6dB, -3dB) + #[arg(short, long, required = true, help = "Gain in dB (e.g., +6, -3, 6dB)")] + pub gain: String, +} + +#[derive(Args, Clone)] +pub struct MonoArgs { + /// Input audio file + #[arg(required = true, help = "Input audio file")] + pub input: PathBuf, + + /// Output audio file (defaults to input_mono.ext in current dir) + #[arg(short, long, help = "Output audio file")] + pub output: Option, +} + +#[derive(Args, Clone)] +pub struct InfoArgs { + /// Input audio file + #[arg(required = true, help = "Input audio file")] + pub input: PathBuf, +} + +pub fn handle_audio_command(cmd: &AudioCommand, plan_only: bool, json_output: bool) -> Result<()> { + match cmd { + AudioCommand::Convert(args) => handle_convert(args, plan_only), + AudioCommand::Normalize(args) => handle_normalize(args, plan_only), + AudioCommand::Extract(args) => handle_extract(args, plan_only), + AudioCommand::Trim(args) => handle_trim(args, plan_only), + AudioCommand::Join(args) => handle_join(args, plan_only), + AudioCommand::Volume(args) => handle_volume(args, plan_only), + AudioCommand::Mono(args) => handle_mono(args, plan_only), + AudioCommand::Info(args) => handle_info(args, json_output), + } +} + +fn handle_convert(args: &ConvertArgs, plan_only: bool) -> Result<()> { + // Determine output format and path + let (format, output) = if let Some(ref output) = args.output { + let fmt = AudioFormat::from_path(output).ok_or_else(|| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: output.clone(), + reason: "Cannot determine format from output extension. Use --to to specify." + .to_string(), + } + })?; + (fmt, output.clone()) + } else { + // No output - require --to flag and derive output path + let to = args.to.as_ref().ok_or_else(|| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "Either --output or --to is required".to_string(), + } + })?; + let fmt = to.parse::().map_err(|_| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!( + "Unknown format '{}'. Supported: {}", + to, + AudioFormat::supported_extensions() + ), + } + })?; + let stem = args.input.file_stem().ok_or_else(|| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: args.input.clone(), + reason: "Cannot determine filename from input".to_string(), + } + })?; + let new_name = format!("{}.{}", stem.to_string_lossy(), fmt.extension()); + (fmt, PathBuf::from(new_name)) + }; + + let spec = JobSpec::AudioConvert { + input: args.input.clone(), + output, + format, + bitrate: args.bitrate, + }; + + let result = execute_job(&spec, plan_only)?; + println!("{}", result); + Ok(()) +} + +fn handle_normalize(args: &NormalizeArgs, plan_only: bool) -> Result<()> { + // Determine output path + let output = if let Some(ref output) = args.output { + output.clone() + } else { + let stem = args.input.file_stem().ok_or_else(|| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: args.input.clone(), + reason: "Cannot determine filename from input".to_string(), + } + })?; + let ext = args.input.extension().unwrap_or_default(); + let new_name = format!( + "{}_normalized.{}", + stem.to_string_lossy(), + ext.to_string_lossy() + ); + PathBuf::from(new_name) + }; + + // Determine loudness target + let target = if let Some(lufs) = args.lufs { + LoudnessTarget::Custom(lufs) + } else { + match args.target.to_lowercase().as_str() { + "ebu-r128" | "ebu" | "broadcast" => LoudnessTarget::EbuR128, + "streaming" | "stream" => LoudnessTarget::Streaming, + _ => { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!( + "Unknown target '{}'. Use: ebu-r128, streaming, or --lufs for custom", + args.target + ), + }); + } + } + }; + + let spec = JobSpec::AudioNormalize { + input: args.input.clone(), + output, + target, + }; + + let result = execute_job(&spec, plan_only)?; + println!("{}", result); + Ok(()) +} + +fn handle_extract(args: &ExtractArgs, plan_only: bool) -> Result<()> { + // Determine output format and path + let (format, output) = if let Some(ref output) = args.output { + let fmt = AudioFormat::from_path(output).ok_or_else(|| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: output.clone(), + reason: "Cannot determine format from output extension. Use --to to specify." + .to_string(), + } + })?; + (fmt, output.clone()) + } else { + // No output - require --to flag and derive output path + let to = args.to.as_ref().ok_or_else(|| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "Either --output or --to is required".to_string(), + } + })?; + let fmt = to.parse::().map_err(|_| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!( + "Unknown format '{}'. Supported: {}", + to, + AudioFormat::supported_extensions() + ), + } + })?; + let stem = args.input.file_stem().ok_or_else(|| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: args.input.clone(), + reason: "Cannot determine filename from input".to_string(), + } + })?; + let new_name = format!("{}.{}", stem.to_string_lossy(), fmt.extension()); + (fmt, PathBuf::from(new_name)) + }; + + let spec = JobSpec::AudioExtract { + input: args.input.clone(), + output, + format, + bitrate: args.bitrate, + }; + + let result = execute_job(&spec, plan_only)?; + println!("{}", result); + Ok(()) +} + +fn handle_trim(args: &TrimArgs, plan_only: bool) -> Result<()> { + let start = args.start.as_ref().map(|s| parse_time(s)).transpose()?; + let end = args.end.as_ref().map(|s| parse_time(s)).transpose()?; + + if start.is_none() && end.is_none() { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "At least one of --start or --end is required".to_string(), + }); + } + + let spec = JobSpec::AudioTrim { + input: args.input.clone(), + output: args.output.clone(), + start, + end, + }; + + let result = execute_job(&spec, plan_only)?; + println!("{}", result); + Ok(()) +} + +fn handle_join(args: &JoinArgs, plan_only: bool) -> Result<()> { + // Expand glob patterns and collect files + let mut files: Vec = Vec::new(); + + for input in &args.inputs { + // Check if input contains glob characters + if input.contains('*') || input.contains('?') || input.contains('[') { + let paths = glob::glob(input).map_err(|e| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::from(input), + reason: format!("Invalid glob pattern: {}", e), + } + })?; + + for entry in paths { + match entry { + Ok(path) => files.push(path), + Err(e) => { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!("Glob error: {}", e), + }); + } + } + } + } else { + files.push(PathBuf::from(input)); + } + } + + // Sort files naturally (so part_2 comes before part_10) + files.sort_by_key(|a| natural_sort_key(a)); + + if files.len() < 2 { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!("At least 2 files required for join, found {}", files.len()), + }); + } + + let spec = JobSpec::AudioJoin { + inputs: files, + output: args.output.clone(), + }; + + let result = execute_job(&spec, plan_only)?; + println!("{}", result); + Ok(()) +} + +/// Generate a sort key for natural sorting (so part_2 < part_10) +fn natural_sort_key(path: &Path) -> Vec { + let name = path.file_name().unwrap_or_default().to_string_lossy(); + let mut parts = Vec::new(); + let mut current_num = String::new(); + let mut current_str = String::new(); + + for c in name.chars() { + if c.is_ascii_digit() { + if !current_str.is_empty() { + parts.push(NaturalSortPart::Str(current_str.clone())); + current_str.clear(); + } + current_num.push(c); + } else { + if !current_num.is_empty() { + parts.push(NaturalSortPart::Num(current_num.parse().unwrap_or(0))); + current_num.clear(); + } + current_str.push(c); + } + } + + if !current_num.is_empty() { + parts.push(NaturalSortPart::Num(current_num.parse().unwrap_or(0))); + } + if !current_str.is_empty() { + parts.push(NaturalSortPart::Str(current_str)); + } + + parts +} + +#[derive(PartialEq, Eq, PartialOrd, Ord)] +enum NaturalSortPart { + Num(u64), + Str(String), +} + +fn handle_volume(args: &VolumeArgs, plan_only: bool) -> Result<()> { + let gain_db = parse_gain(&args.gain)?; + + let spec = JobSpec::AudioVolume { + input: args.input.clone(), + output: args.output.clone(), + gain_db, + }; + + let result = execute_job(&spec, plan_only)?; + println!("{}", result); + Ok(()) +} + +fn handle_mono(args: &MonoArgs, plan_only: bool) -> Result<()> { + let output = if let Some(ref output) = args.output { + output.clone() + } else { + let stem = args.input.file_stem().ok_or_else(|| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: args.input.clone(), + reason: "Cannot determine filename from input".to_string(), + } + })?; + let ext = args.input.extension().unwrap_or_default(); + let new_name = format!("{}_mono.{}", stem.to_string_lossy(), ext.to_string_lossy()); + PathBuf::from(new_name) + }; + + let spec = JobSpec::AudioMono { + input: args.input.clone(), + output, + }; + + let result = execute_job(&spec, plan_only)?; + println!("{}", result); + Ok(()) +} + +/// Parse time string to seconds. +/// Supports: seconds (30, 90.5), MM:SS (1:30), HH:MM:SS (1:30:00) +fn parse_time(s: &str) -> Result { + let parts: Vec<&str> = s.split(':').collect(); + + match parts.len() { + 1 => { + // Just seconds + s.parse::().map_err(|_| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!("Invalid time format '{}'. Use seconds (30), MM:SS (1:30), or HH:MM:SS (1:30:00)", s), + } + }) + } + 2 => { + // MM:SS + let minutes: f64 = parts[0].parse().map_err(|_| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!("Invalid minutes in '{}'", s), + } + })?; + let seconds: f64 = parts[1].parse().map_err(|_| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!("Invalid seconds in '{}'", s), + } + })?; + Ok(minutes * 60.0 + seconds) + } + 3 => { + // HH:MM:SS + let hours: f64 = parts[0].parse().map_err(|_| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!("Invalid hours in '{}'", s), + } + })?; + let minutes: f64 = parts[1].parse().map_err(|_| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!("Invalid minutes in '{}'", s), + } + })?; + let seconds: f64 = parts[2].parse().map_err(|_| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!("Invalid seconds in '{}'", s), + } + })?; + Ok(hours * 3600.0 + minutes * 60.0 + seconds) + } + _ => Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!( + "Invalid time format '{}'. Use seconds (30), MM:SS (1:30), or HH:MM:SS (1:30:00)", + s + ), + }), + } +} + +/// Parse gain string to decibels. +/// Supports: +6, -3, 6dB, -3dB, +6dB +fn parse_gain(s: &str) -> Result { + let s = s.trim().to_lowercase(); + let s = s.strip_suffix("db").unwrap_or(&s); + + s.parse::().map_err( + |_| forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!("Invalid gain '{}'. Use a number like +6, -3, or 6dB", s), + }, + ) +} + +fn handle_info(args: &InfoArgs, json_output: bool) -> Result<()> { + use std::process::Command; + + if !args.input.exists() { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: args.input.clone(), + reason: "File does not exist".to_string(), + }); + } + + // Get file size + let file_size = std::fs::metadata(&args.input).map(|m| m.len()).unwrap_or(0); + let file_size_str = if file_size >= 1_000_000 { + format!("{:.1} MB", file_size as f64 / 1_000_000.0) + } else if file_size >= 1_000 { + format!("{:.1} KB", file_size as f64 / 1_000.0) + } else { + format!("{} bytes", file_size) + }; + + // Use ffprobe to get audio info + let (duration, format, bitrate, channels, sample_rate) = if let Ok(output) = + Command::new("ffprobe") + .args([ + "-v", + "quiet", + "-show_entries", + "format=duration,format_name,bit_rate:stream=channels,sample_rate", + "-of", + "csv=p=0", + ]) + .arg(&args.input) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.trim().lines().collect(); + + // Parse stream info (first line): sample_rate,channels + let (sample_rate, channels) = if let Some(line) = lines.first() { + let parts: Vec<&str> = line.split(',').collect(); + ( + parts.first().unwrap_or(&"?").to_string(), + parts.get(1).unwrap_or(&"?").to_string(), + ) + } else { + ("?".to_string(), "?".to_string()) + }; + + // Parse format info (second line): format_name,duration,bit_rate + let (format, duration, bitrate) = if let Some(line) = lines.get(1) { + let parts: Vec<&str> = line.split(',').collect(); + let fmt = parts.first().unwrap_or(&"?").to_string(); + let dur = parts + .get(1) + .and_then(|s| s.parse::().ok()) + .map(format_duration) + .unwrap_or_else(|| "?".to_string()); + let br = parts + .get(2) + .and_then(|s| s.parse::().ok()) + .map(|b| format!("{} kbps", b / 1000)) + .unwrap_or_else(|| "?".to_string()); + (fmt, dur, br) + } else { + ("?".to_string(), "?".to_string(), "?".to_string()) + }; + + (duration, format, bitrate, channels, sample_rate) + } else { + ( + "?".to_string(), + "?".to_string(), + "?".to_string(), + "?".to_string(), + "?".to_string(), + ) + } + } else { + ( + "?".to_string(), + "?".to_string(), + "?".to_string(), + "?".to_string(), + "?".to_string(), + ) + }; + + if json_output { + let info = serde_json::json!({ + "file": args.input.display().to_string(), + "duration": duration, + "format": format, + "bitrate": bitrate, + "channels": channels.parse::().unwrap_or(0), + "sample_rate": sample_rate.parse::().unwrap_or(0), + "size_bytes": file_size, + "size": file_size_str, + }); + println!( + "{}", + serde_json::to_string_pretty(&info).unwrap_or_default() + ); + } else { + println!("File: {}", args.input.display()); + println!("Duration: {}", duration); + println!("Format: {}", format); + println!("Bitrate: {}", bitrate); + println!("Channels: {}", channels); + println!("Sample Rate: {} Hz", sample_rate); + println!("Size: {}", file_size_str); + } + + Ok(()) +} + +fn format_duration(seconds: f64) -> String { + let hours = (seconds / 3600.0).floor() as u32; + let minutes = ((seconds % 3600.0) / 60.0).floor() as u32; + let secs = (seconds % 60.0).floor() as u32; + + if hours > 0 { + format!("{}:{:02}:{:02}", hours, minutes, secs) + } else { + format!("{}:{:02}", minutes, secs) + } +} diff --git a/crates/cli/src/commands/check.rs b/crates/cli/src/commands/check.rs index 21803be..d833a67 100644 --- a/crates/cli/src/commands/check.rs +++ b/crates/cli/src/commands/check.rs @@ -1,4 +1,5 @@ use forgekit_core::tools::exiftool::ExiftoolTool; +use forgekit_core::tools::ffmpeg::FfmpegTool; use forgekit_core::tools::gs::GsTool; use forgekit_core::tools::libvips::LibvipsTool; use forgekit_core::tools::ocrmypdf::OcrmypdfTool; @@ -25,6 +26,7 @@ pub fn handle_check_deps() -> Result<()> { ("ocrmypdf", Box::new(OcrmypdfTool)), ("exiftool", Box::new(ExiftoolTool)), ("vips", Box::new(LibvipsTool)), + ("ffmpeg", Box::new(FfmpegTool)), ]; let mut all_ok = true; diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 69ad818..d2d6592 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod audio; pub mod check; pub mod image; pub mod pdf; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 4066947..1a9bdaf 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -26,6 +26,7 @@ mod commands; use clap::{Parser, Subcommand}; +use commands::audio::{handle_audio_command, AudioCommand}; use commands::check::handle_check_deps; use commands::image::{handle_image_command, ImageCommand}; use commands::pdf::{handle_pdf_command, PdfCommand}; @@ -63,6 +64,10 @@ enum Commands { #[command(subcommand)] Image(ImageCommand), + /// Audio operations (convert, normalize, trim, join, etc.) + #[command(subcommand)] + Audio(AudioCommand), + /// Check if required dependencies are installed CheckDeps, } @@ -75,6 +80,7 @@ fn main() { let result = match &cli.command { Some(Commands::Pdf(ref cmd)) => handle_pdf_command(cmd.clone(), plan_only, json_output), Some(Commands::Image(ref cmd)) => handle_image_command(cmd.clone(), plan_only, json_output), + Some(Commands::Audio(ref cmd)) => handle_audio_command(cmd, plan_only, json_output), Some(Commands::CheckDeps) => handle_check_deps(), None => { println!("ForgeKit - Local-first media and PDF toolkit"); diff --git a/crates/core/src/job/executor.rs b/crates/core/src/job/executor.rs index cecf4b5..4e665d1 100644 --- a/crates/core/src/job/executor.rs +++ b/crates/core/src/job/executor.rs @@ -27,11 +27,13 @@ use crate::job::spec::MetadataAction; use crate::job::JobSpec; use crate::presets::get_compression_strategy; use crate::tools::exiftool::ExiftoolTool; +use crate::tools::ffmpeg::FfmpegTool; use crate::tools::gs::GsTool; use crate::tools::libvips::LibvipsTool; use crate::tools::ocrmypdf::OcrmypdfTool; use crate::tools::qpdf::QpdfTool; use crate::tools::{Tool, ToolConfig, ToolInfo}; +use crate::utils::audio::{AudioFormat, LoudnessTarget}; use crate::utils::error::{ForgeKitError, Result}; use crate::utils::image::ImageFormat; use crate::utils::pages::PageSpec; @@ -145,6 +147,38 @@ pub fn execute_job_with_progress( height, } => execute_image_resize(input, output, *width, *height, plan_only), JobSpec::ImageStrip { input, output } => execute_image_strip(input, output, plan_only), + + // Audio operations + JobSpec::AudioConvert { + input, + output, + format, + bitrate, + } => execute_audio_convert(input, output, format, *bitrate, plan_only), + JobSpec::AudioNormalize { + input, + output, + target, + } => execute_audio_normalize(input, output, target, plan_only), + JobSpec::AudioExtract { + input, + output, + format, + bitrate, + } => execute_audio_extract(input, output, format, *bitrate, plan_only), + JobSpec::AudioTrim { + input, + output, + start, + end, + } => execute_audio_trim(input, output, *start, *end, plan_only), + JobSpec::AudioJoin { inputs, output } => execute_audio_join(inputs, output, plan_only), + JobSpec::AudioVolume { + input, + output, + gain_db, + } => execute_audio_volume(input, output, *gain_db, plan_only), + JobSpec::AudioMono { input, output } => execute_audio_mono(input, output, plan_only), } } @@ -1187,6 +1221,235 @@ fn execute_image_strip(input: &Path, output: &Path, plan_only: bool) -> Result Result { + let tool = FfmpegTool; + let config = ToolConfig::default(); + tool.probe(&config) +} + +fn execute_audio_convert( + input: &Path, + output: &Path, + format: &AudioFormat, + bitrate: Option, + plan_only: bool, +) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_convert(input, output, format, bitrate)); + } + + if !input.exists() { + return Err(ForgeKitError::InvalidInput { + path: input.to_path_buf(), + reason: "Input file does not exist".to_string(), + }); + } + + let tool_info = probe_ffmpeg()?; + let tool = FfmpegTool; + tool.convert(&tool_info.path, input, output, format, bitrate)?; + + let bitrate_str = bitrate + .map(|b| format!(" at {}kbps", b)) + .unwrap_or_default(); + + Ok(format!( + "Successfully converted audio to {} ({}){}", + output.display(), + format.extension(), + bitrate_str + )) +} + +fn execute_audio_normalize( + input: &Path, + output: &Path, + target: &LoudnessTarget, + plan_only: bool, +) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_normalize(input, output, target)); + } + + if !input.exists() { + return Err(ForgeKitError::InvalidInput { + path: input.to_path_buf(), + reason: "Input file does not exist".to_string(), + }); + } + + let tool_info = probe_ffmpeg()?; + let tool = FfmpegTool; + tool.normalize(&tool_info.path, input, output, target)?; + + Ok(format!( + "Successfully normalized audio to {} ({})", + output.display(), + target + )) +} + +fn execute_audio_extract( + input: &Path, + output: &Path, + format: &AudioFormat, + bitrate: Option, + plan_only: bool, +) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_extract(input, output, format, bitrate)); + } + + if !input.exists() { + return Err(ForgeKitError::InvalidInput { + path: input.to_path_buf(), + reason: "Input file does not exist".to_string(), + }); + } + + let tool_info = probe_ffmpeg()?; + let tool = FfmpegTool; + tool.extract(&tool_info.path, input, output, format, bitrate)?; + + let bitrate_str = bitrate + .map(|b| format!(" at {}kbps", b)) + .unwrap_or_default(); + + Ok(format!( + "Successfully extracted audio to {} ({}){}", + output.display(), + format.extension(), + bitrate_str + )) +} + +fn execute_audio_trim( + input: &Path, + output: &Path, + start: Option, + end: Option, + plan_only: bool, +) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_trim(input, output, start, end)); + } + + if !input.exists() { + return Err(ForgeKitError::InvalidInput { + path: input.to_path_buf(), + reason: "Input file does not exist".to_string(), + }); + } + + let tool_info = probe_ffmpeg()?; + let tool = FfmpegTool; + tool.trim(&tool_info.path, input, output, start, end)?; + + let time_str = match (start, end) { + (Some(s), Some(e)) => format!(" from {:.1}s to {:.1}s", s, e), + (Some(s), None) => format!(" from {:.1}s", s), + (None, Some(e)) => format!(" to {:.1}s", e), + (None, None) => String::new(), + }; + + Ok(format!( + "Successfully trimmed audio to {}{}", + output.display(), + time_str + )) +} + +fn execute_audio_join(inputs: &[PathBuf], output: &Path, plan_only: bool) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_join(inputs, output)); + } + + // Validate inputs + for input in inputs { + if !input.exists() { + return Err(ForgeKitError::InvalidInput { + path: input.clone(), + reason: "Input file does not exist".to_string(), + }); + } + } + + if inputs.len() < 2 { + return Err(ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "At least 2 input files are required for join".to_string(), + }); + } + + let tool_info = probe_ffmpeg()?; + let tool = FfmpegTool; + tool.join(&tool_info.path, inputs, output)?; + + Ok(format!( + "Successfully joined {} audio files to {}", + inputs.len(), + output.display() + )) +} + +fn execute_audio_volume( + input: &Path, + output: &Path, + gain_db: f64, + plan_only: bool, +) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_volume(input, output, gain_db)); + } + + if !input.exists() { + return Err(ForgeKitError::InvalidInput { + path: input.to_path_buf(), + reason: "Input file does not exist".to_string(), + }); + } + + let tool_info = probe_ffmpeg()?; + let tool = FfmpegTool; + tool.volume(&tool_info.path, input, output, gain_db)?; + + let gain_str = if gain_db >= 0.0 { + format!("+{:.1}dB", gain_db) + } else { + format!("{:.1}dB", gain_db) + }; + + Ok(format!( + "Successfully adjusted volume by {} to {}", + gain_str, + output.display() + )) +} + +fn execute_audio_mono(input: &Path, output: &Path, plan_only: bool) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_mono(input, output)); + } + + if !input.exists() { + return Err(ForgeKitError::InvalidInput { + path: input.to_path_buf(), + reason: "Input file does not exist".to_string(), + }); + } + + let tool_info = probe_ffmpeg()?; + let tool = FfmpegTool; + tool.mono(&tool_info.path, input, output)?; + + Ok(format!( + "Successfully converted to mono: {}", + output.display() + )) +} + #[cfg(test)] mod tests { use super::*; @@ -1655,3 +1918,234 @@ mod image_operation_tests { assert!(result.contains("strip")); } } + +#[cfg(test)] +mod audio_operation_tests { + use super::*; + use crate::utils::audio::{AudioFormat, LoudnessTarget}; + + #[test] + fn test_execute_audio_convert_mp3_plan() { + let input = PathBuf::from("audio.wav"); + let output = PathBuf::from("audio.mp3"); + + let result = + execute_audio_convert(&input, &output, &AudioFormat::Mp3, Some(192), true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-i audio.wav")); + assert!(result.contains("-c:a libmp3lame")); + assert!(result.contains("-b:a 192k")); + assert!(result.contains("audio.mp3")); + } + + #[test] + fn test_execute_audio_convert_opus_plan() { + let input = PathBuf::from("audio.wav"); + let output = PathBuf::from("audio.opus"); + + let result = + execute_audio_convert(&input, &output, &AudioFormat::Opus, Some(128), true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-c:a libopus")); + assert!(result.contains("-b:a 128k")); + assert!(result.contains("-f opus")); + } + + #[test] + fn test_execute_audio_convert_flac_no_bitrate() { + let input = PathBuf::from("audio.wav"); + let output = PathBuf::from("audio.flac"); + + // FLAC doesn't support bitrate, so it shouldn't be in the plan + let result = + execute_audio_convert(&input, &output, &AudioFormat::Flac, Some(320), true).unwrap(); + + assert!(result.contains("-c:a flac")); + assert!(!result.contains("-b:a")); + } + + #[test] + fn test_execute_audio_convert_aac_plan() { + let input = PathBuf::from("audio.wav"); + let output = PathBuf::from("audio.m4a"); + + let result = + execute_audio_convert(&input, &output, &AudioFormat::M4a, Some(256), true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-c:a aac")); + assert!(result.contains("-b:a 256k")); + } + + #[test] + fn test_execute_audio_normalize_ebu_r128_plan() { + let input = PathBuf::from("audio.wav"); + let output = PathBuf::from("audio_normalized.wav"); + + let result = + execute_audio_normalize(&input, &output, &LoudnessTarget::EbuR128, true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-af")); + assert!(result.contains("loudnorm")); + assert!(result.contains("I=-23")); + assert!(result.contains("TP=-1")); + } + + #[test] + fn test_execute_audio_normalize_streaming_plan() { + let input = PathBuf::from("audio.wav"); + let output = PathBuf::from("audio_normalized.wav"); + + let result = + execute_audio_normalize(&input, &output, &LoudnessTarget::Streaming, true).unwrap(); + + assert!(result.contains("I=-14")); + } + + #[test] + fn test_execute_audio_normalize_custom_plan() { + let input = PathBuf::from("audio.wav"); + let output = PathBuf::from("audio_normalized.wav"); + + let result = + execute_audio_normalize(&input, &output, &LoudnessTarget::Custom(-16.0), true).unwrap(); + + assert!(result.contains("I=-16")); + } + + #[test] + fn test_execute_audio_extract_mp3_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("audio.mp3"); + + let result = + execute_audio_extract(&input, &output, &AudioFormat::Mp3, Some(192), true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-i video.mp4")); + assert!(result.contains("-vn")); // Strip video + assert!(result.contains("-c:a libmp3lame")); + assert!(result.contains("-b:a 192k")); + assert!(result.contains("audio.mp3")); + } + + #[test] + fn test_execute_audio_extract_opus_plan() { + let input = PathBuf::from("video.mkv"); + let output = PathBuf::from("audio.opus"); + + let result = + execute_audio_extract(&input, &output, &AudioFormat::Opus, Some(128), true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-vn")); + assert!(result.contains("-c:a libopus")); + assert!(result.contains("-b:a 128k")); + assert!(result.contains("-f opus")); + } + + #[test] + fn test_execute_audio_extract_flac_no_bitrate() { + let input = PathBuf::from("video.mov"); + let output = PathBuf::from("audio.flac"); + + // FLAC doesn't support bitrate + let result = + execute_audio_extract(&input, &output, &AudioFormat::Flac, Some(320), true).unwrap(); + + assert!(result.contains("-vn")); + assert!(result.contains("-c:a flac")); + assert!(!result.contains("-b:a")); + } + + #[test] + fn test_execute_audio_trim_plan() { + let input = PathBuf::from("song.mp3"); + let output = PathBuf::from("clip.mp3"); + + let result = execute_audio_trim(&input, &output, Some(30.0), Some(120.0), true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-ss 30")); + assert!(result.contains("-t 90")); // duration = 120 - 30 + assert!(result.contains("-c copy")); + } + + #[test] + fn test_execute_audio_trim_start_only_plan() { + let input = PathBuf::from("song.mp3"); + let output = PathBuf::from("clip.mp3"); + + let result = execute_audio_trim(&input, &output, Some(60.0), None, true).unwrap(); + + assert!(result.contains("-ss 60")); + assert!(!result.contains("-t ")); + assert!(!result.contains("-to ")); + } + + #[test] + fn test_execute_audio_trim_end_only_plan() { + let input = PathBuf::from("song.mp3"); + let output = PathBuf::from("clip.mp3"); + + let result = execute_audio_trim(&input, &output, None, Some(90.0), true).unwrap(); + + assert!(!result.contains("-ss")); + assert!(result.contains("-to 90")); + } + + #[test] + fn test_execute_audio_join_plan() { + let inputs = vec![ + PathBuf::from("part1.mp3"), + PathBuf::from("part2.mp3"), + PathBuf::from("part3.mp3"), + ]; + let output = PathBuf::from("joined.mp3"); + + let result = execute_audio_join(&inputs, &output, true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-f concat")); + assert!(result.contains("-c copy")); + assert!(result.contains("part1.mp3")); + assert!(result.contains("part2.mp3")); + assert!(result.contains("part3.mp3")); + } + + #[test] + fn test_execute_audio_volume_positive_plan() { + let input = PathBuf::from("quiet.wav"); + let output = PathBuf::from("louder.wav"); + + let result = execute_audio_volume(&input, &output, 6.0, true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-af")); + assert!(result.contains("volume=6dB")); + } + + #[test] + fn test_execute_audio_volume_negative_plan() { + let input = PathBuf::from("loud.wav"); + let output = PathBuf::from("quieter.wav"); + + let result = execute_audio_volume(&input, &output, -3.0, true).unwrap(); + + assert!(result.contains("volume=-3dB")); + } + + #[test] + fn test_execute_audio_mono_plan() { + let input = PathBuf::from("stereo.wav"); + let output = PathBuf::from("mono.wav"); + + let result = execute_audio_mono(&input, &output, true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-ac 1")); + } +} diff --git a/crates/core/src/job/spec.rs b/crates/core/src/job/spec.rs index 4473bbf..e7b8a76 100644 --- a/crates/core/src/job/spec.rs +++ b/crates/core/src/job/spec.rs @@ -11,6 +11,7 @@ //! - **Serialization**: Could save jobs to disk, queue them, etc. (future) //! - **Clarity**: The executor code is cleaner when it just focuses on "how", not "what" +use crate::utils::audio::{AudioFormat, LoudnessTarget}; use crate::utils::image::ImageFormat; use crate::utils::pages::PageSpec; use std::path::PathBuf; @@ -189,6 +190,93 @@ pub enum JobSpec { /// Output image file. output: PathBuf, }, + + // ========== Audio Operations ========== + /// Convert audio to a different format. + /// + /// Uses ffmpeg for conversion. Supports MP3, AAC, Opus, FLAC, WAV, OGG, M4A. + AudioConvert { + /// Input audio file. + input: PathBuf, + /// Output audio file. + output: PathBuf, + /// Target format. + format: AudioFormat, + /// Bitrate in kbps (e.g., 128, 192, 320). Not all formats support bitrate. + bitrate: Option, + }, + + /// Normalize audio loudness. + /// + /// Uses ffmpeg loudnorm filter for EBU R128 compliant loudness normalization. + AudioNormalize { + /// Input audio file. + input: PathBuf, + /// Output audio file. + output: PathBuf, + /// Loudness target (EBU R128, Streaming, or custom LUFS). + target: LoudnessTarget, + }, + + /// Extract audio from video file. + /// + /// Uses ffmpeg to extract the audio stream from a video file. + AudioExtract { + /// Input video file. + input: PathBuf, + /// Output audio file. + output: PathBuf, + /// Target audio format. + format: AudioFormat, + /// Bitrate in kbps (e.g., 128, 192, 320). Optional. + bitrate: Option, + }, + + /// Trim audio to a specific time range. + /// + /// Uses ffmpeg to extract a segment from start to end time. + AudioTrim { + /// Input audio file. + input: PathBuf, + /// Output audio file. + output: PathBuf, + /// Start time in seconds (e.g., 30.0 for 0:30). + start: Option, + /// End time in seconds (e.g., 120.0 for 2:00). + end: Option, + }, + + /// Join multiple audio files into one. + /// + /// Uses ffmpeg concat demuxer to concatenate audio files. + AudioJoin { + /// Input audio files to join (at least 2 required). + inputs: Vec, + /// Output audio file. + output: PathBuf, + }, + + /// Adjust audio volume/gain. + /// + /// Uses ffmpeg volume filter to adjust gain in decibels. + AudioVolume { + /// Input audio file. + input: PathBuf, + /// Output audio file. + output: PathBuf, + /// Gain adjustment in decibels (e.g., 6.0 for +6dB, -3.0 for -3dB). + gain_db: f64, + }, + + /// Convert stereo audio to mono. + /// + /// Uses ffmpeg to downmix stereo to mono. + AudioMono { + /// Input audio file. + input: PathBuf, + /// Output audio file. + output: PathBuf, + }, } impl JobSpec { @@ -246,6 +334,44 @@ impl JobSpec { (None, None) => "Resize image".to_string(), }, JobSpec::ImageStrip { .. } => "Strip image metadata".to_string(), + JobSpec::AudioConvert { + format, bitrate, .. + } => { + if let Some(br) = bitrate { + format!("Convert audio to {} ({}kbps)", format.extension(), br) + } else { + format!("Convert audio to {}", format.extension()) + } + } + JobSpec::AudioNormalize { target, .. } => { + format!("Normalize audio to {}", target) + } + JobSpec::AudioExtract { + format, bitrate, .. + } => { + if let Some(br) = bitrate { + format!("Extract audio as {} ({}kbps)", format.extension(), br) + } else { + format!("Extract audio as {}", format.extension()) + } + } + JobSpec::AudioTrim { start, end, .. } => match (start, end) { + (Some(s), Some(e)) => format!("Trim audio from {:.1}s to {:.1}s", s, e), + (Some(s), None) => format!("Trim audio from {:.1}s to end", s), + (None, Some(e)) => format!("Trim audio from start to {:.1}s", e), + (None, None) => "Trim audio".to_string(), + }, + JobSpec::AudioJoin { inputs, .. } => { + format!("Join {} audio files", inputs.len()) + } + JobSpec::AudioVolume { gain_db, .. } => { + if *gain_db >= 0.0 { + format!("Adjust audio volume by +{:.1}dB", gain_db) + } else { + format!("Adjust audio volume by {:.1}dB", gain_db) + } + } + JobSpec::AudioMono { .. } => "Convert audio to mono".to_string(), } } } @@ -456,4 +582,158 @@ mod tests { }; assert_eq!(spec.description(), "Convert image to png"); } + + // Audio tests + + #[test] + fn test_audio_convert_description_with_bitrate() { + let spec = JobSpec::AudioConvert { + input: PathBuf::from("audio.wav"), + output: PathBuf::from("audio.mp3"), + format: AudioFormat::Mp3, + bitrate: Some(192), + }; + assert_eq!(spec.description(), "Convert audio to mp3 (192kbps)"); + } + + #[test] + fn test_audio_convert_description_no_bitrate() { + let spec = JobSpec::AudioConvert { + input: PathBuf::from("audio.wav"), + output: PathBuf::from("audio.flac"), + format: AudioFormat::Flac, + bitrate: None, + }; + assert_eq!(spec.description(), "Convert audio to flac"); + } + + #[test] + fn test_audio_normalize_ebu_r128_description() { + let spec = JobSpec::AudioNormalize { + input: PathBuf::from("audio.wav"), + output: PathBuf::from("audio_normalized.wav"), + target: LoudnessTarget::EbuR128, + }; + assert_eq!(spec.description(), "Normalize audio to EBU R128 (-23 LUFS)"); + } + + #[test] + fn test_audio_normalize_streaming_description() { + let spec = JobSpec::AudioNormalize { + input: PathBuf::from("audio.wav"), + output: PathBuf::from("audio_normalized.wav"), + target: LoudnessTarget::Streaming, + }; + assert_eq!( + spec.description(), + "Normalize audio to Streaming (-14 LUFS)" + ); + } + + #[test] + fn test_audio_normalize_custom_description() { + let spec = JobSpec::AudioNormalize { + input: PathBuf::from("audio.wav"), + output: PathBuf::from("audio_normalized.wav"), + target: LoudnessTarget::Custom(-16.0), + }; + assert_eq!(spec.description(), "Normalize audio to -16 LUFS"); + } + + #[test] + fn test_audio_extract_description_with_bitrate() { + let spec = JobSpec::AudioExtract { + input: PathBuf::from("video.mp4"), + output: PathBuf::from("audio.mp3"), + format: AudioFormat::Mp3, + bitrate: Some(192), + }; + assert_eq!(spec.description(), "Extract audio as mp3 (192kbps)"); + } + + #[test] + fn test_audio_extract_description_no_bitrate() { + let spec = JobSpec::AudioExtract { + input: PathBuf::from("video.mp4"), + output: PathBuf::from("audio.flac"), + format: AudioFormat::Flac, + bitrate: None, + }; + assert_eq!(spec.description(), "Extract audio as flac"); + } + + #[test] + fn test_audio_trim_description() { + let spec = JobSpec::AudioTrim { + input: PathBuf::from("song.mp3"), + output: PathBuf::from("clip.mp3"), + start: Some(30.0), + end: Some(120.0), + }; + assert_eq!(spec.description(), "Trim audio from 30.0s to 120.0s"); + } + + #[test] + fn test_audio_trim_description_start_only() { + let spec = JobSpec::AudioTrim { + input: PathBuf::from("song.mp3"), + output: PathBuf::from("clip.mp3"), + start: Some(60.0), + end: None, + }; + assert_eq!(spec.description(), "Trim audio from 60.0s to end"); + } + + #[test] + fn test_audio_trim_description_end_only() { + let spec = JobSpec::AudioTrim { + input: PathBuf::from("song.mp3"), + output: PathBuf::from("clip.mp3"), + start: None, + end: Some(90.0), + }; + assert_eq!(spec.description(), "Trim audio from start to 90.0s"); + } + + #[test] + fn test_audio_join_description() { + let spec = JobSpec::AudioJoin { + inputs: vec![ + PathBuf::from("part1.mp3"), + PathBuf::from("part2.mp3"), + PathBuf::from("part3.mp3"), + ], + output: PathBuf::from("joined.mp3"), + }; + assert_eq!(spec.description(), "Join 3 audio files"); + } + + #[test] + fn test_audio_volume_positive_description() { + let spec = JobSpec::AudioVolume { + input: PathBuf::from("quiet.wav"), + output: PathBuf::from("louder.wav"), + gain_db: 6.0, + }; + assert_eq!(spec.description(), "Adjust audio volume by +6.0dB"); + } + + #[test] + fn test_audio_volume_negative_description() { + let spec = JobSpec::AudioVolume { + input: PathBuf::from("loud.wav"), + output: PathBuf::from("quieter.wav"), + gain_db: -3.0, + }; + assert_eq!(spec.description(), "Adjust audio volume by -3.0dB"); + } + + #[test] + fn test_audio_mono_description() { + let spec = JobSpec::AudioMono { + input: PathBuf::from("stereo.wav"), + output: PathBuf::from("mono.wav"), + }; + assert_eq!(spec.description(), "Convert audio to mono"); + } } diff --git a/crates/core/src/tools/ffmpeg.rs b/crates/core/src/tools/ffmpeg.rs new file mode 100644 index 0000000..ca7e1ba --- /dev/null +++ b/crates/core/src/tools/ffmpeg.rs @@ -0,0 +1,754 @@ +//! # ffmpeg Tool Adapter +//! +//! ffmpeg is a powerful multimedia framework for audio/video conversion. +//! We use the `ffmpeg` CLI for: +//! - Audio format conversion +//! - Bitrate transcoding +//! - Loudness normalization (EBU R128) +//! +//! ## Minimum Version: 5.0+ + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::tools::{Tool, ToolConfig, ToolInfo}; +use crate::utils::audio::{AudioFormat, LoudnessTarget}; +use crate::utils::error::{ForgeKitError, Result}; +use crate::utils::platform::ToolInstallHints; + +/// ffmpeg tool adapter. +pub struct FfmpegTool; + +impl Tool for FfmpegTool { + fn name(&self) -> &'static str { + "ffmpeg" + } + + fn probe(&self, config: &ToolConfig) -> Result { + // Check override path first + if let Some(ref path) = config.override_path { + if path.exists() { + let version = self.version(path)?; + return Ok(ToolInfo { + path: path.clone(), + version, + available: true, + }); + } + } + + // Probe PATH for 'ffmpeg' command + let which_output = if cfg!(target_os = "windows") { + Command::new("where").arg("ffmpeg").output() + } else { + Command::new("which").arg("ffmpeg").output() + }; + + let path = match which_output { + Ok(output) if output.status.success() => { + let path_str = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + if !path_str.is_empty() { + PathBuf::from(path_str) + } else { + PathBuf::from("ffmpeg") + } + } + _ => PathBuf::from("ffmpeg"), + }; + + // Verify it works + let output = Command::new(&path).arg("-version").output().map_err(|_| { + ForgeKitError::ToolNotFound { + tool: "ffmpeg".to_string(), + hint: ToolInstallHints::for_tool("ffmpeg"), + } + })?; + + if !output.status.success() { + return Err(ForgeKitError::ToolNotFound { + tool: "ffmpeg".to_string(), + hint: ToolInstallHints::for_tool("ffmpeg"), + }); + } + + let version = self.version(&path)?; + + Ok(ToolInfo { + path, + version, + available: true, + }) + } + + fn version(&self, path: &Path) -> Result { + let output = Command::new(path) + .arg("-version") + .output() + .map_err(|e| ForgeKitError::Other(anyhow::anyhow!("Failed to run ffmpeg: {}", e)))?; + + if !output.status.success() { + return Err(ForgeKitError::Other(anyhow::anyhow!( + "ffmpeg -version failed" + ))); + } + + // ffmpeg -version outputs something like: "ffmpeg version 6.1 Copyright..." + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .to_string(); + + // Extract just the version part + let version = version + .split_whitespace() + .nth(2) + .unwrap_or(&version) + .to_string(); + + Ok(version.trim().to_string()) + } +} + +impl FfmpegTool { + /// Convert audio to a different format with optional bitrate. + /// + /// Uses `ffmpeg -i input -c:a codec -b:a bitrate output` syntax. + pub fn convert( + &self, + tool_path: &Path, + input: &Path, + output: &Path, + format: &AudioFormat, + bitrate: Option, + ) -> Result<()> { + let mut cmd = Command::new(tool_path); + cmd.arg("-y"); // Overwrite output + cmd.arg("-i").arg(input); + cmd.arg("-c:a").arg(format.ffmpeg_codec()); + + if let Some(br) = bitrate { + if format.supports_bitrate() { + cmd.arg("-b:a").arg(format!("{}k", br)); + } + } + + // For opus in ogg container, we need to specify format + if matches!(format, AudioFormat::Opus) { + cmd.arg("-f").arg("opus"); + } + + cmd.arg(output); + + let output_result = cmd + .output() + .map_err(|e| ForgeKitError::Other(anyhow::anyhow!("Failed to run ffmpeg: {}", e)))?; + + if !output_result.status.success() { + let stderr = String::from_utf8_lossy(&output_result.stderr); + return Err(ForgeKitError::ProcessingFailed { + tool: "ffmpeg".to_string(), + stderr: stderr.to_string(), + }); + } + + Ok(()) + } + + /// Normalize audio loudness using EBU R128 standard. + /// + /// Uses two-pass loudnorm filter for accurate normalization. + pub fn normalize( + &self, + tool_path: &Path, + input: &Path, + output: &Path, + target: &LoudnessTarget, + ) -> Result<()> { + let lufs = target.lufs(); + let tp = target.true_peak(); + + // Single-pass loudnorm with linear normalization + let loudnorm_filter = format!("loudnorm=I={}:TP={}:LRA=11:print_format=summary", lufs, tp); + + let mut cmd = Command::new(tool_path); + cmd.arg("-y"); // Overwrite output + cmd.arg("-i").arg(input); + cmd.arg("-af").arg(&loudnorm_filter); + cmd.arg(output); + + let output_result = cmd + .output() + .map_err(|e| ForgeKitError::Other(anyhow::anyhow!("Failed to run ffmpeg: {}", e)))?; + + if !output_result.status.success() { + let stderr = String::from_utf8_lossy(&output_result.stderr); + return Err(ForgeKitError::ProcessingFailed { + tool: "ffmpeg".to_string(), + stderr: stderr.to_string(), + }); + } + + Ok(()) + } + + /// Extract audio from video file. + /// + /// Uses `ffmpeg -i input -vn -c:a codec output` to strip video and keep audio. + pub fn extract( + &self, + tool_path: &Path, + input: &Path, + output: &Path, + format: &AudioFormat, + bitrate: Option, + ) -> Result<()> { + let mut cmd = Command::new(tool_path); + cmd.arg("-y"); // Overwrite output + cmd.arg("-i").arg(input); + cmd.arg("-vn"); // No video - strip video stream + cmd.arg("-c:a").arg(format.ffmpeg_codec()); + + if let Some(br) = bitrate { + if format.supports_bitrate() { + cmd.arg("-b:a").arg(format!("{}k", br)); + } + } + + // For opus in ogg container, we need to specify format + if matches!(format, AudioFormat::Opus) { + cmd.arg("-f").arg("opus"); + } + + cmd.arg(output); + + let output_result = cmd + .output() + .map_err(|e| ForgeKitError::Other(anyhow::anyhow!("Failed to run ffmpeg: {}", e)))?; + + if !output_result.status.success() { + let stderr = String::from_utf8_lossy(&output_result.stderr); + return Err(ForgeKitError::ProcessingFailed { + tool: "ffmpeg".to_string(), + stderr: stderr.to_string(), + }); + } + + Ok(()) + } + + /// Generate plan string for audio extraction (for --plan flag). + pub fn plan_extract( + input: &Path, + output: &Path, + format: &AudioFormat, + bitrate: Option, + ) -> String { + let mut parts = vec![ + "ffmpeg".to_string(), + "-y".to_string(), + "-i".to_string(), + input.display().to_string(), + "-vn".to_string(), + "-c:a".to_string(), + format.ffmpeg_codec().to_string(), + ]; + + if let Some(br) = bitrate { + if format.supports_bitrate() { + parts.push("-b:a".to_string()); + parts.push(format!("{}k", br)); + } + } + + if matches!(format, AudioFormat::Opus) { + parts.push("-f".to_string()); + parts.push("opus".to_string()); + } + + parts.push(output.display().to_string()); + parts.join(" ") + } + + /// Generate plan string for audio conversion (for --plan flag). + pub fn plan_convert( + input: &Path, + output: &Path, + format: &AudioFormat, + bitrate: Option, + ) -> String { + let mut parts = vec![ + "ffmpeg".to_string(), + "-y".to_string(), + "-i".to_string(), + input.display().to_string(), + "-c:a".to_string(), + format.ffmpeg_codec().to_string(), + ]; + + if let Some(br) = bitrate { + if format.supports_bitrate() { + parts.push("-b:a".to_string()); + parts.push(format!("{}k", br)); + } + } + + if matches!(format, AudioFormat::Opus) { + parts.push("-f".to_string()); + parts.push("opus".to_string()); + } + + parts.push(output.display().to_string()); + parts.join(" ") + } + + /// Generate plan string for audio normalization (for --plan flag). + pub fn plan_normalize(input: &Path, output: &Path, target: &LoudnessTarget) -> String { + let lufs = target.lufs(); + let tp = target.true_peak(); + + format!( + "ffmpeg -y -i {} -af \"loudnorm=I={}:TP={}:LRA=11:print_format=summary\" {}", + input.display(), + lufs, + tp, + output.display() + ) + } + + /// Trim audio to a specific time range. + /// + /// Uses `-ss` for start time and `-to` for end time. + pub fn trim( + &self, + tool_path: &Path, + input: &Path, + output: &Path, + start: Option, + end: Option, + ) -> Result<()> { + let mut cmd = Command::new(tool_path); + cmd.arg("-y"); // Overwrite output + + // -ss before -i for fast seeking + if let Some(s) = start { + cmd.arg("-ss").arg(format!("{}", s)); + } + + cmd.arg("-i").arg(input); + + // -to after -i for accurate end time (relative to start if -ss used before -i) + if let Some(e) = end { + if let Some(s) = start { + // Duration from start + cmd.arg("-t").arg(format!("{}", e - s)); + } else { + cmd.arg("-to").arg(format!("{}", e)); + } + } + + cmd.arg("-c").arg("copy"); // Stream copy for speed + cmd.arg(output); + + let output_result = cmd + .output() + .map_err(|e| ForgeKitError::Other(anyhow::anyhow!("Failed to run ffmpeg: {}", e)))?; + + if !output_result.status.success() { + let stderr = String::from_utf8_lossy(&output_result.stderr); + return Err(ForgeKitError::ProcessingFailed { + tool: "ffmpeg".to_string(), + stderr: stderr.to_string(), + }); + } + + Ok(()) + } + + /// Generate plan string for audio trimming (for --plan flag). + pub fn plan_trim(input: &Path, output: &Path, start: Option, end: Option) -> String { + let mut parts = vec!["ffmpeg".to_string(), "-y".to_string()]; + + if let Some(s) = start { + parts.push("-ss".to_string()); + parts.push(format!("{}", s)); + } + + parts.push("-i".to_string()); + parts.push(input.display().to_string()); + + if let Some(e) = end { + if let Some(s) = start { + parts.push("-t".to_string()); + parts.push(format!("{}", e - s)); + } else { + parts.push("-to".to_string()); + parts.push(format!("{}", e)); + } + } + + parts.push("-c".to_string()); + parts.push("copy".to_string()); + parts.push(output.display().to_string()); + + parts.join(" ") + } + + /// Join multiple audio files into one. + /// + /// Uses ffmpeg concat demuxer with a temporary file list. + pub fn join(&self, tool_path: &Path, inputs: &[PathBuf], output: &Path) -> Result<()> { + use std::io::Write; + + // Create temporary file with list of inputs + let temp_dir = std::env::temp_dir(); + let list_file = temp_dir.join(format!("ffmpeg_concat_{}.txt", std::process::id())); + + { + let mut file = std::fs::File::create(&list_file).map_err(|e| { + ForgeKitError::Other(anyhow::anyhow!("Failed to create concat list: {}", e)) + })?; + + for input in inputs { + // Escape single quotes in paths + let escaped = input.display().to_string().replace('\'', "'\\''"); + writeln!(file, "file '{}'", escaped).map_err(|e| { + ForgeKitError::Other(anyhow::anyhow!("Failed to write concat list: {}", e)) + })?; + } + } + + let mut cmd = Command::new(tool_path); + cmd.arg("-y"); + cmd.arg("-f").arg("concat"); + cmd.arg("-safe").arg("0"); // Allow absolute paths + cmd.arg("-i").arg(&list_file); + cmd.arg("-c").arg("copy"); // Stream copy + cmd.arg(output); + + let output_result = cmd + .output() + .map_err(|e| ForgeKitError::Other(anyhow::anyhow!("Failed to run ffmpeg: {}", e)))?; + + // Clean up temp file + let _ = std::fs::remove_file(&list_file); + + if !output_result.status.success() { + let stderr = String::from_utf8_lossy(&output_result.stderr); + return Err(ForgeKitError::ProcessingFailed { + tool: "ffmpeg".to_string(), + stderr: stderr.to_string(), + }); + } + + Ok(()) + } + + /// Generate plan string for audio joining (for --plan flag). + pub fn plan_join(inputs: &[PathBuf], output: &Path) -> String { + let input_list: Vec = inputs.iter().map(|p| p.display().to_string()).collect(); + format!( + "ffmpeg -y -f concat -safe 0 -i -c copy {}", + input_list.join(", "), + output.display() + ) + } + + /// Adjust audio volume/gain. + /// + /// Uses ffmpeg volume filter with dB adjustment. + pub fn volume( + &self, + tool_path: &Path, + input: &Path, + output: &Path, + gain_db: f64, + ) -> Result<()> { + let mut cmd = Command::new(tool_path); + cmd.arg("-y"); + cmd.arg("-i").arg(input); + cmd.arg("-af").arg(format!("volume={}dB", gain_db)); + cmd.arg(output); + + let output_result = cmd + .output() + .map_err(|e| ForgeKitError::Other(anyhow::anyhow!("Failed to run ffmpeg: {}", e)))?; + + if !output_result.status.success() { + let stderr = String::from_utf8_lossy(&output_result.stderr); + return Err(ForgeKitError::ProcessingFailed { + tool: "ffmpeg".to_string(), + stderr: stderr.to_string(), + }); + } + + Ok(()) + } + + /// Generate plan string for volume adjustment (for --plan flag). + pub fn plan_volume(input: &Path, output: &Path, gain_db: f64) -> String { + format!( + "ffmpeg -y -i {} -af \"volume={}dB\" {}", + input.display(), + gain_db, + output.display() + ) + } + + /// Convert stereo audio to mono. + /// + /// Uses ffmpeg pan filter to downmix to mono. + pub fn mono(&self, tool_path: &Path, input: &Path, output: &Path) -> Result<()> { + let mut cmd = Command::new(tool_path); + cmd.arg("-y"); + cmd.arg("-i").arg(input); + cmd.arg("-ac").arg("1"); // Set audio channels to 1 (mono) + cmd.arg(output); + + let output_result = cmd + .output() + .map_err(|e| ForgeKitError::Other(anyhow::anyhow!("Failed to run ffmpeg: {}", e)))?; + + if !output_result.status.success() { + let stderr = String::from_utf8_lossy(&output_result.stderr); + return Err(ForgeKitError::ProcessingFailed { + tool: "ffmpeg".to_string(), + stderr: stderr.to_string(), + }); + } + + Ok(()) + } + + /// Generate plan string for mono conversion (for --plan flag). + pub fn plan_mono(input: &Path, output: &Path) -> String { + format!( + "ffmpeg -y -i {} -ac 1 {}", + input.display(), + output.display() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ffmpeg_name() { + let tool = FfmpegTool; + assert_eq!(tool.name(), "ffmpeg"); + } + + #[test] + fn test_ffmpeg_probe() { + let tool = FfmpegTool; + let config = ToolConfig::default(); + + // This test will pass if ffmpeg is installed, skip otherwise + if let Ok(info) = tool.probe(&config) { + assert!(info.available); + assert!(!info.version.is_empty()); + } + } + + #[test] + fn test_plan_convert_mp3() { + let input = PathBuf::from("audio.wav"); + let output = PathBuf::from("audio.mp3"); + + let plan = FfmpegTool::plan_convert(&input, &output, &AudioFormat::Mp3, Some(192)); + + assert!(plan.contains("ffmpeg")); + assert!(plan.contains("-i audio.wav")); + assert!(plan.contains("-c:a libmp3lame")); + assert!(plan.contains("-b:a 192k")); + assert!(plan.contains("audio.mp3")); + } + + #[test] + fn test_plan_convert_opus() { + let input = PathBuf::from("audio.wav"); + let output = PathBuf::from("audio.opus"); + + let plan = FfmpegTool::plan_convert(&input, &output, &AudioFormat::Opus, Some(128)); + + assert!(plan.contains("ffmpeg")); + assert!(plan.contains("-c:a libopus")); + assert!(plan.contains("-b:a 128k")); + assert!(plan.contains("-f opus")); + } + + #[test] + fn test_plan_convert_flac_no_bitrate() { + let input = PathBuf::from("audio.wav"); + let output = PathBuf::from("audio.flac"); + + let plan = FfmpegTool::plan_convert(&input, &output, &AudioFormat::Flac, Some(320)); + + // FLAC doesn't support bitrate, so it shouldn't be in the plan + assert!(plan.contains("-c:a flac")); + assert!(!plan.contains("-b:a")); + } + + #[test] + fn test_plan_normalize_ebu_r128() { + let input = PathBuf::from("audio.wav"); + let output = PathBuf::from("audio_normalized.wav"); + + let plan = FfmpegTool::plan_normalize(&input, &output, &LoudnessTarget::EbuR128); + + assert!(plan.contains("ffmpeg")); + assert!(plan.contains("-af")); + assert!(plan.contains("loudnorm")); + assert!(plan.contains("I=-23")); + assert!(plan.contains("TP=-1")); + } + + #[test] + fn test_plan_normalize_streaming() { + let input = PathBuf::from("audio.wav"); + let output = PathBuf::from("audio_normalized.wav"); + + let plan = FfmpegTool::plan_normalize(&input, &output, &LoudnessTarget::Streaming); + + assert!(plan.contains("I=-14")); + } + + #[test] + fn test_plan_normalize_custom() { + let input = PathBuf::from("audio.wav"); + let output = PathBuf::from("audio_normalized.wav"); + + let plan = FfmpegTool::plan_normalize(&input, &output, &LoudnessTarget::Custom(-16.0)); + + assert!(plan.contains("I=-16")); + } + + #[test] + fn test_plan_extract_mp3() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("audio.mp3"); + + let plan = FfmpegTool::plan_extract(&input, &output, &AudioFormat::Mp3, Some(192)); + + assert!(plan.contains("ffmpeg")); + assert!(plan.contains("-i video.mp4")); + assert!(plan.contains("-vn")); + assert!(plan.contains("-c:a libmp3lame")); + assert!(plan.contains("-b:a 192k")); + assert!(plan.contains("audio.mp3")); + } + + #[test] + fn test_plan_extract_opus() { + let input = PathBuf::from("video.mkv"); + let output = PathBuf::from("audio.opus"); + + let plan = FfmpegTool::plan_extract(&input, &output, &AudioFormat::Opus, Some(128)); + + assert!(plan.contains("-vn")); + assert!(plan.contains("-c:a libopus")); + assert!(plan.contains("-f opus")); + } + + #[test] + fn test_plan_extract_flac_no_bitrate() { + let input = PathBuf::from("video.mov"); + let output = PathBuf::from("audio.flac"); + + // FLAC doesn't support bitrate + let plan = FfmpegTool::plan_extract(&input, &output, &AudioFormat::Flac, Some(320)); + + assert!(plan.contains("-vn")); + assert!(plan.contains("-c:a flac")); + assert!(!plan.contains("-b:a")); + } + + #[test] + fn test_plan_trim() { + let input = PathBuf::from("song.mp3"); + let output = PathBuf::from("clip.mp3"); + + let plan = FfmpegTool::plan_trim(&input, &output, Some(30.0), Some(120.0)); + + assert!(plan.contains("ffmpeg")); + assert!(plan.contains("-ss 30")); + assert!(plan.contains("-t 90")); // duration = 120 - 30 + assert!(plan.contains("-c copy")); + } + + #[test] + fn test_plan_trim_start_only() { + let input = PathBuf::from("song.mp3"); + let output = PathBuf::from("clip.mp3"); + + let plan = FfmpegTool::plan_trim(&input, &output, Some(60.0), None); + + assert!(plan.contains("-ss 60")); + assert!(!plan.contains("-t ")); + } + + #[test] + fn test_plan_trim_end_only() { + let input = PathBuf::from("song.mp3"); + let output = PathBuf::from("clip.mp3"); + + let plan = FfmpegTool::plan_trim(&input, &output, None, Some(90.0)); + + assert!(!plan.contains("-ss")); + assert!(plan.contains("-to 90")); + } + + #[test] + fn test_plan_join() { + let inputs = vec![PathBuf::from("part1.mp3"), PathBuf::from("part2.mp3")]; + let output = PathBuf::from("joined.mp3"); + + let plan = FfmpegTool::plan_join(&inputs, &output); + + assert!(plan.contains("ffmpeg")); + assert!(plan.contains("-f concat")); + assert!(plan.contains("-c copy")); + assert!(plan.contains("part1.mp3")); + assert!(plan.contains("part2.mp3")); + } + + #[test] + fn test_plan_volume_positive() { + let input = PathBuf::from("quiet.wav"); + let output = PathBuf::from("louder.wav"); + + let plan = FfmpegTool::plan_volume(&input, &output, 6.0); + + assert!(plan.contains("ffmpeg")); + assert!(plan.contains("-af")); + assert!(plan.contains("volume=6dB")); + } + + #[test] + fn test_plan_volume_negative() { + let input = PathBuf::from("loud.wav"); + let output = PathBuf::from("quieter.wav"); + + let plan = FfmpegTool::plan_volume(&input, &output, -3.0); + + assert!(plan.contains("volume=-3dB")); + } + + #[test] + fn test_plan_mono() { + let input = PathBuf::from("stereo.wav"); + let output = PathBuf::from("mono.wav"); + + let plan = FfmpegTool::plan_mono(&input, &output); + + assert!(plan.contains("ffmpeg")); + assert!(plan.contains("-ac 1")); + } +} diff --git a/crates/core/src/tools/mod.rs b/crates/core/src/tools/mod.rs index a96e601..524d2ca 100644 --- a/crates/core/src/tools/mod.rs +++ b/crates/core/src/tools/mod.rs @@ -1,9 +1,11 @@ pub mod exiftool; +pub mod ffmpeg; pub mod gs; pub mod libvips; pub mod ocrmypdf; pub mod qpdf; pub mod trait_def; +pub use ffmpeg::FfmpegTool; pub use libvips::LibvipsTool; pub use trait_def::{Tool, ToolConfig, ToolInfo}; diff --git a/crates/core/src/utils/audio.rs b/crates/core/src/utils/audio.rs new file mode 100644 index 0000000..3d2f4bf --- /dev/null +++ b/crates/core/src/utils/audio.rs @@ -0,0 +1,219 @@ +//! # Audio Format Utilities +//! +//! Types and helpers for audio format detection and conversion. + +use std::path::Path; +use std::str::FromStr; + +/// Supported audio formats for conversion +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AudioFormat { + Mp3, + Aac, + Opus, + Flac, + Wav, + Ogg, + M4a, +} + +impl AudioFormat { + /// Detect format from file extension + pub fn from_extension(ext: &str) -> Option { + match ext.to_lowercase().as_str() { + "mp3" => Some(Self::Mp3), + "aac" => Some(Self::Aac), + "opus" => Some(Self::Opus), + "flac" => Some(Self::Flac), + "wav" => Some(Self::Wav), + "ogg" | "oga" => Some(Self::Ogg), + "m4a" => Some(Self::M4a), + _ => None, + } + } + + /// Get canonical file extension + pub fn extension(&self) -> &'static str { + match self { + Self::Mp3 => "mp3", + Self::Aac => "aac", + Self::Opus => "opus", + Self::Flac => "flac", + Self::Wav => "wav", + Self::Ogg => "ogg", + Self::M4a => "m4a", + } + } + + /// Detect format from file path + pub fn from_path(path: &Path) -> Option { + path.extension() + .and_then(|ext| ext.to_str()) + .and_then(Self::from_extension) + } + + /// Get ffmpeg codec name for this format + pub fn ffmpeg_codec(&self) -> &'static str { + match self { + Self::Mp3 => "libmp3lame", + Self::Aac => "aac", + Self::Opus => "libopus", + Self::Flac => "flac", + Self::Wav => "pcm_s16le", + Self::Ogg => "libvorbis", + Self::M4a => "aac", + } + } + + /// Get all supported format extensions as a comma-separated string + pub fn supported_extensions() -> &'static str { + "mp3, aac, opus, flac, wav, ogg, m4a" + } + + /// Check if this format supports bitrate setting + pub fn supports_bitrate(&self) -> bool { + matches!( + self, + Self::Mp3 | Self::Aac | Self::Opus | Self::Ogg | Self::M4a + ) + } +} + +impl FromStr for AudioFormat { + type Err = String; + + fn from_str(s: &str) -> Result { + Self::from_extension(s).ok_or_else(|| { + format!( + "Unknown audio format: '{}'. Supported: {}", + s, + Self::supported_extensions() + ) + }) + } +} + +impl std::fmt::Display for AudioFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.extension()) + } +} + +/// Loudness normalization target +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum LoudnessTarget { + /// EBU R128 broadcast standard (-23 LUFS) + EbuR128, + /// Streaming standard (-14 LUFS) + Streaming, + /// Custom LUFS target + Custom(f32), +} + +impl LoudnessTarget { + /// Get the target integrated loudness in LUFS + pub fn lufs(&self) -> f32 { + match self { + Self::EbuR128 => -23.0, + Self::Streaming => -14.0, + Self::Custom(lufs) => *lufs, + } + } + + /// Get the true peak limit in dBTP + pub fn true_peak(&self) -> f32 { + match self { + Self::EbuR128 => -1.0, + Self::Streaming => -1.0, + Self::Custom(_) => -1.0, + } + } +} + +impl std::fmt::Display for LoudnessTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EbuR128 => write!(f, "EBU R128 (-23 LUFS)"), + Self::Streaming => write!(f, "Streaming (-14 LUFS)"), + Self::Custom(lufs) => write!(f, "{} LUFS", lufs), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_from_extension() { + assert_eq!(AudioFormat::from_extension("mp3"), Some(AudioFormat::Mp3)); + assert_eq!(AudioFormat::from_extension("MP3"), Some(AudioFormat::Mp3)); + assert_eq!(AudioFormat::from_extension("aac"), Some(AudioFormat::Aac)); + assert_eq!(AudioFormat::from_extension("opus"), Some(AudioFormat::Opus)); + assert_eq!(AudioFormat::from_extension("flac"), Some(AudioFormat::Flac)); + assert_eq!(AudioFormat::from_extension("wav"), Some(AudioFormat::Wav)); + assert_eq!(AudioFormat::from_extension("ogg"), Some(AudioFormat::Ogg)); + assert_eq!(AudioFormat::from_extension("oga"), Some(AudioFormat::Ogg)); + assert_eq!(AudioFormat::from_extension("m4a"), Some(AudioFormat::M4a)); + assert_eq!(AudioFormat::from_extension("xyz"), None); + } + + #[test] + fn test_format_extension() { + assert_eq!(AudioFormat::Mp3.extension(), "mp3"); + assert_eq!(AudioFormat::Opus.extension(), "opus"); + assert_eq!(AudioFormat::Flac.extension(), "flac"); + } + + #[test] + fn test_format_from_path() { + assert_eq!( + AudioFormat::from_path(Path::new("song.mp3")), + Some(AudioFormat::Mp3) + ); + assert_eq!( + AudioFormat::from_path(Path::new("/path/to/audio.opus")), + Some(AudioFormat::Opus) + ); + assert_eq!(AudioFormat::from_path(Path::new("file.unknown")), None); + } + + #[test] + fn test_format_from_str() { + assert_eq!("mp3".parse::().unwrap(), AudioFormat::Mp3); + assert_eq!("opus".parse::().unwrap(), AudioFormat::Opus); + assert!("xyz".parse::().is_err()); + } + + #[test] + fn test_ffmpeg_codec() { + assert_eq!(AudioFormat::Mp3.ffmpeg_codec(), "libmp3lame"); + assert_eq!(AudioFormat::Opus.ffmpeg_codec(), "libopus"); + assert_eq!(AudioFormat::Flac.ffmpeg_codec(), "flac"); + } + + #[test] + fn test_supports_bitrate() { + assert!(AudioFormat::Mp3.supports_bitrate()); + assert!(AudioFormat::Opus.supports_bitrate()); + assert!(!AudioFormat::Flac.supports_bitrate()); + assert!(!AudioFormat::Wav.supports_bitrate()); + } + + #[test] + fn test_loudness_target_lufs() { + assert_eq!(LoudnessTarget::EbuR128.lufs(), -23.0); + assert_eq!(LoudnessTarget::Streaming.lufs(), -14.0); + assert_eq!(LoudnessTarget::Custom(-16.0).lufs(), -16.0); + } + + #[test] + fn test_loudness_target_display() { + assert_eq!(LoudnessTarget::EbuR128.to_string(), "EBU R128 (-23 LUFS)"); + assert_eq!( + LoudnessTarget::Streaming.to_string(), + "Streaming (-14 LUFS)" + ); + assert_eq!(LoudnessTarget::Custom(-16.0).to_string(), "-16 LUFS"); + } +} diff --git a/crates/core/src/utils/mod.rs b/crates/core/src/utils/mod.rs index 9a06d37..aaafac0 100644 --- a/crates/core/src/utils/mod.rs +++ b/crates/core/src/utils/mod.rs @@ -1,3 +1,4 @@ +pub mod audio; pub mod error; pub mod image; pub mod pages;