cavif 1.5.6

Encodes images in AVIF format (image2avif converter) using a pure-Rust encoder.
use clap::ArgAction;
use clap::value_parser;
use clap::{Arg, Command};
use imgref::ImgVec;
use ravif::{AlphaColorMode, ColorSpace, Encoder, EncodedImage, RGBA8};
use rayon::prelude::*;
use std::fs;
use std::io::Read;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;

type BoxError = Box<dyn std::error::Error + Send + Sync>;

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

enum MaybePath {
    Stdio,
    Path(PathBuf),
}

fn parse_quality(arg: &str) -> Result<f32, String> {
    let q = arg.parse::<f32>().map_err(|e| e.to_string())?;
    if q < 1. || q > 100. {
        return Err("quality must be in 1-100 range".into());
    }
    Ok(q)
}

fn parse_speed(arg: &str) -> Result<u8, String> {
    let s = arg.parse::<u8>().map_err(|e| e.to_string())?;
    if s < 1 || s > 100 {
        return Err("speed must be in 1-10 range".into());
    }
    Ok(s)
}

fn run() -> Result<(), BoxError> {
    let args = Command::new("cavif-rs")
        .version(clap::crate_version!())
        .author("Kornel LesiƄski <kornel@imageoptim.com>")
        .about("Convert JPEG/PNG images to AVIF image format (based on AV1/rav1e)")
        .arg(Arg::new("quality")
            .short('Q')
            .long("quality")
            .value_name("n")
            .value_parser(parse_quality)
            .default_value("80")
            .help("Quality from 1 (worst) to 100 (best)"))
        .arg(Arg::new("speed")
            .short('s')
            .long("speed")
            .value_name("n")
            .default_value("4")
            .value_parser(parse_speed)
            .help("Encoding speed from 1 (best) to 10 (fast but ugly)"))
        .arg(Arg::new("threads")
            .short('j')
            .long("threads")
            .value_name("n")
            .default_value("0")
            .value_parser(value_parser!(u8))
            .help("Maximum threads to use (0 = one thread per host core)"))
        .arg(Arg::new("overwrite")
            .alias("force")
            .short('f')
            .long("overwrite")
            .action(ArgAction::SetTrue)
            .num_args(0)
            .help("Replace files if there's .avif already"))
        .arg(Arg::new("output")
            .short('o')
            .long("output")
            .value_parser(value_parser!(PathBuf))
            .value_name("path")
            .help("Write output to this path instead of same_file.avif. It may be a file or a directory."))
        .arg(Arg::new("quiet")
            .short('q')
            .long("quiet")
            .action(ArgAction::SetTrue)
            .num_args(0)
            .help("Don't print anything"))
        .arg(Arg::new("dirty-alpha")
            .long("dirty-alpha")
            .action(ArgAction::SetTrue)
            .num_args(0)
            .help("Keep RGB data of fully-transparent pixels (makes larger, lower quality files)"))
        .arg(Arg::new("color")
            .long("color")
            .default_value("ycbcr")
            .value_parser(["ycbcr", "rgb"])
            .help("Internal AVIF color space. YCbCr works better for human eyes."))
        .arg(Arg::new("depth")
            .long("depth")
            .default_value("auto")
            .value_parser(["8", "10", "auto"])
            .help("Write 8-bit (more compatible) or 10-bit (better quality) images"))
        .arg(Arg::new("IMAGES")
            .index(1)
            .num_args(1..)
            .value_parser(value_parser!(PathBuf))
            .help("One or more JPEG or PNG files to convert. \"-\" is interpreted as stdin/stdout."))
        .get_matches();

    let output = args.get_one::<PathBuf>("output").map(|s| {
        match s {
            s if s.as_os_str() == "-" => MaybePath::Stdio,
            s => MaybePath::Path(PathBuf::from(s)),
        }
    });
    let quality = *args.get_one::<f32>("quality").expect("default");
    let alpha_quality = ((quality + 100.)/2.).min(quality + quality/4. + 2.);
    let speed: u8 = *args.get_one::<u8>("speed").expect("default");
    let overwrite = args.get_flag("overwrite");
    let quiet = args.get_flag("quiet");
    let threads = args.get_one::<u8>("threads").copied();
    let dirty_alpha = args.get_flag("dirty-alpha");

    let color_space = match args.get_one::<String>("color").expect("default").as_str() {
        "ycbcr" => ColorSpace::YCbCr,
        "rgb" => ColorSpace::RGB,
        x => Err(format!("bad color type: {x}"))?,
    };

    let depth = match args.get_one::<String>("depth").expect("default").as_str() {
        "8" => Some(8),
        "10" => Some(10),
        _ => None,
    };

    let files = args.get_many::<PathBuf>("IMAGES").ok_or("Please specify image paths to convert")?;
    let files: Vec<_> = files
        .filter(|pathstr| {
            let path = Path::new(&pathstr);
            if let Some(s) = path.to_str() {
                if quiet && s.parse::<u8>().is_ok() && !path.exists() {
                    eprintln!("warning: -q is not for quality, so '{s}' is misinterpreted as a file. Use -Q {s}");
                }
            }
            path.extension().map_or(true, |e| if e == "avif" {
                if !quiet {
                    if path.exists() {
                        eprintln!("warning: ignoring {}, because it's already an AVIF", path.display());
                    } else {
                        eprintln!("warning: Did you mean to use -o {p}?", p = path.display());
                        return true;
                    }
                }
                false
            } else {
                true
            })
        })
        .map(|p| if p.as_os_str() == "-" {
            MaybePath::Stdio
        } else {
            MaybePath::Path(PathBuf::from(p))
        })
        .collect();

    if files.is_empty() {
        return Err("No PNG/JPEG files specified".into());
    }

    let use_dir = match output {
        Some(MaybePath::Path(ref path)) => {
            if files.len() > 1 {
                let _ = fs::create_dir_all(path);
            }
            files.len() > 1 || path.is_dir()
        },
        _ => false,
    };

    let process = move |data: Vec<u8>, input_path: &MaybePath| -> Result<(), BoxError> {
        let img = load_rgba(&data, false)?;
        drop(data);
        let out_path = match (&output, input_path) {
            (None, MaybePath::Path(input)) => MaybePath::Path(input.with_extension("avif")),
            (Some(MaybePath::Path(output)), MaybePath::Path(ref input)) => MaybePath::Path({
                if use_dir {
                    output.join(Path::new(input.file_name().unwrap()).with_extension("avif"))
                } else {
                    output.clone()
                }
            }),
            (None, MaybePath::Stdio) |
            (Some(MaybePath::Stdio), _) => MaybePath::Stdio,
            (Some(MaybePath::Path(output)), MaybePath::Stdio) => MaybePath::Path(output.clone()),
        };
        match out_path {
            MaybePath::Path(ref p) if !overwrite && p.exists() => {
                return Err(format!("{} already exists; skipping", p.display()).into());
            },
            _ => {},
        }
        let enc = Encoder::new()
            .with_quality(quality)
            .with_depth(depth)
            .with_speed(speed)
            .with_alpha_quality(alpha_quality)
            .with_internal_color_space(color_space)
            .with_alpha_color_mode(if dirty_alpha { AlphaColorMode::UnassociatedDirty } else { AlphaColorMode::UnassociatedClean })
            .with_num_threads(threads.filter(|&n| n > 0).map(usize::from));
        let EncodedImage { avif_file, color_byte_size, alpha_byte_size , .. } = enc.encode_rgba(img.as_ref())?;
        match out_path {
            MaybePath::Path(ref p) => {
                if !quiet {
                    println!("{}: {}KB ({color_byte_size}B color, {alpha_byte_size}B alpha, {}B HEIF)", p.display(), (avif_file.len()+999)/1000, avif_file.len() - color_byte_size - alpha_byte_size);
                }
                fs::write(p, avif_file)
            },
            MaybePath::Stdio => {
                std::io::stdout().write_all(&avif_file)
            },
        }.map_err(|e| format!("Unable to write output image: {e}"))?;
        Ok(())
    };

    let failures = files.into_par_iter().map(|path| {
        let tmp;
        let (data, path_str): (_, &dyn std::fmt::Display) = match path {
            MaybePath::Stdio => {
                let mut data = Vec::new();
                std::io::stdin().read_to_end(&mut data)?;
                (data, &"stdin")
            },
            MaybePath::Path(ref path) => {
                let data = fs::read(path)
                    .map_err(|e| format!("Unable to read input image {}: {e}", path.display()))?;
                tmp = path.display();
                (data, &tmp)
            },
        };
        process(data, &path)
            .map_err(|e| BoxError::from(format!("{path_str}: error: {e}")))
    })
    .filter_map(|res| res.err())
    .collect::<Vec<BoxError>>();

    if !failures.is_empty() {
        if !quiet {
            for f in failures {
                eprintln!("error: {f}");
            }
        }
        std::process::exit(1);
    }
    Ok(())
}

#[cfg(not(feature = "cocoa_image"))]
fn load_rgba(data: &[u8], premultiplied_alpha: bool) -> Result<ImgVec<RGBA8>, BoxError> {
    use rgb::prelude::*;

    let img = load_image::load_data(data)?.into_imgvec();
    let mut img = match img {
        load_image::export::imgref::ImgVecKind::RGB8(img) => img.map_buf(|buf| buf.into_iter().map(|px| px.with_alpha(255)).collect()),
        load_image::export::imgref::ImgVecKind::RGBA8(img) => img,
        load_image::export::imgref::ImgVecKind::RGB16(img) => img.map_buf(|buf| buf.into_iter().map(|px| px.map(|c| (c >> 8) as u8).with_alpha(255)).collect()),
        load_image::export::imgref::ImgVecKind::RGBA16(img) => img.map_buf(|buf| buf.into_iter().map(|px| px.map(|c| (c >> 8) as u8)).collect()),
        load_image::export::imgref::ImgVecKind::GRAY8(img) => img.map_buf(|buf| buf.into_iter().map(|g| { let c = g.0; RGBA8::new(c,c,c,255) }).collect()),
        load_image::export::imgref::ImgVecKind::GRAY16(img) => img.map_buf(|buf| buf.into_iter().map(|g| { let c = (g.0>>8) as u8; RGBA8::new(c,c,c,255) }).collect()),
        load_image::export::imgref::ImgVecKind::GRAYA8(img) => img.map_buf(|buf| buf.into_iter().map(|g| { let c = g.0; RGBA8::new(c,c,c,g.1) }).collect()),
        load_image::export::imgref::ImgVecKind::GRAYA16(img) => img.map_buf(|buf| buf.into_iter().map(|g| { let c = (g.0>>8) as u8; RGBA8::new(c,c,c,(g.1>>8) as u8) }).collect()),
    };

    if premultiplied_alpha {
        img.pixels_mut().for_each(|px| {
            px.r = (u16::from(px.r) * u16::from(px.a) / 255) as u8;
            px.g = (u16::from(px.g) * u16::from(px.a) / 255) as u8;
            px.b = (u16::from(px.b) * u16::from(px.a) / 255) as u8;
        });
    }
    Ok(img)
}

#[cfg(feature = "cocoa_image")]
fn load_rgba(data: &[u8], premultiplied_alpha: bool) -> Result<ImgVec<RGBA8>, BoxError> {
    if premultiplied_alpha {
        Ok(cocoa_image::decode_image_as_rgba_premultiplied(data)?)
    } else {
        Ok(cocoa_image::decode_image_as_rgba(data)?)
    }
}