diff --git a/Cargo.lock b/Cargo.lock index fa7bd79..0eee29e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,7 +124,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "forgekit" -version = "0.0.7" +version = "0.0.8" dependencies = [ "anyhow", "clap", @@ -135,7 +135,7 @@ dependencies = [ [[package]] name = "forgekit-core" -version = "0.0.7" +version = "0.0.8" dependencies = [ "anyhow", "serde", diff --git a/Cargo.toml b/Cargo.toml index 3879dcb..bd3d035 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/core", "crates/cli"] resolver = "2" [workspace.package] -version = "0.0.7" +version = "0.0.8" edition = "2021" authors = ["ForgeKit Contributors"] license = "MIT" diff --git a/ROADMAP.md b/ROADMAP.md index 4bc755b..f86fca4 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, Audio operations complete -**Current Version**: v0.0.7 +**Status**: Core foundation complete, PDF operations complete, Image operations complete, Audio operations complete, Video operations complete +**Current Version**: v0.0.8 ## Versioning Strategy @@ -123,20 +123,19 @@ - ✅ Supported formats: MP3, AAC, Opus, FLAC, WAV, OGG, M4A - ✅ Tests for audio operations (12 new tests) -#### v0.0.8 - Video Operations 📋 +#### v0.0.8 - Video Operations ✅ -**Status**: Pending +**Status**: Completed **Deliverables**: -- Media transcode command (`media transcode`) with preset support -- ffmpeg video adapter (H.264 only, software x264) -- Video preset (H.264 1080p with CRF 23) -- Progress parsing from ffmpeg stderr (time= and Duration=) -- CRF-based quality control (0-51 range) -- Scale filter with aspect ratio preservation -- Tests for video operations (duration, codec, resolution) -- Golden tests for video transcoding +- ✅ Media transcode command (`media transcode`) with preset support +- ✅ ffmpeg video adapter (H.264 only, software x264) +- ✅ CRF-based quality control (0-51 range, default 23) +- ✅ Encoder preset support (ultrafast to veryslow) +- ✅ Scale filter with aspect ratio preservation +- ✅ Audio copy or re-encode option +- ✅ Tests for video operations (7 new tests) #### v0.0.9 - Package Creation and CI/CD 📋 @@ -218,22 +217,22 @@ - **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) +- **Audio Operations**: Convert, normalize, extract, trim, join, volume, mono, info (via ffmpeg) +- **Video Operations**: Transcode (H.264, CRF, presets, scale) (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.7 (completed) +**Current Version**: v0.0.8 (completed) ### 🔄 In Progress -- **v0.0.8**: Video Operations +- **v0.0.9**: Package Creation and CI/CD ### 📋 Pending Features -- Media: transcode - Preset system (YAML) ✅ - Package creation (deb, rpm, Homebrew, winget) - CI/CD setup with package building diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index d2d6592..0ef785d 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -2,3 +2,4 @@ pub mod audio; pub mod check; pub mod image; pub mod pdf; +pub mod video; diff --git a/crates/cli/src/commands/video.rs b/crates/cli/src/commands/video.rs new file mode 100644 index 0000000..74653f5 --- /dev/null +++ b/crates/cli/src/commands/video.rs @@ -0,0 +1,894 @@ +use clap::{Args, Subcommand}; +use forgekit_core::job::executor::execute_job; +use forgekit_core::job::JobSpec; +use forgekit_core::utils::error::Result; +use std::path::{Path, PathBuf}; + +#[derive(Subcommand, Clone)] +pub enum VideoCommand { + /// Transcode video to H.264 format + /// + /// Examples: + /// forgekit video transcode video.mov --output video.mp4 + /// forgekit video transcode video.mkv --output video.mp4 --crf 20 --preset slow + /// forgekit video transcode video.mp4 --output 720p.mp4 --scale 1280 + /// + /// CRF (Constant Rate Factor): 0-51, lower = better quality, larger file + /// 0 = Lossless + /// 18-23 = Visually lossless to good quality (default: 23) + /// 24-28 = Medium quality, good compression + /// 29+ = Low quality + /// + /// Presets: ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow + /// Slower presets = better compression, smaller file size for same quality + Transcode(TranscodeArgs), + + /// Trim video to a specific time range + /// + /// Examples: + /// forgekit video trim video.mp4 --start 0:30 --end 2:00 --output clip.mp4 + /// forgekit video trim video.mp4 --start 30 --end 120 --output clip.mp4 + /// forgekit video trim movie.mp4 --start 5:00 --output from_5min.mp4 + /// + /// Time format: seconds (30, 90.5) or MM:SS (1:30) or HH:MM:SS (1:30:00) + /// Uses stream copy (no re-encoding) for fast trimming. + Trim(TrimArgs), + + /// Join multiple video files into one + /// + /// Examples: + /// forgekit video join intro.mp4 main.mp4 outro.mp4 --output final.mp4 + /// forgekit video join "part_*.mp4" --output full.mp4 + /// forgekit video join "clip_[0-9][0-9].mp4" --output movie.mp4 + /// + /// Supports glob patterns. Files are sorted naturally (part_2 before part_10). + /// All input files must have the same codec, resolution, and frame rate. + Join(JoinArgs), + + /// Show video file information + /// + /// Examples: + /// forgekit video info video.mp4 + /// forgekit video info video.mp4 --json + /// + /// Shows duration, resolution, codec, bitrate, and frame rate. + Info(InfoArgs), + + /// Extract a thumbnail frame from video + /// + /// Examples: + /// forgekit video thumbnail video.mp4 --time 5 --output thumb.jpg + /// forgekit video thumbnail video.mp4 --time 1:30 --output preview.png + /// + /// Extracts a single frame at the specified timestamp. + /// Output format determined by extension (.jpg, .png). + Thumbnail(ThumbnailArgs), + + /// Convert video to a different format + /// + /// Examples: + /// forgekit video convert video.mp4 -t gif --output clip.gif + /// forgekit video convert video.mp4 -t gif --start 10 --duration 5 --output clip.gif + /// forgekit video convert video.mp4 -t webm --output video.webm + /// forgekit video convert video.mkv -t mp4 --output video.mp4 + /// + /// Supported formats: gif, webm, mp4, mov, avi, mkv + /// GIF options: --start, --duration, --width, --fps + Convert(ConvertArgs), + + /// Change video playback speed + /// + /// Examples: + /// forgekit video speed video.mp4 --speed 2 --output fast.mp4 + /// forgekit video speed video.mp4 --speed 0.5 --output slow.mp4 + /// + /// Speed multiplier: 2 = double speed, 0.5 = half speed. + /// Audio is adjusted to match video speed. + Speed(SpeedArgs), + + /// Rotate video by specified degrees + /// + /// Examples: + /// forgekit video rotate video.mp4 --degrees 90 --output rotated.mp4 + /// forgekit video rotate video.mp4 --degrees 180 --output flipped.mp4 + /// + /// Supports 90, 180, or 270 degrees clockwise rotation. + Rotate(RotateArgs), + + /// Remove audio track from video + /// + /// Examples: + /// forgekit video mute video.mp4 --output silent.mp4 + /// + /// Creates a video file with no audio stream. + Mute(MuteArgs), + + /// Stitch image sequence into video or GIF + /// + /// Examples: + /// forgekit video stitch "frame_*.png" -t mp4 --fps 24 --output animation.mp4 + /// forgekit video stitch "render_*.exr" -t mp4 --fps 30 --output video.mp4 + /// forgekit video stitch "img_*.jpg" -t gif --fps 10 --output animation.gif + /// + /// Supports glob patterns. Files are sorted naturally (frame_2 before frame_10). + /// Useful for Blender renders, timelapses, and stop-motion. + Stitch(StitchArgs), +} + +#[derive(Args, Clone)] +pub struct TranscodeArgs { + /// Input video file + #[arg(required = true, help = "Input video file")] + pub input: PathBuf, + + /// Output video file + #[arg( + short, + long, + required = true, + help = "Output video file (must be .mp4)" + )] + pub output: PathBuf, + + /// CRF quality (0-51, lower = better quality). Default: 23 + #[arg( + short, + long, + default_value = "23", + help = "CRF quality (0-51, default: 23)" + )] + pub crf: u8, + + /// Encoder preset (ultrafast to veryslow). Default: medium + #[arg( + short, + long, + default_value = "medium", + help = "Preset: ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow" + )] + pub preset: String, + + /// Scale video width (height auto-calculated to preserve aspect ratio) + #[arg( + short, + long, + help = "Scale to width (height auto-calculated to preserve aspect ratio)" + )] + pub scale: Option, + + /// Re-encode audio to AAC 128kbps (default: copy audio stream) + #[arg(long, default_value = "false", help = "Re-encode audio to AAC 128kbps")] + pub reencode_audio: bool, +} + +#[derive(Args, Clone)] +pub struct TrimArgs { + /// Input video file + #[arg(required = true, help = "Input video file")] + pub input: PathBuf, + + /// Output video file + #[arg(short, long, required = true, help = "Output video 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 video files or glob pattern (e.g., "part_*.mp4") + #[arg(required = true, num_args = 1.., help = "Input files or glob pattern (e.g., part_*.mp4)")] + pub inputs: Vec, + + /// Output video file + #[arg(short, long, required = true, help = "Output video file")] + pub output: PathBuf, +} + +#[derive(Args, Clone)] +pub struct InfoArgs { + /// Input video file + #[arg(required = true, help = "Input video file")] + pub input: PathBuf, +} + +#[derive(Args, Clone)] +pub struct ThumbnailArgs { + /// Input video file + #[arg(required = true, help = "Input video file")] + pub input: PathBuf, + + /// Output image file (.jpg or .png) + #[arg( + short, + long, + required = true, + help = "Output image file (.jpg or .png)" + )] + pub output: PathBuf, + + /// Timestamp to extract frame (seconds or MM:SS or HH:MM:SS) + #[arg( + short, + long, + required = true, + help = "Time to extract frame (e.g., 5, 1:30)" + )] + pub time: String, +} + +#[derive(Args, Clone)] +pub struct ConvertArgs { + /// Input video file + #[arg(required = true, help = "Input video file")] + pub input: PathBuf, + + /// Output video file + #[arg(short, long, required = true, help = "Output file")] + pub output: PathBuf, + + /// Target format (gif, webm, mp4, mov, avi, mkv) + #[arg( + short = 't', + long = "to", + required = true, + help = "Target format (gif, webm, mp4, mov, avi)" + )] + pub format: String, + + /// Start time - for GIF only (seconds or MM:SS or HH:MM:SS) + #[arg(short, long, help = "Start time for GIF (e.g., 10, 0:30)")] + pub start: Option, + + /// Duration in seconds - for GIF only + #[arg(short, long, help = "Duration for GIF in seconds")] + pub duration: Option, + + /// Output width in pixels - for GIF only (height auto-calculated) + #[arg(short, long, help = "Width for GIF (height auto-calculated)")] + pub width: Option, + + /// Frame rate - for GIF only (default: 10) + #[arg(short, long, help = "Frame rate for GIF (default: 10)")] + pub fps: Option, +} + +#[derive(Args, Clone)] +pub struct SpeedArgs { + /// Input video file + #[arg(required = true, help = "Input video file")] + pub input: PathBuf, + + /// Output video file + #[arg(short, long, required = true, help = "Output video file")] + pub output: PathBuf, + + /// Speed multiplier (e.g., 2 for 2x speed, 0.5 for half speed) + #[arg( + short = 'x', + long, + required = true, + help = "Speed multiplier (e.g., 2, 0.5)" + )] + pub speed: f64, +} + +#[derive(Args, Clone)] +pub struct RotateArgs { + /// Input video file + #[arg(required = true, help = "Input video file")] + pub input: PathBuf, + + /// Output video file + #[arg(short, long, required = true, help = "Output video file")] + pub output: PathBuf, + + /// Rotation angle: 90, 180, or 270 degrees clockwise + #[arg( + short, + long, + required = true, + help = "Rotation: 90, 180, or 270 degrees" + )] + pub degrees: u32, +} + +#[derive(Args, Clone)] +pub struct MuteArgs { + /// Input video file + #[arg(required = true, help = "Input video file")] + pub input: PathBuf, + + /// Output video file (no audio) + #[arg(short, long, required = true, help = "Output video file (no audio)")] + pub output: PathBuf, +} + +#[derive(Args, Clone)] +pub struct StitchArgs { + /// Input images (glob pattern like "frame_*.png") + #[arg(required = true, num_args = 1.., help = "Input images or glob pattern")] + pub inputs: Vec, + + /// Output video or GIF file + #[arg(short, long, required = true, help = "Output file")] + pub output: PathBuf, + + /// Target format (mp4, gif, webm, mov) + #[arg( + short = 't', + long = "to", + required = true, + help = "Target format (mp4, gif, webm)" + )] + pub format: String, + + /// Frame rate + #[arg(short, long, required = true, help = "Frame rate (e.g., 24, 30, 60)")] + pub fps: u32, + + /// Output width - for GIF only (height auto-calculated) + #[arg(short, long, help = "Width for GIF (height auto-calculated)")] + pub width: Option, +} + +pub fn handle_video_command(cmd: &VideoCommand, plan_only: bool, json_output: bool) -> Result<()> { + match cmd { + VideoCommand::Transcode(args) => handle_transcode(args, plan_only), + VideoCommand::Trim(args) => handle_trim(args, plan_only), + VideoCommand::Join(args) => handle_join(args, plan_only), + VideoCommand::Info(args) => handle_info(args, json_output), + VideoCommand::Thumbnail(args) => handle_thumbnail(args, plan_only), + VideoCommand::Convert(args) => handle_convert(args, plan_only), + VideoCommand::Speed(args) => handle_speed(args, plan_only), + VideoCommand::Rotate(args) => handle_rotate(args, plan_only), + VideoCommand::Mute(args) => handle_mute(args, plan_only), + VideoCommand::Stitch(args) => handle_stitch(args, plan_only), + } +} + +fn handle_transcode(args: &TranscodeArgs, plan_only: bool) -> Result<()> { + // Validate CRF range + if args.crf > 51 { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!("CRF must be 0-51, got {}", args.crf), + }); + } + + // Validate preset + let valid_presets = [ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + "medium", + "slow", + "slower", + "veryslow", + ]; + if !valid_presets.contains(&args.preset.as_str()) { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!( + "Invalid preset '{}'. Valid presets: {}", + args.preset, + valid_presets.join(", ") + ), + }); + } + + // Convert scale width to (width, -1) tuple for aspect ratio preservation + let scale = args.scale.map(|w| (w, -1)); + + let spec = JobSpec::VideoTranscode { + input: args.input.clone(), + output: args.output.clone(), + crf: args.crf, + preset: args.preset.clone(), + scale, + copy_audio: !args.reencode_audio, + }; + + 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::VideoTrim { + 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::VideoJoin { + 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, Clone)] +enum NaturalSortPart { + Num(u64), + Str(String), +} + +/// 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 + ), + }), + } +} + +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_000 { + format!("{:.1} GB", file_size as f64 / 1_000_000_000.0) + } else 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 video info + let (duration, width, height, codec, fps, bitrate) = if let Ok(output) = Command::new("ffprobe") + .args([ + "-v", + "quiet", + "-show_entries", + "format=duration,bit_rate:stream=width,height,codec_name,r_frame_rate", + "-select_streams", + "v:0", + "-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): width,height,codec_name,r_frame_rate + let (width, height, codec, fps) = if let Some(line) = lines.first() { + let parts: Vec<&str> = line.split(',').collect(); + let w = parts + .first() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let h = parts + .get(1) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let c = parts.get(2).unwrap_or(&"?").to_string(); + let fps_str = parts.get(3).unwrap_or(&"0/1"); + // Parse frame rate (e.g., "30/1" or "30000/1001") + let fps = if let Some((num, den)) = fps_str.split_once('/') { + let n: f64 = num.parse().unwrap_or(0.0); + let d: f64 = den.parse().unwrap_or(1.0); + if d > 0.0 { + n / d + } else { + 0.0 + } + } else { + fps_str.parse().unwrap_or(0.0) + }; + (w, h, c, fps) + } else { + (0, 0, "?".to_string(), 0.0) + }; + + // Parse format info (second line): duration,bit_rate + let (duration, bitrate) = if let Some(line) = lines.get(1) { + let parts: Vec<&str> = line.split(',').collect(); + let dur = parts + .first() + .and_then(|s| s.parse::().ok()) + .map(format_duration) + .unwrap_or_else(|| "?".to_string()); + let br = parts + .get(1) + .and_then(|s| s.parse::().ok()) + .map(|b| format!("{:.1} Mbps", b as f64 / 1_000_000.0)) + .unwrap_or_else(|| "?".to_string()); + (dur, br) + } else { + ("?".to_string(), "?".to_string()) + }; + + (duration, width, height, codec, fps, bitrate) + } else { + ("?".to_string(), 0, 0, "?".to_string(), 0.0, "?".to_string()) + } + } else { + ("?".to_string(), 0, 0, "?".to_string(), 0.0, "?".to_string()) + }; + + let resolution = if width > 0 && height > 0 { + format!("{}x{}", width, height) + } else { + "?".to_string() + }; + + let fps_str = if fps > 0.0 { + format!("{:.2}", fps) + } else { + "?".to_string() + }; + + if json_output { + let info = serde_json::json!({ + "file": args.input.display().to_string(), + "duration": duration, + "resolution": resolution, + "width": width, + "height": height, + "codec": codec, + "fps": fps, + "bitrate": bitrate, + "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!("Resolution: {}", resolution); + println!("Codec: {}", codec); + println!("Frame Rate: {} fps", fps_str); + println!("Bitrate: {}", bitrate); + 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) + } +} + +fn handle_thumbnail(args: &ThumbnailArgs, plan_only: bool) -> Result<()> { + let timestamp = parse_time(&args.time)?; + + let spec = JobSpec::VideoThumbnail { + input: args.input.clone(), + output: args.output.clone(), + timestamp, + }; + + let result = execute_job(&spec, plan_only)?; + println!("{}", result); + Ok(()) +} + +fn handle_convert(args: &ConvertArgs, plan_only: bool) -> Result<()> { + let start = args.start.as_ref().map(|s| parse_time(s)).transpose()?; + + // Validate format + let format = args.format.to_lowercase(); + let valid_formats = ["gif", "webm", "mp4", "mov", "avi", "mkv"]; + if !valid_formats.contains(&format.as_str()) { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!( + "Invalid format '{}'. Supported: {}", + args.format, + valid_formats.join(", ") + ), + }); + } + + let spec = JobSpec::VideoConvert { + input: args.input.clone(), + output: args.output.clone(), + format, + start, + duration: args.duration, + width: args.width, + fps: args.fps, + }; + + let result = execute_job(&spec, plan_only)?; + println!("{}", result); + Ok(()) +} + +fn handle_speed(args: &SpeedArgs, plan_only: bool) -> Result<()> { + if args.speed <= 0.0 { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "Speed must be greater than 0".to_string(), + }); + } + + let spec = JobSpec::VideoSpeed { + input: args.input.clone(), + output: args.output.clone(), + speed: args.speed, + }; + + let result = execute_job(&spec, plan_only)?; + println!("{}", result); + Ok(()) +} + +fn handle_rotate(args: &RotateArgs, plan_only: bool) -> Result<()> { + if args.degrees != 90 && args.degrees != 180 && args.degrees != 270 { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!( + "Invalid rotation angle {}. Use 90, 180, or 270.", + args.degrees + ), + }); + } + + let spec = JobSpec::VideoRotate { + input: args.input.clone(), + output: args.output.clone(), + degrees: args.degrees, + }; + + let result = execute_job(&spec, plan_only)?; + println!("{}", result); + Ok(()) +} + +fn handle_mute(args: &MuteArgs, plan_only: bool) -> Result<()> { + let spec = JobSpec::VideoMute { + input: args.input.clone(), + output: args.output.clone(), + }; + + let result = execute_job(&spec, plan_only)?; + println!("{}", result); + Ok(()) +} + +fn handle_stitch(args: &StitchArgs, 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 frame_2 comes before frame_10) + files.sort_by_key(|a| natural_sort_key(a)); + + if files.is_empty() { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "No matching files found".to_string(), + }); + } + + // Validate format + let format = args.format.to_lowercase(); + let valid_formats = ["gif", "mp4", "webm", "mov", "avi", "mkv"]; + if !valid_formats.contains(&format.as_str()) { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!( + "Invalid format '{}'. Supported: {}", + args.format, + valid_formats.join(", ") + ), + }); + } + + let spec = JobSpec::VideoStitch { + inputs: files, + output: args.output.clone(), + format, + fps: args.fps, + width: args.width, + }; + + let result = execute_job(&spec, plan_only)?; + println!("{}", result); + Ok(()) +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 1a9bdaf..c9cf384 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -30,6 +30,7 @@ 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}; +use commands::video::{handle_video_command, VideoCommand}; use forgekit_core::utils::error::{ExitCode, ForgeKitError}; /// Main CLI structure. @@ -68,6 +69,10 @@ enum Commands { #[command(subcommand)] Audio(AudioCommand), + /// Video operations (convert, transcode, trim, thumbnail, etc.) + #[command(subcommand)] + Video(VideoCommand), + /// Check if required dependencies are installed CheckDeps, } @@ -81,6 +86,7 @@ fn main() { 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::Video(ref cmd)) => handle_video_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 4e665d1..7e75558 100644 --- a/crates/core/src/job/executor.rs +++ b/crates/core/src/job/executor.rs @@ -179,6 +179,55 @@ pub fn execute_job_with_progress( gain_db, } => execute_audio_volume(input, output, *gain_db, plan_only), JobSpec::AudioMono { input, output } => execute_audio_mono(input, output, plan_only), + JobSpec::VideoTranscode { + input, + output, + crf, + preset, + scale, + copy_audio, + } => execute_video_transcode(input, output, *crf, preset, *scale, *copy_audio, plan_only), + JobSpec::VideoTrim { + input, + output, + start, + end, + } => execute_video_trim(input, output, *start, *end, plan_only), + JobSpec::VideoJoin { inputs, output } => execute_video_join(inputs, output, plan_only), + JobSpec::VideoThumbnail { + input, + output, + timestamp, + } => execute_video_thumbnail(input, output, *timestamp, plan_only), + JobSpec::VideoConvert { + input, + output, + format, + start, + duration, + width, + fps, + } => execute_video_convert( + input, output, format, *start, *duration, *width, *fps, plan_only, + ), + JobSpec::VideoSpeed { + input, + output, + speed, + } => execute_video_speed(input, output, *speed, plan_only), + JobSpec::VideoRotate { + input, + output, + degrees, + } => execute_video_rotate(input, output, *degrees, plan_only), + JobSpec::VideoMute { input, output } => execute_video_mute(input, output, plan_only), + JobSpec::VideoStitch { + inputs, + output, + format, + fps, + width, + } => execute_video_stitch(inputs, output, format, *fps, *width, plan_only), } } @@ -1450,6 +1499,325 @@ fn execute_audio_mono(input: &Path, output: &Path, plan_only: bool) -> Result, + copy_audio: bool, + plan_only: bool, +) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_transcode( + input, output, crf, preset, scale, copy_audio, + )); + } + + 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.transcode( + &tool_info.path, + input, + output, + crf, + preset, + scale, + copy_audio, + )?; + + let scale_str = scale + .map(|(w, h)| { + if h == -1 { + format!(" scaled to {}p", w) + } else { + format!(" scaled to {}x{}", w, h) + } + }) + .unwrap_or_default(); + + Ok(format!( + "Successfully transcoded video to {} (H.264, CRF {}){}", + output.display(), + crf, + scale_str + )) +} + +fn execute_video_trim( + input: &Path, + output: &Path, + start: Option, + end: Option, + plan_only: bool, +) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_video_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.video_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 to end", s), + (None, Some(e)) => format!(" from start to {:.1}s", e), + (None, None) => String::new(), + }; + + Ok(format!("Successfully trimmed video{}", time_str)) +} + +fn execute_video_join(inputs: &[PathBuf], output: &Path, plan_only: bool) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_video_join(inputs, output)); + } + + if inputs.len() < 2 { + return Err(ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!("At least 2 files required for join, got {}", inputs.len()), + }); + } + + for input in inputs { + if !input.exists() { + return Err(ForgeKitError::InvalidInput { + path: input.clone(), + reason: "Input file does not exist".to_string(), + }); + } + } + + let tool_info = probe_ffmpeg()?; + let tool = FfmpegTool; + tool.video_join(&tool_info.path, inputs, output)?; + + Ok(format!( + "Successfully joined {} videos into {}", + inputs.len(), + output.display() + )) +} + +fn execute_video_thumbnail( + input: &Path, + output: &Path, + timestamp: f64, + plan_only: bool, +) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_video_thumbnail(input, output, timestamp)); + } + + 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.video_thumbnail(&tool_info.path, input, output, timestamp)?; + + Ok(format!( + "Successfully extracted thumbnail at {:.1}s to {}", + timestamp, + output.display() + )) +} + +#[allow(clippy::too_many_arguments)] +fn execute_video_convert( + input: &Path, + output: &Path, + format: &str, + start: Option, + duration: Option, + width: Option, + fps: Option, + plan_only: bool, +) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_video_convert( + input, output, format, start, duration, width, fps, + )); + } + + 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.video_convert( + &tool_info.path, + input, + output, + format, + start, + duration, + width, + fps, + )?; + + Ok(format!( + "Successfully converted video to {}: {}", + format.to_uppercase(), + output.display() + )) +} + +fn execute_video_speed(input: &Path, output: &Path, speed: f64, plan_only: bool) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_video_speed(input, output, speed)); + } + + if !input.exists() { + return Err(ForgeKitError::InvalidInput { + path: input.to_path_buf(), + reason: "Input file does not exist".to_string(), + }); + } + + if speed <= 0.0 { + return Err(ForgeKitError::InvalidInput { + path: input.to_path_buf(), + reason: "Speed must be greater than 0".to_string(), + }); + } + + let tool_info = probe_ffmpeg()?; + let tool = FfmpegTool; + tool.video_speed(&tool_info.path, input, output, speed)?; + + Ok(format!( + "Successfully changed video speed to {:.1}x: {}", + speed, + output.display() + )) +} + +fn execute_video_rotate( + input: &Path, + output: &Path, + degrees: u32, + plan_only: bool, +) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_video_rotate(input, output, degrees)); + } + + if !input.exists() { + return Err(ForgeKitError::InvalidInput { + path: input.to_path_buf(), + reason: "Input file does not exist".to_string(), + }); + } + + if degrees != 90 && degrees != 180 && degrees != 270 { + return Err(ForgeKitError::InvalidInput { + path: input.to_path_buf(), + reason: format!("Invalid rotation angle {}. Use 90, 180, or 270.", degrees), + }); + } + + let tool_info = probe_ffmpeg()?; + let tool = FfmpegTool; + tool.video_rotate(&tool_info.path, input, output, degrees)?; + + Ok(format!( + "Successfully rotated video {}°: {}", + degrees, + output.display() + )) +} + +fn execute_video_mute(input: &Path, output: &Path, plan_only: bool) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_video_mute(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.video_mute(&tool_info.path, input, output)?; + + Ok(format!( + "Successfully removed audio from video: {}", + output.display() + )) +} + +fn execute_video_stitch( + inputs: &[PathBuf], + output: &Path, + format: &str, + fps: u32, + width: Option, + plan_only: bool, +) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_video_stitch( + inputs, output, format, fps, width, + )); + } + + if inputs.is_empty() { + return Err(ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "No input images provided".to_string(), + }); + } + + for input in inputs { + if !input.exists() { + return Err(ForgeKitError::InvalidInput { + path: input.clone(), + reason: "Input file does not exist".to_string(), + }); + } + } + + let tool_info = probe_ffmpeg()?; + let tool = FfmpegTool; + tool.video_stitch(&tool_info.path, inputs, output, format, fps, width)?; + + Ok(format!( + "Successfully stitched {} images into {}: {}", + inputs.len(), + format.to_uppercase(), + output.display() + )) +} + #[cfg(test)] mod tests { use super::*; @@ -2149,3 +2517,65 @@ mod audio_operation_tests { assert!(result.contains("-ac 1")); } } + +#[cfg(test)] +mod video_operation_tests { + use super::*; + + #[test] + fn test_execute_video_transcode_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("output.mp4"); + + let result = + execute_video_transcode(&input, &output, 23, "medium", None, true, true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-i video.mp4")); + assert!(result.contains("-c:v libx264")); + assert!(result.contains("-crf 23")); + assert!(result.contains("-preset medium")); + assert!(result.contains("-c:a copy")); + } + + #[test] + fn test_execute_video_transcode_with_scale_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("output.mp4"); + + let result = + execute_video_transcode(&input, &output, 23, "fast", Some((1920, 1080)), true, true) + .unwrap(); + + assert!(result.contains("-vf scale=1920:1080")); + } + + #[test] + fn test_execute_video_transcode_width_only_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("output.mp4"); + + // -1 height = preserve aspect ratio + let result = + execute_video_transcode(&input, &output, 20, "slow", Some((1280, -1)), false, true) + .unwrap(); + + assert!(result.contains("-vf scale=1280:-2")); // -2 ensures divisible by 2 + assert!(result.contains("-c:a aac")); + assert!(result.contains("-b:a 128k")); + } + + #[test] + fn test_execute_video_transcode_reencode_audio_plan() { + let input = PathBuf::from("video.mkv"); + let output = PathBuf::from("output.mp4"); + + let result = + execute_video_transcode(&input, &output, 18, "fast", None, false, true).unwrap(); + + assert!(result.contains("-c:v libx264")); + assert!(result.contains("-crf 18")); + assert!(result.contains("-c:a aac")); + assert!(result.contains("-b:a 128k")); + } +} diff --git a/crates/core/src/job/spec.rs b/crates/core/src/job/spec.rs index e7b8a76..1e6d0aa 100644 --- a/crates/core/src/job/spec.rs +++ b/crates/core/src/job/spec.rs @@ -277,6 +277,119 @@ pub enum JobSpec { /// Output audio file. output: PathBuf, }, + + // ========== Video Operations ========== + /// Transcode video to H.264 format. + /// + /// Uses ffmpeg with software x264 encoder. CRF controls quality (0-51, lower is better). + VideoTranscode { + /// Input video file. + input: PathBuf, + /// Output video file. + output: PathBuf, + /// CRF quality (0-51, default 23). Lower = better quality, larger file. + crf: u8, + /// Encoder preset (ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow). + preset: String, + /// Optional scale (width, height). Height of -1 preserves aspect ratio. + scale: Option<(i32, i32)>, + /// Copy audio stream instead of re-encoding. + copy_audio: bool, + }, + + /// Trim video to a specific time range. + /// + /// Uses ffmpeg with stream copy for fast trimming. + VideoTrim { + /// Input video file. + input: PathBuf, + /// Output video file. + output: PathBuf, + /// Start time in seconds (optional). + start: Option, + /// End time in seconds (optional). + end: Option, + }, + + /// Join multiple video files into one. + /// + /// Uses ffmpeg concat demuxer. All inputs must have same codec/resolution. + VideoJoin { + /// Input video files. + inputs: Vec, + /// Output video file. + output: PathBuf, + }, + + /// Extract a thumbnail frame from video at a specific timestamp. + VideoThumbnail { + /// Input video file. + input: PathBuf, + /// Output image file (jpg, png). + output: PathBuf, + /// Timestamp in seconds to extract frame. + timestamp: f64, + }, + + /// Convert video to a different format/container. + VideoConvert { + /// Input video file. + input: PathBuf, + /// Output video file. + output: PathBuf, + /// Target format (gif, webm, mov, avi, etc.). + format: String, + /// Start time in seconds (optional, for gif). + start: Option, + /// Duration in seconds (optional, for gif). + duration: Option, + /// Output width (optional, for gif). + width: Option, + /// Frame rate (optional, for gif). + fps: Option, + }, + + /// Change video playback speed. + VideoSpeed { + /// Input video file. + input: PathBuf, + /// Output video file. + output: PathBuf, + /// Speed multiplier (0.5 = half speed, 2.0 = double speed). + speed: f64, + }, + + /// Rotate video by specified degrees. + VideoRotate { + /// Input video file. + input: PathBuf, + /// Output video file. + output: PathBuf, + /// Rotation angle: 90, 180, or 270 degrees clockwise. + degrees: u32, + }, + + /// Remove audio track from video. + VideoMute { + /// Input video file. + input: PathBuf, + /// Output video file (no audio). + output: PathBuf, + }, + + /// Stitch image sequence into video or GIF. + VideoStitch { + /// Input image files (sorted). + inputs: Vec, + /// Output video or GIF file. + output: PathBuf, + /// Target format (gif, mp4, webm, etc.). + format: String, + /// Frame rate. + fps: u32, + /// Output width (optional, for gif). + width: Option, + }, } impl JobSpec { @@ -372,6 +485,58 @@ impl JobSpec { } } JobSpec::AudioMono { .. } => "Convert audio to mono".to_string(), + JobSpec::VideoTranscode { + crf, scale, preset, .. + } => { + let scale_str = scale + .map(|(w, h)| { + if h == -1 { + format!(" scaled to {}p", w) + } else { + format!(" scaled to {}x{}", w, h) + } + }) + .unwrap_or_default(); + format!( + "Transcode video to H.264 (CRF {}, {}){}", + crf, preset, scale_str + ) + } + JobSpec::VideoTrim { start, end, .. } => match (start, end) { + (Some(s), Some(e)) => format!("Trim video from {:.1}s to {:.1}s", s, e), + (Some(s), None) => format!("Trim video from {:.1}s to end", s), + (None, Some(e)) => format!("Trim video from start to {:.1}s", e), + (None, None) => "Trim video".to_string(), + }, + JobSpec::VideoJoin { inputs, .. } => { + format!("Join {} video files", inputs.len()) + } + JobSpec::VideoThumbnail { timestamp, .. } => { + format!("Extract thumbnail at {:.1}s", timestamp) + } + JobSpec::VideoConvert { format, .. } => { + format!("Convert video to {}", format.to_uppercase()) + } + JobSpec::VideoSpeed { speed, .. } => { + format!("Change video speed to {:.1}x", speed) + } + JobSpec::VideoRotate { degrees, .. } => { + format!("Rotate video {}°", degrees) + } + JobSpec::VideoMute { .. } => "Remove audio from video".to_string(), + JobSpec::VideoStitch { + inputs, + format, + fps, + .. + } => { + format!( + "Stitch {} images into {} at {} fps", + inputs.len(), + format.to_uppercase(), + fps + ) + } } } } @@ -736,4 +901,54 @@ mod tests { }; assert_eq!(spec.description(), "Convert audio to mono"); } + + // Video tests + + #[test] + fn test_video_transcode_description() { + let spec = JobSpec::VideoTranscode { + input: PathBuf::from("video.mp4"), + output: PathBuf::from("output.mp4"), + crf: 23, + preset: "medium".to_string(), + scale: None, + copy_audio: true, + }; + assert_eq!( + spec.description(), + "Transcode video to H.264 (CRF 23, medium)" + ); + } + + #[test] + fn test_video_transcode_with_scale_description() { + let spec = JobSpec::VideoTranscode { + input: PathBuf::from("video.mp4"), + output: PathBuf::from("output.mp4"), + crf: 20, + preset: "fast".to_string(), + scale: Some((1920, 1080)), + copy_audio: false, + }; + assert_eq!( + spec.description(), + "Transcode video to H.264 (CRF 20, fast) scaled to 1920x1080" + ); + } + + #[test] + fn test_video_transcode_width_only_description() { + let spec = JobSpec::VideoTranscode { + input: PathBuf::from("video.mp4"), + output: PathBuf::from("output.mp4"), + crf: 23, + preset: "slow".to_string(), + scale: Some((1280, -1)), + copy_audio: true, + }; + assert_eq!( + spec.description(), + "Transcode video to H.264 (CRF 23, slow) scaled to 1280p" + ); + } } diff --git a/crates/core/src/tools/ffmpeg.rs b/crates/core/src/tools/ffmpeg.rs index ca7e1ba..db8acde 100644 --- a/crates/core/src/tools/ffmpeg.rs +++ b/crates/core/src/tools/ffmpeg.rs @@ -533,6 +533,708 @@ impl FfmpegTool { output.display() ) } + + // ========== Video Operations ========== + + /// Transcode video to H.264 using software x264 encoder. + /// + /// Uses CRF (Constant Rate Factor) for quality control. + /// CRF 0 = lossless, CRF 51 = worst quality. Default is 23. + #[allow(clippy::too_many_arguments)] + pub fn transcode( + &self, + tool_path: &Path, + input: &Path, + output: &Path, + crf: u8, + preset: &str, + scale: Option<(i32, i32)>, + copy_audio: bool, + ) -> Result<()> { + let mut cmd = Command::new(tool_path); + cmd.arg("-y"); // Overwrite output + cmd.arg("-i").arg(input); + + // Video codec: H.264 with x264 + cmd.arg("-c:v").arg("libx264"); + cmd.arg("-crf").arg(crf.to_string()); + cmd.arg("-preset").arg(preset); + + // Scale filter if specified + if let Some((width, height)) = scale { + let scale_filter = if height == -1 { + // Preserve aspect ratio, scale to width + format!("scale={}:-2", width) // -2 ensures divisible by 2 + } else { + format!("scale={}:{}", width, height) + }; + cmd.arg("-vf").arg(scale_filter); + } + + // Audio handling + if copy_audio { + cmd.arg("-c:a").arg("copy"); + } else { + cmd.arg("-c:a").arg("aac"); + cmd.arg("-b:a").arg("128k"); + } + + 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 video transcode (for --plan flag). + pub fn plan_transcode( + input: &Path, + output: &Path, + crf: u8, + preset: &str, + scale: Option<(i32, i32)>, + copy_audio: bool, + ) -> String { + let mut parts = vec![ + "ffmpeg".to_string(), + "-y".to_string(), + "-i".to_string(), + input.display().to_string(), + "-c:v".to_string(), + "libx264".to_string(), + "-crf".to_string(), + crf.to_string(), + "-preset".to_string(), + preset.to_string(), + ]; + + if let Some((width, height)) = scale { + let scale_filter = if height == -1 { + format!("scale={}:-2", width) + } else { + format!("scale={}:{}", width, height) + }; + parts.push("-vf".to_string()); + parts.push(scale_filter); + } + + if copy_audio { + parts.push("-c:a".to_string()); + parts.push("copy".to_string()); + } else { + parts.push("-c:a".to_string()); + parts.push("aac".to_string()); + parts.push("-b:a".to_string()); + parts.push("128k".to_string()); + } + + parts.push(output.display().to_string()); + parts.join(" ") + } + + /// Trim video to a specific time range using stream copy (fast, no re-encoding). + pub fn video_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 + + // Add start time before input for fast seeking + if let Some(start) = start { + cmd.arg("-ss").arg(format!("{:.3}", start)); + } + + cmd.arg("-i").arg(input); + + // Add end time / duration + if let Some(end) = end { + if let Some(start) = start { + // Duration from start + let duration = end - start; + cmd.arg("-t").arg(format!("{:.3}", duration)); + } else { + // End time from beginning + cmd.arg("-to").arg(format!("{:.3}", end)); + } + } + + // Use stream copy for fast trimming (no re-encoding) + cmd.arg("-c").arg("copy"); + cmd.arg(output); + + let output_result = cmd.output()?; + 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 video trim. + pub fn plan_video_trim( + input: &Path, + output: &Path, + start: Option, + end: Option, + ) -> String { + let mut parts = vec!["ffmpeg".to_string(), "-y".to_string()]; + + if let Some(start) = start { + parts.push("-ss".to_string()); + parts.push(format!("{:.3}", start)); + } + + parts.push("-i".to_string()); + parts.push(input.display().to_string()); + + if let Some(end) = end { + if let Some(start) = start { + let duration = end - start; + parts.push("-t".to_string()); + parts.push(format!("{:.3}", duration)); + } else { + parts.push("-to".to_string()); + parts.push(format!("{:.3}", end)); + } + } + + parts.push("-c".to_string()); + parts.push("copy".to_string()); + parts.push(output.display().to_string()); + parts.join(" ") + } + + /// Join multiple video files using concat demuxer. + /// All inputs must have the same codec/resolution/frame rate. + pub fn video_join(&self, tool_path: &Path, inputs: &[PathBuf], output: &Path) -> Result<()> { + use std::io::Write; + + // Create concat file list + let temp_dir = std::env::temp_dir(); + let concat_file = temp_dir.join(format!("forgekit_concat_{}.txt", uuid::Uuid::new_v4())); + + { + let mut file = std::fs::File::create(&concat_file)?; + for input in inputs { + writeln!(file, "file '{}'", input.canonicalize()?.display())?; + } + } + + let mut cmd = Command::new(tool_path); + cmd.arg("-y") + .arg("-f") + .arg("concat") + .arg("-safe") + .arg("0") + .arg("-i") + .arg(&concat_file) + .arg("-c") + .arg("copy") + .arg(output); + + let output_result = cmd.output()?; + + // Clean up concat file + let _ = std::fs::remove_file(&concat_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 video join. + pub fn plan_video_join(inputs: &[PathBuf], output: &Path) -> String { + let files_str = inputs + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + format!( + "ffmpeg -y -f concat -safe 0 -i -c copy {}", + files_str, + output.display() + ) + } + + /// Extract a thumbnail frame from video at a specific timestamp. + pub fn video_thumbnail( + &self, + tool_path: &Path, + input: &Path, + output: &Path, + timestamp: f64, + ) -> Result<()> { + let mut cmd = Command::new(tool_path); + cmd.arg("-y") + .arg("-ss") + .arg(format!("{:.3}", timestamp)) + .arg("-i") + .arg(input) + .arg("-frames:v") + .arg("1") + .arg("-q:v") + .arg("2") + .arg(output); + + let output_result = cmd.output()?; + 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 video thumbnail. + pub fn plan_video_thumbnail(input: &Path, output: &Path, timestamp: f64) -> String { + format!( + "ffmpeg -y -ss {:.3} -i {} -frames:v 1 -q:v 2 {}", + timestamp, + input.display(), + output.display() + ) + } + + /// Convert video to a different format. + /// For GIF: uses palette generation for quality. + /// For other formats: uses stream copy when possible. + #[allow(clippy::too_many_arguments)] + pub fn video_convert( + &self, + tool_path: &Path, + input: &Path, + output: &Path, + format: &str, + start: Option, + duration: Option, + width: Option, + fps: Option, + ) -> Result<()> { + let mut cmd = Command::new(tool_path); + cmd.arg("-y"); + + if let Some(s) = start { + cmd.arg("-ss").arg(format!("{:.3}", s)); + } + + cmd.arg("-i").arg(input); + + if let Some(d) = duration { + cmd.arg("-t").arg(format!("{:.3}", d)); + } + + if format == "gif" { + // GIF needs special palette generation for quality + let fps_val = fps.unwrap_or(10); + let filter = if let Some(w) = width { + format!("fps={},scale={}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", fps_val, w) + } else { + format!( + "fps={},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", + fps_val + ) + }; + cmd.arg("-filter_complex").arg(&filter); + } else { + // For other formats, use stream copy when possible + cmd.arg("-c").arg("copy"); + } + + cmd.arg(output); + + let output_result = cmd.output()?; + 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 video format conversion. + #[allow(clippy::too_many_arguments)] + pub fn plan_video_convert( + input: &Path, + output: &Path, + format: &str, + start: Option, + duration: Option, + width: Option, + fps: 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!("{:.3}", s)); + } + + parts.push("-i".to_string()); + parts.push(input.display().to_string()); + + if let Some(d) = duration { + parts.push("-t".to_string()); + parts.push(format!("{:.3}", d)); + } + + if format == "gif" { + let fps_val = fps.unwrap_or(10); + let filter = if let Some(w) = width { + format!("fps={},scale={}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", fps_val, w) + } else { + format!( + "fps={},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", + fps_val + ) + }; + parts.push("-filter_complex".to_string()); + parts.push(format!("\"{}\"", filter)); + } else { + parts.push("-c".to_string()); + parts.push("copy".to_string()); + } + + parts.push(output.display().to_string()); + parts.join(" ") + } + + /// Change video playback speed. + pub fn video_speed( + &self, + tool_path: &Path, + input: &Path, + output: &Path, + speed: f64, + ) -> Result<()> { + let mut cmd = Command::new(tool_path); + cmd.arg("-y").arg("-i").arg(input); + + // Video speed: setpts=PTS/speed (e.g., 2x = PTS/2, 0.5x = PTS/0.5) + // Audio speed: atempo filter (only supports 0.5 to 2.0, chain for more) + let video_filter = format!("setpts=PTS/{}", speed); + + // Audio tempo - chain atempo filters if needed for extreme speeds + let audio_filters = build_atempo_chain(speed); + + cmd.arg("-filter:v").arg(&video_filter); + + if !audio_filters.is_empty() { + cmd.arg("-filter:a").arg(&audio_filters); + } else { + cmd.arg("-an"); // No audio if speed is too extreme + } + + cmd.arg(output); + + let output_result = cmd.output()?; + 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 video speed change. + pub fn plan_video_speed(input: &Path, output: &Path, speed: f64) -> String { + let video_filter = format!("setpts=PTS/{}", speed); + let audio_filters = build_atempo_chain(speed); + + if !audio_filters.is_empty() { + format!( + "ffmpeg -y -i {} -filter:v {} -filter:a {} {}", + input.display(), + video_filter, + audio_filters, + output.display() + ) + } else { + format!( + "ffmpeg -y -i {} -filter:v {} -an {}", + input.display(), + video_filter, + output.display() + ) + } + } + + /// Rotate video by specified degrees. + pub fn video_rotate( + &self, + tool_path: &Path, + input: &Path, + output: &Path, + degrees: u32, + ) -> Result<()> { + let transpose = match degrees { + 90 => "transpose=1", // 90 clockwise + 180 => "transpose=1,transpose=1", // 180 + 270 => "transpose=2", // 90 counter-clockwise (270 clockwise) + _ => { + return Err(ForgeKitError::InvalidInput { + path: input.to_path_buf(), + reason: format!("Invalid rotation angle {}. Use 90, 180, or 270.", degrees), + }) + } + }; + + let mut cmd = Command::new(tool_path); + cmd.arg("-y") + .arg("-i") + .arg(input) + .arg("-vf") + .arg(transpose) + .arg("-c:a") + .arg("copy") + .arg(output); + + let output_result = cmd.output()?; + 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 video rotation. + pub fn plan_video_rotate(input: &Path, output: &Path, degrees: u32) -> String { + let transpose = match degrees { + 90 => "transpose=1", + 180 => "transpose=1,transpose=1", + 270 => "transpose=2", + _ => "transpose=1", + }; + format!( + "ffmpeg -y -i {} -vf {} -c:a copy {}", + input.display(), + transpose, + output.display() + ) + } + + /// Remove audio track from video. + pub fn video_mute(&self, tool_path: &Path, input: &Path, output: &Path) -> Result<()> { + let mut cmd = Command::new(tool_path); + cmd.arg("-y") + .arg("-i") + .arg(input) + .arg("-c:v") + .arg("copy") + .arg("-an") + .arg(output); + + let output_result = cmd.output()?; + 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 video mute. + pub fn plan_video_mute(input: &Path, output: &Path) -> String { + format!( + "ffmpeg -y -i {} -c:v copy -an {}", + input.display(), + output.display() + ) + } + + /// Stitch image sequence into video or GIF. + pub fn video_stitch( + &self, + tool_path: &Path, + inputs: &[PathBuf], + output: &Path, + format: &str, + fps: u32, + width: Option, + ) -> Result<()> { + use std::io::Write; + + // Create a concat file listing all images + let temp_dir = std::env::temp_dir(); + let concat_file = temp_dir.join(format!("forgekit_stitch_{}.txt", uuid::Uuid::new_v4())); + + { + let mut file = std::fs::File::create(&concat_file)?; + for input in inputs { + // Each image shown for 1/fps duration + writeln!(file, "file '{}'", input.canonicalize()?.display())?; + writeln!(file, "duration {:.6}", 1.0 / fps as f64)?; + } + // Repeat last frame to avoid ffmpeg duration issue + if let Some(last) = inputs.last() { + writeln!(file, "file '{}'", last.canonicalize()?.display())?; + } + } + + let mut cmd = Command::new(tool_path); + cmd.arg("-y") + .arg("-f") + .arg("concat") + .arg("-safe") + .arg("0") + .arg("-i") + .arg(&concat_file); + + if format == "gif" { + // GIF with palette generation + let filter = if let Some(w) = width { + format!("fps={},scale={}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", fps, w) + } else { + format!( + "fps={},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", + fps + ) + }; + cmd.arg("-filter_complex").arg(&filter); + } else { + // Video output + cmd.arg("-c:v") + .arg("libx264") + .arg("-pix_fmt") + .arg("yuv420p") + .arg("-r") + .arg(fps.to_string()); + } + + cmd.arg(output); + + let output_result = cmd.output()?; + + // Clean up concat file + let _ = std::fs::remove_file(&concat_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 video stitch. + pub fn plan_video_stitch( + inputs: &[PathBuf], + output: &Path, + format: &str, + fps: u32, + width: Option, + ) -> String { + let files_preview = if inputs.len() <= 3 { + inputs + .iter() + .map(|p| { + p.file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + }) + .collect::>() + .join(", ") + } else { + format!( + "{}, ... ({} files)", + inputs[0].file_name().unwrap_or_default().to_string_lossy(), + inputs.len() + ) + }; + + if format == "gif" { + let filter = if let Some(w) = width { + format!("fps={},scale={}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", fps, w) + } else { + format!( + "fps={},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", + fps + ) + }; + format!( + "ffmpeg -y -f concat -i <{}> -filter_complex \"{}\" {}", + files_preview, + filter, + output.display() + ) + } else { + format!( + "ffmpeg -y -f concat -i <{}> -c:v libx264 -pix_fmt yuv420p -r {} {}", + files_preview, + fps, + output.display() + ) + } + } +} + +/// Build atempo filter chain for speed changes. +/// atempo only supports 0.5 to 2.0, so we chain multiple for extreme speeds. +fn build_atempo_chain(speed: f64) -> String { + if !(0.5..=2.0).contains(&speed) { + // For extreme speeds, chain atempo filters + let mut filters = Vec::new(); + let mut remaining = speed; + + while remaining > 2.0 { + filters.push("atempo=2.0".to_string()); + remaining /= 2.0; + } + while remaining < 0.5 { + filters.push("atempo=0.5".to_string()); + remaining /= 0.5; + } + + if (remaining - 1.0).abs() > 0.01 { + filters.push(format!("atempo={:.3}", remaining)); + } + + if filters.is_empty() { + String::new() + } else { + filters.join(",") + } + } else if (speed - 1.0).abs() < 0.01 { + String::new() // No change needed + } else { + format!("atempo={:.3}", speed) + } } #[cfg(test)]