clippr 0.1.1

Convert MP4 to chunked GitHub-friendly GIFs
Documentation
pub mod encode;
pub mod error;
pub mod gui;
pub mod probe;
pub mod strategy;

use encode::EncodeParams;
use error::{Error, Result};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use strategy::InitialParams;

const MIN_SPLIT_DURATION: f64 = 0.5;

pub struct ConvertOptions {
    pub input: PathBuf,
    pub output: Option<PathBuf>,
    pub max_size_mb: f64,
    pub width: u32,
    pub fps: u32,
    pub colors: u32,
    pub chunk_secs: f64,
}

#[derive(Clone)]
struct Segment {
    start_secs: f64,
    duration_secs: f64,
}

fn output_stem_from_args(input: &Path, output: Option<&Path>) -> Result<PathBuf> {
    match output {
        Some(path) => Ok(path.to_path_buf().with_extension("")),
        None => {
            let stem = input
                .file_stem()
                .ok_or_else(|| Error::InvalidInput("input has no file stem".into()))?;
            Ok(input.with_file_name(stem))
        }
    }
}

fn chunk_output_path(stem: &Path, chunk_index: u32, chunk_count: u32) -> PathBuf {
    if chunk_count == 1 {
        stem.with_extension("gif")
    } else {
        let name = format!(
            "{}_{:03}.gif",
            stem.file_name().unwrap_or_default().to_string_lossy(),
            chunk_index + 1,
        );
        stem.with_file_name(name)
    }
}

fn temp_output_path(stem: &Path, index: u32) -> PathBuf {
    let name = format!(
        "{}.tmp_{:06}.gif",
        stem.file_name().unwrap_or_default().to_string_lossy(),
        index,
    );
    stem.with_file_name(name)
}

pub fn convert(
    options: &ConvertOptions,
    mut on_progress: impl FnMut(&str),
) -> Result<Vec<PathBuf>> {
    if !options.input.exists() {
        return Err(Error::InputNotFound(options.input.clone()));
    }

    if options.max_size_mb <= 0.0 {
        return Err(Error::InvalidInput("--max-size-mb must be positive".into()));
    }

    if options.chunk_secs <= 0.0 {
        return Err(Error::InvalidInput("--chunk-secs must be positive".into()));
    }

    let info = probe::probe(&options.input)?;
    on_progress(&format!(
        "input: {}x{}, {:.1}fps, {:.1}s",
        info.width, info.height, info.framerate, info.duration_secs
    ));

    let target_bytes = (options.max_size_mb * 1024.0 * 1024.0) as u64;
    let output_stem = output_stem_from_args(&options.input, options.output.as_deref())?;
    let initial_chunk_count = (info.duration_secs / options.chunk_secs).ceil() as u32;

    if initial_chunk_count == 0 {
        return Err(Error::InvalidInput("video has zero duration".into()));
    }

    let initial = InitialParams {
        width: options.width.min(info.width),
        fps: options.fps.min(info.framerate.ceil() as u32),
        colors: options.colors,
    };

    let mut queue: VecDeque<Segment> = VecDeque::new();
    for chunk_index in 0..initial_chunk_count {
        let start_secs = chunk_index as f64 * options.chunk_secs;
        let remaining = info.duration_secs - start_secs;
        let duration_secs = remaining.min(options.chunk_secs);
        if duration_secs > 0.0 {
            queue.push_back(Segment {
                start_secs,
                duration_secs,
            });
        }
    }

    let mut temp_paths: Vec<PathBuf> = Vec::new();
    let mut temp_counter: u32 = 0;

    while let Some(segment) = queue.pop_front() {
        let temp_path = temp_output_path(&output_stem, temp_counter);
        temp_counter += 1;

        on_progress(&format!(
            "\nsegment: {:.1}s - {:.1}s ({:.1}s)",
            segment.start_secs,
            segment.start_secs + segment.duration_secs,
            segment.duration_secs,
        ));

        let params = EncodeParams {
            width: initial.width,
            fps: initial.fps,
            colors: initial.colors,
            start_secs: segment.start_secs,
            duration_secs: segment.duration_secs,
        };

        let size = encode::encode(&options.input, &temp_path, &params)?;

        if size <= target_bytes {
            let size_mb = size as f64 / (1024.0 * 1024.0);
            on_progress(&format!("  -> {:.2} MB (fits at full quality)", size_mb));
            temp_paths.push(temp_path);
            continue;
        }

        std::fs::remove_file(&temp_path)?;

        if segment.duration_secs > MIN_SPLIT_DURATION {
            let half = segment.duration_secs / 2.0;
            on_progress(&format!(
                "  -> {:.2} MB (too large, splitting {:.1}s into 2x {:.1}s)",
                size as f64 / (1024.0 * 1024.0),
                segment.duration_secs,
                half,
            ));
            queue.push_front(Segment {
                start_secs: segment.start_secs + half,
                duration_secs: segment.duration_secs - half,
            });
            queue.push_front(Segment {
                start_secs: segment.start_secs,
                duration_secs: half,
            });
            continue;
        }

        on_progress(&format!(
            "  -> {:.2} MB (too large, segment too short to split — degrading quality)",
            size as f64 / (1024.0 * 1024.0),
        ));

        let temp_path = temp_output_path(&output_stem, temp_counter);
        temp_counter += 1;

        let size = strategy::auto_encode(
            &options.input,
            &temp_path,
            target_bytes,
            &initial,
            segment.start_secs,
            segment.duration_secs,
            &mut on_progress,
        )?;

        let size_mb = size as f64 / (1024.0 * 1024.0);
        on_progress(&format!("  -> {:.2} MB (degraded quality)", size_mb));
        temp_paths.push(temp_path);
    }

    let final_count = temp_paths.len() as u32;
    let mut outputs: Vec<PathBuf> = Vec::new();

    for (index, temp_path) in temp_paths.iter().enumerate() {
        let final_path = chunk_output_path(&output_stem, index as u32, final_count);
        std::fs::rename(temp_path, &final_path)?;
        outputs.push(final_path);
    }

    on_progress(&format!("\ndone — {} chunk(s) written:", outputs.len()));
    for path in &outputs {
        on_progress(&format!("  {}", path.display()));
    }

    Ok(outputs)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn single_chunk_produces_plain_gif_extension() {
        let result = chunk_output_path(Path::new("demo"), 0, 1);
        assert_eq!(result, PathBuf::from("demo.gif"));
    }

    #[test]
    fn multi_chunk_produces_numbered_suffixes() {
        let result = chunk_output_path(Path::new("demo"), 0, 4);
        assert_eq!(result, PathBuf::from("demo_001.gif"));

        let result = chunk_output_path(Path::new("demo"), 3, 4);
        assert_eq!(result, PathBuf::from("demo_004.gif"));
    }

    #[test]
    fn chunk_path_preserves_parent_directory() {
        let stem = Path::new("/tmp/output/demo");
        let result = chunk_output_path(stem, 0, 3);
        assert_eq!(result, PathBuf::from("/tmp/output/demo_001.gif"));
    }

    #[test]
    fn output_stem_strips_extension_from_input() {
        let result = output_stem_from_args(Path::new("video.mp4"), None).unwrap();
        assert_eq!(result, PathBuf::from("video"));
    }

    #[test]
    fn output_stem_uses_explicit_output_without_extension() {
        let result =
            output_stem_from_args(Path::new("video.mp4"), Some(Path::new("out.gif"))).unwrap();
        assert_eq!(result, PathBuf::from("out"));
    }

    #[test]
    fn output_stem_explicit_output_no_extension() {
        let result =
            output_stem_from_args(Path::new("video.mp4"), Some(Path::new("myoutput"))).unwrap();
        assert_eq!(result, PathBuf::from("myoutput"));
    }
}