From 47061eb2921a93a1d1add16afc11b254b2711e15 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:36:58 +0800 Subject: [PATCH 01/25] feat(core): add video transcoding functionality with scaling and audio options --- crates/core/src/job/executor.rs | 130 ++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/crates/core/src/job/executor.rs b/crates/core/src/job/executor.rs index 4e665d1..2f61cb6 100644 --- a/crates/core/src/job/executor.rs +++ b/crates/core/src/job/executor.rs @@ -179,6 +179,14 @@ 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), } } @@ -1450,6 +1458,52 @@ 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 + )) +} + #[cfg(test)] mod tests { use super::*; @@ -2149,3 +2203,79 @@ 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")); + } +} From 4995c8fb686616c2e41bb8ee7e3c04af4bf325bd Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:37:06 +0800 Subject: [PATCH 02/25] feat(core): implement video transcoding with detailed options and tests --- crates/core/src/job/spec.rs | 84 +++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/crates/core/src/job/spec.rs b/crates/core/src/job/spec.rs index e7b8a76..b0c75f9 100644 --- a/crates/core/src/job/spec.rs +++ b/crates/core/src/job/spec.rs @@ -277,6 +277,26 @@ 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, + }, } impl JobSpec { @@ -372,6 +392,20 @@ 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) + } } } } @@ -736,4 +770,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" + ); + } } From 6aec6074b701454c43d9b9e8a8fd8e060c5a98e3 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:37:17 +0800 Subject: [PATCH 03/25] feat(core): add video transcoding method with customizable options for quality, scaling, and audio handling --- crates/core/src/tools/ffmpeg.rs | 108 ++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/crates/core/src/tools/ffmpeg.rs b/crates/core/src/tools/ffmpeg.rs index ca7e1ba..8b68448 100644 --- a/crates/core/src/tools/ffmpeg.rs +++ b/crates/core/src/tools/ffmpeg.rs @@ -533,6 +533,114 @@ 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(" ") + } } #[cfg(test)] From ed17bf48bdf65484fe4db4a2a8fe511ffe920427 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:37:32 +0800 Subject: [PATCH 04/25] feat(cli): introduce media command for video transcoding with customizable options --- crates/cli/src/commands/media.rs | 125 +++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 crates/cli/src/commands/media.rs diff --git a/crates/cli/src/commands/media.rs b/crates/cli/src/commands/media.rs new file mode 100644 index 0000000..49118c9 --- /dev/null +++ b/crates/cli/src/commands/media.rs @@ -0,0 +1,125 @@ +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::PathBuf; + +#[derive(Subcommand, Clone)] +pub enum MediaCommand { + /// Transcode video to H.264 format + /// + /// Examples: + /// forgekit media transcode video.mov --output video.mp4 + /// forgekit media transcode video.mkv --output video.mp4 --crf 20 --preset slow + /// forgekit media 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), +} + +#[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, +} + +pub fn handle_media_command(cmd: &MediaCommand, plan_only: bool, _json_output: bool) -> Result<()> { + match cmd { + MediaCommand::Transcode(args) => handle_transcode(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(()) +} From 8f7aad05eeac8b541684935ef099173c7c778e92 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:37:37 +0800 Subject: [PATCH 05/25] feat(cli): add media module to support video transcoding commands --- crates/cli/src/commands/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index d2d6592..22ebe14 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod audio; pub mod check; pub mod image; +pub mod media; pub mod pdf; From 1186bb2739e043f048c49b06f7340ff404b5443b Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:37:42 +0800 Subject: [PATCH 06/25] feat(cli): integrate media command into CLI for video transcoding support --- crates/cli/src/main.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 1a9bdaf..72f0067 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -29,6 +29,7 @@ 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::media::{handle_media_command, MediaCommand}; use commands::pdf::{handle_pdf_command, PdfCommand}; use forgekit_core::utils::error::{ExitCode, ForgeKitError}; @@ -68,6 +69,10 @@ enum Commands { #[command(subcommand)] Audio(AudioCommand), + /// Media/video operations (transcode) + #[command(subcommand)] + Media(MediaCommand), + /// 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::Media(ref cmd)) => handle_media_command(cmd, plan_only, json_output), Some(Commands::CheckDeps) => handle_check_deps(), None => { println!("ForgeKit - Local-first media and PDF toolkit"); From 359eaf1fb743f35629b9a6da4638fcb137ecc04c Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:51:29 +0800 Subject: [PATCH 07/25] feat(core): add video editing functionalities including trim, join, thumbnail, GIF creation, speed adjustment, rotation, and mute --- crates/core/src/job/executor.rs | 256 ++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) diff --git a/crates/core/src/job/executor.rs b/crates/core/src/job/executor.rs index 2f61cb6..b1d552e 100644 --- a/crates/core/src/job/executor.rs +++ b/crates/core/src/job/executor.rs @@ -187,6 +187,37 @@ pub fn execute_job_with_progress( 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::VideoGif { + input, + output, + start, + duration, + width, + fps, + } => execute_video_gif(input, output, *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), } } @@ -1504,6 +1535,231 @@ fn execute_video_transcode( )) } +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() + )) +} + +fn execute_video_gif( + input: &Path, + output: &Path, + start: Option, + duration: Option, + width: Option, + fps: Option, + plan_only: bool, +) -> Result { + if plan_only { + return Ok(FfmpegTool::plan_video_gif(input, output, 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_gif(&tool_info.path, input, output, start, duration, width, fps)?; + + Ok(format!( + "Successfully created GIF: {}", + 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() + )) +} + #[cfg(test)] mod tests { use super::*; From 153e67899ac679bdfa08f833bcb306bdf4ce5953 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:51:38 +0800 Subject: [PATCH 08/25] feat(core): enhance video editing capabilities with trim, join, thumbnail extraction, GIF conversion, speed adjustment, rotation, and audio mute options --- crates/core/src/job/spec.rs | 119 ++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/crates/core/src/job/spec.rs b/crates/core/src/job/spec.rs index b0c75f9..b35c25e 100644 --- a/crates/core/src/job/spec.rs +++ b/crates/core/src/job/spec.rs @@ -297,6 +297,84 @@ pub enum JobSpec { /// 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 clip to animated GIF. + VideoGif { + /// Input video file. + input: PathBuf, + /// Output GIF file. + output: PathBuf, + /// Start time in seconds (optional). + start: Option, + /// Duration in seconds (optional, default 5). + duration: Option, + /// Output width (height auto-calculated). + width: Option, + /// Frame rate for GIF (default 10). + 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, + }, } impl JobSpec { @@ -406,6 +484,47 @@ impl JobSpec { .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::VideoGif { + start, + duration, + width, + fps, + .. + } => { + let mut desc = "Convert to GIF".to_string(); + if let Some(s) = start { + desc.push_str(&format!(" from {:.1}s", s)); + } + if let Some(d) = duration { + desc.push_str(&format!(" ({:.1}s)", d)); + } + if let Some(w) = width { + desc.push_str(&format!(" {}px", w)); + } + if let Some(f) = fps { + desc.push_str(&format!(" {}fps", f)); + } + desc + } + 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(), } } } From d990ebd268a78fb0165c81bce6fb63dab285fddb Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:51:50 +0800 Subject: [PATCH 09/25] feat(core): implement additional video editing features including trimming, joining, thumbnail extraction, GIF conversion, speed adjustment, rotation, and audio muting --- crates/core/src/tools/ffmpeg.rs | 441 ++++++++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) diff --git a/crates/core/src/tools/ffmpeg.rs b/crates/core/src/tools/ffmpeg.rs index 8b68448..3930e97 100644 --- a/crates/core/src/tools/ffmpeg.rs +++ b/crates/core/src/tools/ffmpeg.rs @@ -641,6 +641,447 @@ impl FfmpegTool { 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 animated GIF. + #[allow(clippy::too_many_arguments)] + pub fn video_gif( + &self, + tool_path: &Path, + input: &Path, + output: &Path, + 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)); + } + + // Build filter for fps and scale + 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); + 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 to GIF conversion. + pub fn plan_video_gif( + input: &Path, + output: &Path, + 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)); + } + + 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)); + 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() + ) + } +} + +/// 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)] From cedbd78242d2ef487994a18cac949494976ad609 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:51:59 +0800 Subject: [PATCH 10/25] refactor(cli): replace media command with video command consolidating video editing functionalities including transcoding, trimming, joining, and more --- crates/cli/src/commands/media.rs | 125 ------ crates/cli/src/commands/video.rs | 727 +++++++++++++++++++++++++++++++ 2 files changed, 727 insertions(+), 125 deletions(-) delete mode 100644 crates/cli/src/commands/media.rs create mode 100644 crates/cli/src/commands/video.rs diff --git a/crates/cli/src/commands/media.rs b/crates/cli/src/commands/media.rs deleted file mode 100644 index 49118c9..0000000 --- a/crates/cli/src/commands/media.rs +++ /dev/null @@ -1,125 +0,0 @@ -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::PathBuf; - -#[derive(Subcommand, Clone)] -pub enum MediaCommand { - /// Transcode video to H.264 format - /// - /// Examples: - /// forgekit media transcode video.mov --output video.mp4 - /// forgekit media transcode video.mkv --output video.mp4 --crf 20 --preset slow - /// forgekit media 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), -} - -#[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, -} - -pub fn handle_media_command(cmd: &MediaCommand, plan_only: bool, _json_output: bool) -> Result<()> { - match cmd { - MediaCommand::Transcode(args) => handle_transcode(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(()) -} diff --git a/crates/cli/src/commands/video.rs b/crates/cli/src/commands/video.rs new file mode 100644 index 0000000..7506c68 --- /dev/null +++ b/crates/cli/src/commands/video.rs @@ -0,0 +1,727 @@ +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 clip to animated GIF + /// + /// Examples: + /// forgekit video gif video.mp4 --output clip.gif + /// forgekit video gif video.mp4 --start 10 --duration 5 --output clip.gif + /// forgekit video gif video.mp4 --width 480 --fps 15 --output clip.gif + /// + /// Creates high-quality GIF with optimized palette. + Gif(GifArgs), + + /// 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), +} + +#[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 GifArgs { + /// Input video file + #[arg(required = true, help = "Input video file")] + pub input: PathBuf, + + /// Output GIF file + #[arg(short, long, required = true, help = "Output GIF file")] + pub output: PathBuf, + + /// Start time (seconds or MM:SS or HH:MM:SS) + #[arg(short, long, help = "Start time (e.g., 10, 0:30)")] + pub start: Option, + + /// Duration in seconds (default: 5) + #[arg(short, long, default_value = "5", help = "Duration in seconds")] + pub duration: f64, + + /// Output width in pixels (height auto-calculated) + #[arg(short, long, help = "Output width (height auto-calculated)")] + pub width: Option, + + /// Frame rate (default: 10) + #[arg(short, long, default_value = "10", help = "Frame rate (default: 10)")] + pub fps: u32, +} + +#[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, +} + +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::Gif(args) => handle_gif(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), + } +} + +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_gif(args: &GifArgs, plan_only: bool) -> Result<()> { + let start = args.start.as_ref().map(|s| parse_time(s)).transpose()?; + + let spec = JobSpec::VideoGif { + input: args.input.clone(), + output: args.output.clone(), + start, + duration: Some(args.duration), + width: args.width, + fps: Some(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(()) +} From 437cb5189b65bc9141d516896aaea555fadc77a4 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:52:05 +0800 Subject: [PATCH 11/25] refactor(cli): replace media module with video module to streamline video command structure --- crates/cli/src/commands/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 22ebe14..0ef785d 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -1,5 +1,5 @@ pub mod audio; pub mod check; pub mod image; -pub mod media; pub mod pdf; +pub mod video; From 438b3e3e0b5de367e2d9b5493975eaaca4a1f55b Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:52:10 +0800 Subject: [PATCH 12/25] refactor(cli): update command structure to replace Media with Video for enhanced video operations --- crates/cli/src/main.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 72f0067..bce5294 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -29,8 +29,8 @@ 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::media::{handle_media_command, MediaCommand}; use commands::pdf::{handle_pdf_command, PdfCommand}; +use commands::video::{handle_video_command, VideoCommand}; use forgekit_core::utils::error::{ExitCode, ForgeKitError}; /// Main CLI structure. @@ -69,9 +69,9 @@ enum Commands { #[command(subcommand)] Audio(AudioCommand), - /// Media/video operations (transcode) + /// Video operations (transcode, trim, join, thumbnail, gif, etc.) #[command(subcommand)] - Media(MediaCommand), + Video(VideoCommand), /// Check if required dependencies are installed CheckDeps, @@ -86,7 +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::Media(ref cmd)) => handle_media_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"); From 9e6408a2e4f09043b39119e475fdbd74c39a082c Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:27:28 +0800 Subject: [PATCH 13/25] chore(roadmap): update roadmap to reflect completion of video operations and increment version to `v0.0.8` --- ROADMAP.md | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) 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 From e2b60ce7e8847ca4e5ac8b7789c6db245c85084d Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:41:24 +0800 Subject: [PATCH 14/25] refactor(ffmpeg): rename GIF conversion method to a more generic video format conversion, enhancing functionality to support multiple formats with improved quality handling for GIFs --- crates/core/src/tools/ffmpeg.rs | 51 +++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/crates/core/src/tools/ffmpeg.rs b/crates/core/src/tools/ffmpeg.rs index 3930e97..d53848b 100644 --- a/crates/core/src/tools/ffmpeg.rs +++ b/crates/core/src/tools/ffmpeg.rs @@ -817,13 +817,16 @@ impl FfmpegTool { ) } - /// Convert video to animated GIF. + /// 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_gif( + pub fn video_convert( &self, tool_path: &Path, input: &Path, output: &Path, + format: &str, start: Option, duration: Option, width: Option, @@ -842,15 +845,20 @@ impl FfmpegTool { cmd.arg("-t").arg(format!("{:.3}", d)); } - // Build filter for fps and scale - 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) + 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 { - format!("fps={},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", fps_val) - }; + // For other formats, use stream copy when possible + cmd.arg("-c").arg("copy"); + } - cmd.arg("-filter_complex").arg(&filter); cmd.arg(output); let output_result = cmd.output()?; @@ -864,10 +872,12 @@ impl FfmpegTool { Ok(()) } - /// Generate plan string for video to GIF conversion. - pub fn plan_video_gif( + /// 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, @@ -888,15 +898,20 @@ impl FfmpegTool { parts.push(format!("{:.3}", d)); } - 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) + 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 { - format!("fps={},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", fps_val) - }; + parts.push("-c".to_string()); + parts.push("copy".to_string()); + } - parts.push("-filter_complex".to_string()); - parts.push(format!("\"{}\"", filter)); parts.push(output.display().to_string()); parts.join(" ") } From 069d73ee8bf3fadb52eaa64e5360fde9f6763758 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:41:31 +0800 Subject: [PATCH 15/25] refactor(core): rename VideoGif to VideoConvert, updating documentation and functionality to support various video formats --- crates/core/src/job/spec.rs | 39 +++++++++++-------------------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/crates/core/src/job/spec.rs b/crates/core/src/job/spec.rs index b35c25e..7c01afa 100644 --- a/crates/core/src/job/spec.rs +++ b/crates/core/src/job/spec.rs @@ -332,19 +332,21 @@ pub enum JobSpec { timestamp: f64, }, - /// Convert video clip to animated GIF. - VideoGif { + /// Convert video to a different format/container. + VideoConvert { /// Input video file. input: PathBuf, - /// Output GIF file. + /// Output video file. output: PathBuf, - /// Start time in seconds (optional). + /// Target format (gif, webm, mov, avi, etc.). + format: String, + /// Start time in seconds (optional, for gif). start: Option, - /// Duration in seconds (optional, default 5). + /// Duration in seconds (optional, for gif). duration: Option, - /// Output width (height auto-calculated). + /// Output width (optional, for gif). width: Option, - /// Frame rate for GIF (default 10). + /// Frame rate (optional, for gif). fps: Option, }, @@ -496,27 +498,8 @@ impl JobSpec { JobSpec::VideoThumbnail { timestamp, .. } => { format!("Extract thumbnail at {:.1}s", timestamp) } - JobSpec::VideoGif { - start, - duration, - width, - fps, - .. - } => { - let mut desc = "Convert to GIF".to_string(); - if let Some(s) = start { - desc.push_str(&format!(" from {:.1}s", s)); - } - if let Some(d) = duration { - desc.push_str(&format!(" ({:.1}s)", d)); - } - if let Some(w) = width { - desc.push_str(&format!(" {}px", w)); - } - if let Some(f) = fps { - desc.push_str(&format!(" {}fps", f)); - } - desc + JobSpec::VideoConvert { format, .. } => { + format!("Convert video to {}", format.to_uppercase()) } JobSpec::VideoSpeed { speed, .. } => { format!("Change video speed to {:.1}x", speed) From 1ee0296d6b712ab1d5dc55b606172cabb41b27d1 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:41:44 +0800 Subject: [PATCH 16/25] refactor(core): update `VideoGif` to `VideoConvert`, enhancing functionality for video format conversion and improving method naming for clarity --- crates/core/src/job/executor.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/core/src/job/executor.rs b/crates/core/src/job/executor.rs index b1d552e..a9e6ac3 100644 --- a/crates/core/src/job/executor.rs +++ b/crates/core/src/job/executor.rs @@ -199,14 +199,15 @@ pub fn execute_job_with_progress( output, timestamp, } => execute_video_thumbnail(input, output, *timestamp, plan_only), - JobSpec::VideoGif { + JobSpec::VideoConvert { input, output, + format, start, duration, width, fps, - } => execute_video_gif(input, output, *start, *duration, *width, *fps, plan_only), + } => execute_video_convert(input, output, format, *start, *duration, *width, *fps, plan_only), JobSpec::VideoSpeed { input, output, @@ -1634,9 +1635,11 @@ fn execute_video_thumbnail( )) } -fn execute_video_gif( +#[allow(clippy::too_many_arguments)] +fn execute_video_convert( input: &Path, output: &Path, + format: &str, start: Option, duration: Option, width: Option, @@ -1644,7 +1647,7 @@ fn execute_video_gif( plan_only: bool, ) -> Result { if plan_only { - return Ok(FfmpegTool::plan_video_gif(input, output, start, duration, width, fps)); + return Ok(FfmpegTool::plan_video_convert(input, output, format, start, duration, width, fps)); } if !input.exists() { @@ -1656,10 +1659,11 @@ fn execute_video_gif( let tool_info = probe_ffmpeg()?; let tool = FfmpegTool; - tool.video_gif(&tool_info.path, input, output, start, duration, width, fps)?; + tool.video_convert(&tool_info.path, input, output, format, start, duration, width, fps)?; Ok(format!( - "Successfully created GIF: {}", + "Successfully converted video to {}: {}", + format.to_uppercase(), output.display() )) } From c829283e5992c6981fe571ae235bcb31898defd2 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:41:51 +0800 Subject: [PATCH 17/25] refactor(cli): update video command to support multiple output formats, renaming GIF conversion to a more generic Convert command with enhanced argument handling --- crates/cli/src/commands/video.rs | 69 +++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/crates/cli/src/commands/video.rs b/crates/cli/src/commands/video.rs index 7506c68..0cbe8af 100644 --- a/crates/cli/src/commands/video.rs +++ b/crates/cli/src/commands/video.rs @@ -64,15 +64,17 @@ pub enum VideoCommand { /// Output format determined by extension (.jpg, .png). Thumbnail(ThumbnailArgs), - /// Convert video clip to animated GIF + /// Convert video to a different format /// /// Examples: - /// forgekit video gif video.mp4 --output clip.gif - /// forgekit video gif video.mp4 --start 10 --duration 5 --output clip.gif - /// forgekit video gif video.mp4 --width 480 --fps 15 --output clip.gif + /// 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 /// - /// Creates high-quality GIF with optimized palette. - Gif(GifArgs), + /// Supported formats: gif, webm, mp4, mov, avi, mkv + /// GIF options: --start, --duration, --width, --fps + Convert(ConvertArgs), /// Change video playback speed /// @@ -200,30 +202,34 @@ pub struct ThumbnailArgs { } #[derive(Args, Clone)] -pub struct GifArgs { +pub struct ConvertArgs { /// Input video file #[arg(required = true, help = "Input video file")] pub input: PathBuf, - /// Output GIF file - #[arg(short, long, required = true, help = "Output GIF file")] + /// Output video file + #[arg(short, long, required = true, help = "Output file")] pub output: PathBuf, - /// Start time (seconds or MM:SS or HH:MM:SS) - #[arg(short, long, help = "Start time (e.g., 10, 0:30)")] + /// 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 (default: 5) - #[arg(short, long, default_value = "5", help = "Duration in seconds")] - pub duration: f64, + /// Duration in seconds - for GIF only + #[arg(short, long, help = "Duration for GIF in seconds")] + pub duration: Option, - /// Output width in pixels (height auto-calculated) - #[arg(short, long, help = "Output width (height auto-calculated)")] + /// 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 (default: 10) - #[arg(short, long, default_value = "10", help = "Frame rate (default: 10)")] - pub fps: u32, + /// Frame rate - for GIF only (default: 10) + #[arg(short, long, help = "Frame rate for GIF (default: 10)")] + pub fps: Option, } #[derive(Args, Clone)] @@ -274,7 +280,7 @@ pub fn handle_video_command(cmd: &VideoCommand, plan_only: bool, json_output: bo 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::Gif(args) => handle_gif(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), @@ -660,16 +666,31 @@ fn handle_thumbnail(args: &ThumbnailArgs, plan_only: bool) -> Result<()> { Ok(()) } -fn handle_gif(args: &GifArgs, plan_only: bool) -> Result<()> { +fn handle_convert(args: &ConvertArgs, plan_only: bool) -> Result<()> { let start = args.start.as_ref().map(|s| parse_time(s)).transpose()?; - let spec = JobSpec::VideoGif { + // 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: Some(args.duration), + duration: args.duration, width: args.width, - fps: Some(args.fps), + fps: args.fps, }; let result = execute_job(&spec, plan_only)?; From 21162ea42f6ebc04caab3cc994c0bc881c9579e0 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:41:58 +0800 Subject: [PATCH 18/25] refactor(cli): update video command documentation to reflect changes in operations, replacing 'gif' with 'convert' for clarity --- crates/cli/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index bce5294..c9cf384 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -69,7 +69,7 @@ enum Commands { #[command(subcommand)] Audio(AudioCommand), - /// Video operations (transcode, trim, join, thumbnail, gif, etc.) + /// Video operations (convert, transcode, trim, thumbnail, etc.) #[command(subcommand)] Video(VideoCommand), From 26e24abc2905bac2a574b427e2ec6e2526735200 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:51:57 +0800 Subject: [PATCH 19/25] feat(ffmpeg): add video stitching functionality to create videos or GIFs from image sequences, including plan generation for command execution --- crates/core/src/tools/ffmpeg.rs | 111 ++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/crates/core/src/tools/ffmpeg.rs b/crates/core/src/tools/ffmpeg.rs index d53848b..927b313 100644 --- a/crates/core/src/tools/ffmpeg.rs +++ b/crates/core/src/tools/ffmpeg.rs @@ -1064,6 +1064,117 @@ impl FfmpegTool { 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,palettegen/paletteuse", fps, w) + } else { + format!("fps={},palettegen/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. From 17d8a3b3cee73ad3931ee3196ebad1ba0e1dffbb Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:52:29 +0800 Subject: [PATCH 20/25] feat(ffmpeg): implement video stitching functionality to combine image sequences into videos or GIFs, with input validation and planning support --- crates/core/src/job/executor.rs | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/core/src/job/executor.rs b/crates/core/src/job/executor.rs index a9e6ac3..666690e 100644 --- a/crates/core/src/job/executor.rs +++ b/crates/core/src/job/executor.rs @@ -219,6 +219,13 @@ pub fn execute_job_with_progress( 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), } } @@ -1764,6 +1771,46 @@ fn execute_video_mute( )) } +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::*; From 8155b5caab6d8a59b1a2843b1368206b8a8fd435 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:52:40 +0800 Subject: [PATCH 21/25] feat(core): add `VideoStitch` variant to `JobSpec` for stitching image sequences into videos or GIFs, including parameters for format, frame rate, and optional width --- crates/core/src/job/spec.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/core/src/job/spec.rs b/crates/core/src/job/spec.rs index 7c01afa..387b48e 100644 --- a/crates/core/src/job/spec.rs +++ b/crates/core/src/job/spec.rs @@ -377,6 +377,20 @@ pub enum JobSpec { /// 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 { @@ -508,6 +522,19 @@ impl JobSpec { 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 + ) + } } } } From 82bf1ec30b7183624d56130084373b81fd2f9085 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:52:46 +0800 Subject: [PATCH 22/25] feat(cli): add `Stitch` command to video operations for stitching image sequences into videos or GIFs, including input validation and support for multiple output formats --- crates/cli/src/commands/video.rs | 102 +++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/crates/cli/src/commands/video.rs b/crates/cli/src/commands/video.rs index 0cbe8af..3d2fd0a 100644 --- a/crates/cli/src/commands/video.rs +++ b/crates/cli/src/commands/video.rs @@ -102,6 +102,17 @@ pub enum VideoCommand { /// /// 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)] @@ -273,6 +284,29 @@ pub struct MuteArgs { 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), @@ -284,6 +318,7 @@ pub fn handle_video_command(cmd: &VideoCommand, plan_only: bool, json_output: bo 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), } } @@ -746,3 +781,70 @@ fn handle_mute(args: &MuteArgs, plan_only: bool) -> Result<()> { 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(()) +} From eafa93dbda80c4fa2614ebf13f5ab8c9478bc7b5 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:52:56 +0800 Subject: [PATCH 23/25] fix(ffmpeg): enhance GIF generation filter to improve quality by using palette generation and usage in the ffmpeg command --- crates/core/src/tools/ffmpeg.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/core/src/tools/ffmpeg.rs b/crates/core/src/tools/ffmpeg.rs index 927b313..9a9c8cc 100644 --- a/crates/core/src/tools/ffmpeg.rs +++ b/crates/core/src/tools/ffmpeg.rs @@ -1156,9 +1156,9 @@ impl FfmpegTool { if format == "gif" { let filter = if let Some(w) = width { - format!("fps={},scale={}:-1:flags=lanczos,palettegen/paletteuse", fps, w) + format!("fps={},scale={}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", fps, w) } else { - format!("fps={},palettegen/paletteuse", fps) + format!("fps={},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", fps) }; format!( "ffmpeg -y -f concat -i <{}> -filter_complex \"{}\" {}", From cd38c943714db55dd67343c8a5b8ecb80e3966bb Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:53:11 +0800 Subject: [PATCH 24/25] chore(deps): update version of `forgekit` and `forgekit-core` to `0.0.8` --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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" From ebd2ead9496b748b8618992d13706992d12d45ec Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:55:21 +0800 Subject: [PATCH 25/25] refactor(cli, core): improve code formatting --- crates/cli/src/commands/video.rs | 76 +++++++++++++++----- crates/core/src/job/executor.rs | 83 ++++++++++------------ crates/core/src/job/spec.rs | 6 +- crates/core/src/tools/ffmpeg.rs | 115 +++++++++++++++++++------------ 4 files changed, 173 insertions(+), 107 deletions(-) diff --git a/crates/cli/src/commands/video.rs b/crates/cli/src/commands/video.rs index 3d2fd0a..74653f5 100644 --- a/crates/cli/src/commands/video.rs +++ b/crates/cli/src/commands/video.rs @@ -122,7 +122,12 @@ pub struct TranscodeArgs { pub input: PathBuf, /// Output video file - #[arg(short, long, required = true, help = "Output video file (must be .mp4)")] + #[arg( + short, + long, + required = true, + help = "Output video file (must be .mp4)" + )] pub output: PathBuf, /// CRF quality (0-51, lower = better quality). Default: 23 @@ -152,11 +157,7 @@ pub struct TranscodeArgs { 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" - )] + #[arg(long, default_value = "false", help = "Re-encode audio to AAC 128kbps")] pub reencode_audio: bool, } @@ -204,11 +205,21 @@ pub struct ThumbnailArgs { pub input: PathBuf, /// Output image file (.jpg or .png) - #[arg(short, long, required = true, help = "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)")] + #[arg( + short, + long, + required = true, + help = "Time to extract frame (e.g., 5, 1:30)" + )] pub time: String, } @@ -223,7 +234,12 @@ pub struct ConvertArgs { 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)")] + #[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) @@ -254,7 +270,12 @@ pub struct SpeedArgs { 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)")] + #[arg( + short = 'x', + long, + required = true, + help = "Speed multiplier (e.g., 2, 0.5)" + )] pub speed: f64, } @@ -269,7 +290,12 @@ pub struct RotateArgs { pub output: PathBuf, /// Rotation angle: 90, 180, or 270 degrees clockwise - #[arg(short, long, required = true, help = "Rotation: 90, 180, or 270 degrees")] + #[arg( + short, + long, + required = true, + help = "Rotation: 90, 180, or 270 degrees" + )] pub degrees: u32, } @@ -295,7 +321,12 @@ pub struct StitchArgs { pub output: PathBuf, /// Target format (mp4, gif, webm, mov) - #[arg(short = 't', long = "to", required = true, help = "Target format (mp4, gif, webm)")] + #[arg( + short = 't', + long = "to", + required = true, + help = "Target format (mp4, gif, webm)" + )] pub format: String, /// Frame rate @@ -590,15 +621,25 @@ fn handle_info(args: &InfoArgs, json_output: bool) -> Result<()> { // 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 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 } + if d > 0.0 { + n / d + } else { + 0.0 + } } else { fps_str.parse().unwrap_or(0.0) }; @@ -756,7 +797,10 @@ 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), + reason: format!( + "Invalid rotation angle {}. Use 90, 180, or 270.", + args.degrees + ), }); } diff --git a/crates/core/src/job/executor.rs b/crates/core/src/job/executor.rs index 666690e..7e75558 100644 --- a/crates/core/src/job/executor.rs +++ b/crates/core/src/job/executor.rs @@ -207,7 +207,9 @@ pub fn execute_job_with_progress( duration, width, fps, - } => execute_video_convert(input, output, format, *start, *duration, *width, *fps, plan_only), + } => execute_video_convert( + input, output, format, *start, *duration, *width, *fps, plan_only, + ), JobSpec::VideoSpeed { input, output, @@ -1523,7 +1525,15 @@ fn execute_video_transcode( let tool_info = probe_ffmpeg()?; let tool = FfmpegTool; - tool.transcode(&tool_info.path, input, output, crf, preset, scale, copy_audio)?; + tool.transcode( + &tool_info.path, + input, + output, + crf, + preset, + scale, + copy_audio, + )?; let scale_str = scale .map(|(w, h)| { @@ -1572,17 +1582,10 @@ fn execute_video_trim( (None, None) => String::new(), }; - Ok(format!( - "Successfully trimmed video{}", - time_str - )) + Ok(format!("Successfully trimmed video{}", time_str)) } -fn execute_video_join( - inputs: &[PathBuf], - output: &Path, - plan_only: bool, -) -> Result { +fn execute_video_join(inputs: &[PathBuf], output: &Path, plan_only: bool) -> Result { if plan_only { return Ok(FfmpegTool::plan_video_join(inputs, output)); } @@ -1654,7 +1657,9 @@ fn execute_video_convert( plan_only: bool, ) -> Result { if plan_only { - return Ok(FfmpegTool::plan_video_convert(input, output, format, start, duration, width, fps)); + return Ok(FfmpegTool::plan_video_convert( + input, output, format, start, duration, width, fps, + )); } if !input.exists() { @@ -1666,7 +1671,16 @@ fn execute_video_convert( let tool_info = probe_ffmpeg()?; let tool = FfmpegTool; - tool.video_convert(&tool_info.path, input, output, format, start, duration, width, fps)?; + tool.video_convert( + &tool_info.path, + input, + output, + format, + start, + duration, + width, + fps, + )?; Ok(format!( "Successfully converted video to {}: {}", @@ -1675,12 +1689,7 @@ fn execute_video_convert( )) } -fn execute_video_speed( - input: &Path, - output: &Path, - speed: f64, - plan_only: bool, -) -> Result { +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)); } @@ -1745,11 +1754,7 @@ fn execute_video_rotate( )) } -fn execute_video_mute( - input: &Path, - output: &Path, - plan_only: bool, -) -> Result { +fn execute_video_mute(input: &Path, output: &Path, plan_only: bool) -> Result { if plan_only { return Ok(FfmpegTool::plan_video_mute(input, output)); } @@ -1780,7 +1785,9 @@ fn execute_video_stitch( plan_only: bool, ) -> Result { if plan_only { - return Ok(FfmpegTool::plan_video_stitch(inputs, output, format, fps, width)); + return Ok(FfmpegTool::plan_video_stitch( + inputs, output, format, fps, width, + )); } if inputs.is_empty() { @@ -2536,16 +2543,9 @@ mod video_operation_tests { 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(); + let result = + execute_video_transcode(&input, &output, 23, "fast", Some((1920, 1080)), true, true) + .unwrap(); assert!(result.contains("-vf scale=1920:1080")); } @@ -2556,16 +2556,9 @@ mod video_operation_tests { 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(); + 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")); diff --git a/crates/core/src/job/spec.rs b/crates/core/src/job/spec.rs index 387b48e..1e6d0aa 100644 --- a/crates/core/src/job/spec.rs +++ b/crates/core/src/job/spec.rs @@ -279,7 +279,6 @@ pub enum JobSpec { }, // ========== Video Operations ========== - /// Transcode video to H.264 format. /// /// Uses ffmpeg with software x264 encoder. CRF controls quality (0-51, lower is better). @@ -498,7 +497,10 @@ impl JobSpec { } }) .unwrap_or_default(); - format!("Transcode video to H.264 (CRF {}, {}){}", crf, preset, scale_str) + 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), diff --git a/crates/core/src/tools/ffmpeg.rs b/crates/core/src/tools/ffmpeg.rs index 9a9c8cc..db8acde 100644 --- a/crates/core/src/tools/ffmpeg.rs +++ b/crates/core/src/tools/ffmpeg.rs @@ -724,12 +724,7 @@ impl FfmpegTool { /// 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<()> { + pub fn video_join(&self, tool_path: &Path, inputs: &[PathBuf], output: &Path) -> Result<()> { use std::io::Write; // Create concat file list @@ -745,10 +740,14 @@ impl FfmpegTool { 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("-f") + .arg("concat") + .arg("-safe") + .arg("0") + .arg("-i") + .arg(&concat_file) + .arg("-c") + .arg("copy") .arg(output); let output_result = cmd.output()?; @@ -790,10 +789,14 @@ impl FfmpegTool { ) -> 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("-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()?; @@ -851,7 +854,10 @@ impl FfmpegTool { 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) + format!( + "fps={},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", + fps_val + ) }; cmd.arg("-filter_complex").arg(&filter); } else { @@ -903,7 +909,10 @@ impl FfmpegTool { 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) + format!( + "fps={},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", + fps_val + ) }; parts.push("-filter_complex".to_string()); parts.push(format!("\"{}\"", filter)); @@ -925,8 +934,7 @@ impl FfmpegTool { speed: f64, ) -> Result<()> { let mut cmd = Command::new(tool_path); - cmd.arg("-y") - .arg("-i").arg(input); + 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) @@ -988,20 +996,25 @@ impl FfmpegTool { degrees: u32, ) -> Result<()> { let transpose = match degrees { - 90 => "transpose=1", // 90 clockwise + 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), - }), + 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("-i") + .arg(input) + .arg("-vf") + .arg(transpose) + .arg("-c:a") + .arg("copy") .arg(output); let output_result = cmd.output()?; @@ -1032,16 +1045,13 @@ impl FfmpegTool { } /// Remove audio track from video. - pub fn video_mute( - &self, - tool_path: &Path, - input: &Path, - output: &Path, - ) -> Result<()> { + 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("-i") + .arg(input) + .arg("-c:v") + .arg("copy") .arg("-an") .arg(output); @@ -1096,23 +1106,32 @@ impl FfmpegTool { let mut cmd = Command::new(tool_path); cmd.arg("-y") - .arg("-f").arg("concat") - .arg("-safe").arg("0") - .arg("-i").arg(&concat_file); + .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) + 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("-c:v") + .arg("libx264") + .arg("-pix_fmt") + .arg("yuv420p") + .arg("-r") + .arg(fps.to_string()); } cmd.arg(output); @@ -1143,7 +1162,12 @@ impl FfmpegTool { let files_preview = if inputs.len() <= 3 { inputs .iter() - .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string()) + .map(|p| { + p.file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + }) .collect::>() .join(", ") } else { @@ -1158,7 +1182,10 @@ impl FfmpegTool { 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!( + "fps={},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", + fps + ) }; format!( "ffmpeg -y -f concat -i <{}> -filter_complex \"{}\" {}",