gifski 1.10.0

pngquant-based GIF maker for nice-looking animGIFs
Documentation
use clap::builder::NonEmptyStringValueParser;
use std::io::Read;
use gifski::{Settings, Repeat};
use clap::value_parser;

#[cfg(feature = "video")]
mod ffmpeg_source;
mod png;
mod gif;
mod source;
use crate::source::Source;

use gifski::progress::{NoProgress, ProgressBar, ProgressReporter};

pub type BinResult<T, E = Box<dyn std::error::Error + Send + Sync>> = Result<T, E>;

use clap::{Command, Arg, ArgAction};

use std::env;
use std::fmt;
use std::fs::File;
use std::io;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;

#[cfg(feature = "video")]
const VIDEO_FRAMES_ARG_HELP: &str = "one video file supported by FFmpeg, or multiple PNG image files";
#[cfg(not(feature = "video"))]
const VIDEO_FRAMES_ARG_HELP: &str = "PNG image files for the animation frames";

fn main() {
    if let Err(e) = bin_main() {
        eprintln!("error: {e}");
        if let Some(e) = e.source() {
            eprintln!("error: {e}");
        }
        std::process::exit(1);
    }
}

#[allow(clippy::float_cmp)]
fn bin_main() -> BinResult<()> {
    let matches = Command::new(clap::crate_name!())
                        .version(clap::crate_version!())
                        .about("https://gif.ski by Kornel Lesiński")
                        .arg_required_else_help(true)
                        .allow_negative_numbers(true)
                        .arg(Arg::new("output")
                            .long("output")
                            .short('o')
                            .help("Destination file to write to; \"-\" means stdout")
                            .num_args(1)
                            .value_name("a.gif")
                            .value_parser(value_parser!(PathBuf))
                            .required(true))
                        .arg(Arg::new("fps")
                            .long("fps")
                            .short('r')
                            .help("Frame rate of animation. If using PNG files as \
                                   input, this means the speed, as all frames are \
                                   kept. If video is used, it will be resampled to \
                                   this constant rate by dropping and/or duplicating \
                                   frames")
                            .value_parser(value_parser!(f32))
                            .value_name("num")
                            .default_value("20"))
                        .arg(Arg::new("fast-forward")
                            .long("fast-forward")
                            .help("Multiply speed of video by a factor\n(no effect when using images as input)")
                            .value_parser(value_parser!(f32))
                            .value_name("x")
                            .default_value("1"))
                        .arg(Arg::new("fast")
                            .num_args(0)
                            .action(ArgAction::SetTrue)
                            .long("fast")
                            .help("50% faster encoding, but 10% worse quality and larger file size"))
                        .arg(Arg::new("extra")
                            .long("extra")
                            .conflicts_with("fast")
                            .num_args(0)
                            .action(ArgAction::SetTrue)
                            .help("50% slower encoding, but 1% better quality"))
                        .arg(Arg::new("quality")
                            .long("quality")
                            .short('Q')
                            .value_name("1-100")
                            .value_parser(value_parser!(u8).range(1..=100))
                            .num_args(1)
                            .default_value("90")
                            .help("Lower quality may give smaller file"))
                        .arg(Arg::new("motion-quality")
                            .long("motion-quality")
                            .value_name("1-100")
                            .value_parser(value_parser!(u8).range(1..=100))
                            .num_args(1)
                            .help("Lower values reduce motion"))
                        .arg(Arg::new("lossy-quality")
                            .long("lossy-quality")
                            .value_name("1-100")
                            .value_parser(value_parser!(u8).range(1..=100))
                            .num_args(1)
                            .help("Lower values introduce noise and streaks"))
                        .arg(Arg::new("width")
                            .long("width")
                            .short('W')
                            .num_args(1)
                            .value_parser(value_parser!(u32))
                            .value_name("px")
                            .help("Maximum width.\nBy default anims are limited to about 800x600"))
                        .arg(Arg::new("height")
                            .long("height")
                            .short('H')
                            .num_args(1)
                            .value_parser(value_parser!(u32))
                            .value_name("px")
                            .help("Maximum height (stretches if the width is also set)"))
                        .arg(Arg::new("nosort")
                            .alias("nosort")
                            .long("no-sort")
                            .num_args(0)
                            .action(ArgAction::SetTrue)
                            .help("Use files exactly in the order given, rather than sorted"))
                        .arg(Arg::new("quiet")
                            .long("quiet")
                            .short('q')
                            .num_args(0)
                            .action(ArgAction::SetTrue)
                            .help("Do not display anything on standard output/console"))
                        .arg(Arg::new("FILES")
                            .help(VIDEO_FRAMES_ARG_HELP)
                            .num_args(1..)
                            .value_parser(NonEmptyStringValueParser::new())
                            .use_value_delimiter(false)
                            .required(true))
                        .arg(Arg::new("repeat")
                            .long("repeat")
                            .help("Number of times the animation is repeated (-1 none, 0 forever or <value> repetitions")
                            .num_args(1)
                            .value_parser(value_parser!(i16))
                            .value_name("num"))
                        .get_matches_from(wild::args_os());

    let mut frames: Vec<_> = matches.get_many::<String>("FILES").ok_or("?")?.collect();
    if !matches.get_flag("nosort") {
        frames.sort_by(|a, b| natord::compare(a, b));
    }
    let frames: Vec<_> = frames.into_iter().map(PathBuf::from).collect();

    let output_path = DestPath::new(matches.get_one::<PathBuf>("output").ok_or("?")?);
    let width = matches.get_one::<u32>("width").copied();
    let height = matches.get_one::<u32>("height").copied();
    let repeat_int = matches.get_one::<i16>("repeat").copied().unwrap_or(0);
    let repeat = match repeat_int {
        -1 => Repeat::Finite(0),
        0 => Repeat::Infinite,
        _ => Repeat::Finite(repeat_int as u16),
    };

    let extra = matches.get_flag("extra");
    let motion_quality = matches.get_one::<u8>("motion-quality").copied();
    let lossy_quality = matches.get_one::<u8>("lossy-quality").copied();
    let fast = matches.get_flag("fast");
    let settings = Settings {
        width,
        height,
        quality: matches.get_one::<u8>("quality").copied().unwrap_or(100),
        fast,
        repeat,
    };
    let quiet = matches.get_flag("quiet") || output_path == DestPath::Stdout;
    let fps: f32 = matches.get_one::<f32>("fps").copied().ok_or("?")?;
    let speed: f32 = matches.get_one::<f32>("fast-forward").copied().ok_or("?")?;

    let rate = source::Fps { fps, speed };

    if settings.quality < 20 {
        if settings.quality < 1 {
            return Err("Quality too low".into());
        } else if !quiet {
            eprintln!("warning: quality {} will give really bad results", settings.quality);
        }
    } else if settings.quality > 100 {
        return Err("Quality 100 is maximum".into());
    }

    if speed > 1000.0 || speed <= 0.0 {
        return Err("Fast-forward must be 0..1000".into());
    }

    if fps > 100.0 || fps <= 0.0 {
        return Err("100 fps is maximum".into());
    }
    else if !quiet && fps > 50.0 {
        eprintln!("warning: web browsers support max 50 fps");
    }

    check_if_paths_exist(&frames)?;

    let mut decoder = if let [path] = &frames[..] {
        match file_type(path).unwrap_or(FileType::Other) {
            FileType::PNG | FileType::JPEG => return Err("Only a single image file was given as an input. This is not enough to make an animation.".into()),
            FileType::GIF => {
                if !quiet && (width.is_none() && settings.quality > 50) {
                    eprintln!("warning: reading an existing GIF as an input. This can only worsen the quality. Use PNG frames instead.");
                }
                Box::new(gif::GifDecoder::new(path, rate)?)
            },
            _ if path.is_dir() => {
                return Err(format!("{} is a directory, not a PNG file", path.display()).into());
            },
            _ => get_video_decoder(path, rate, settings)?,
        }
    } else {
        if let Ok(FileType::JPEG) = file_type(&frames[0]) {
            return Err("JPEG format is unsuitable for conversion to GIF.\n\n\
                JPEG's compression artifacts and color space are very problematic for palette-based\n\
                compression. Please don't use JPEG for making GIF animations. Please re-export\n\
                your animation using the PNG format.".into())
        }
        if speed != 1.0 {
            return Err("Speed is for videos. It doesn't make sense for images. Use fps only".into());
        }
        Box::new(png::Lodecoder::new(frames, rate))
    };

    let mut pb;
    let mut nopb = NoProgress {};
    let progress: &mut dyn ProgressReporter = if quiet {
        &mut nopb
    } else {
        pb = ProgressBar::new(decoder.total_frames().unwrap_or(100));
        pb.show_speed = false;
        pb.show_percent = false;
        pb.format(" #_. ");
        pb.message("Frame ");
        pb.set_max_refresh_rate(Some(Duration::from_millis(250)));
        &mut pb
    };

    let (mut collector, mut writer) = gifski::new(settings)?;
    if extra {
        #[allow(deprecated)]
        writer.set_extra_effort(true);
    }
    if let Some(motion_quality) = motion_quality {
        #[allow(deprecated)]
        writer.set_motion_quality(motion_quality);
    }
    if let Some(lossy_quality) = lossy_quality {
        #[allow(deprecated)]
        writer.set_lossy_quality(lossy_quality);
    }
    let decode_thread = thread::Builder::new().name("decode".into()).spawn(move || {
        decoder.collect(&mut collector)
    })?;

    let mut file_tmp;
    let mut stdio_tmp;
    let out: &mut dyn io::Write = match output_path {
        DestPath::Path(p) => {
            file_tmp = File::create(p)
                .map_err(|e| format!("Can't write to {}: {e}", p.display()))?;
            &mut file_tmp
        },
        DestPath::Stdout => {
            stdio_tmp = io::stdout().lock();
            &mut stdio_tmp
        },
    };
    writer.write(io::BufWriter::new(out), progress)?;
    decode_thread.join().map_err(|_| "thread died?")??;
    progress.done(&format!("gifski created {output_path}"));

    Ok(())
}

#[allow(clippy::upper_case_acronyms)]
enum FileType {
    PNG, GIF, JPEG, Other,
}

fn file_type(path: &Path) -> BinResult<FileType> {
    let mut file = std::fs::File::open(path)?;
    let mut buf = [0; 4];
    file.read_exact(&mut buf)?;

    if &buf == b"\x89PNG" {
        return Ok(FileType::PNG);
    }
    if &buf == b"GIF8" {
        return Ok(FileType::GIF);
    }
    if buf[..2] == [0xFF, 0xD8] {
        return Ok(FileType::JPEG);
    }
    Ok(FileType::Other)
}

fn check_if_paths_exist(paths: &[PathBuf]) -> BinResult<()> {
    for path in paths {
        if !path.exists() {
            let mut msg = format!("Unable to find the input file: \"{}\"", path.display());
            if path.to_str().map_or(false, |p| p.contains('*')) {
                msg += "\nThe path contains a literal \"*\" character. Either no files matched the pattern, or the pattern was in quotes.";
            } else if path.extension() == Some("gif".as_ref()) {
                msg = format!("Did you mean to use -o \"{}\" to specify it as the output file instead?", path.display());
            } else if path.is_relative() {
                msg += &format!(" (searched in \"{}\")", env::current_dir()?.display());
            }
            return Err(msg.into())
        }
    }
    Ok(())
}

#[derive(PartialEq)]
enum DestPath<'a> {
    Path(&'a Path),
    Stdout,
}

impl<'a> DestPath<'a> {
    pub fn new(path: &'a Path) -> Self {
        if path.as_os_str() == "-" {
            Self::Stdout
        } else {
            Self::Path(Path::new(path))
        }
    }
}

impl fmt::Display for DestPath<'_> {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self::Path(orig_path) => {
                let abs_path = dunce::canonicalize(orig_path);
                abs_path.as_ref().map(|p| p.as_path()).unwrap_or(orig_path).display().fmt(f)
            },
            Self::Stdout => f.write_str("stdout"),
        }
    }
}

#[cfg(feature = "video")]
fn get_video_decoder(path: &Path, fps: source::Fps, settings: Settings) -> BinResult<Box<dyn Source + Send>> {
    Ok(Box::new(ffmpeg_source::FfmpegDecoder::new(path, fps, settings)?))
}

#[cfg(not(feature = "video"))]
#[cold]
fn get_video_decoder(_: &Path, _: source::Fps, _: Settings) -> BinResult<Box<dyn Source + Send>> {
    Err(r"Video support is permanently disabled in this executable.

To enable video decoding you need to recompile gifski from source with:
cargo build --release --features=video
or
cargo install gifski --features=video

Alternatively, use ffmpeg command to export PNG frames, and then specify
the PNG files as input for this executable. Instructions on https://gif.ski
".into())
}