sprite-slicer 0.1.0

Sprite-sheet slicing, transparent sprite detection, action grouping, background removal, frame normalization, and GIF preview export.
Documentation
use std::path::PathBuf;

use anyhow::Result;
use clap::{Args, Parser, Subcommand};
use sprite_slicer::{
    AnchorX, AnchorY, DetectOptions, GifOptions, GroupOptions, NormalizeOptions,
    RemoveBgOptions, SliceOptions, detect_frames, export_gif, group_actions, normalize_frames,
    remove_background, slice_sheet,
};

#[derive(Parser, Debug)]
#[command(
    author,
    version,
    about = "Slice sprite sheets into frames and regroup them into animation folders."
)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    Slice(SliceArgs),
    Detect(DetectArgs),
    Group(GroupArgsCli),
    Gif(GifArgs),
    RemoveBg(RemoveBgArgs),
    Normalize(NormalizeArgsCli),
}

#[derive(Args, Debug)]
struct SliceArgs {
    #[arg(long)]
    input: PathBuf,
    #[arg(long)]
    output: PathBuf,
    #[arg(long)]
    frame_width: u32,
    #[arg(long)]
    frame_height: u32,
    #[arg(long)]
    columns: Option<u32>,
    #[arg(long)]
    rows: Option<u32>,
    #[arg(long, default_value_t = 0)]
    offset_x: u32,
    #[arg(long, default_value_t = 0)]
    offset_y: u32,
    #[arg(long, default_value_t = 0)]
    gap_x: u32,
    #[arg(long, default_value_t = 0)]
    gap_y: u32,
    #[arg(long, default_value_t = false)]
    skip_empty: bool,
    #[arg(long, default_value_t = 0)]
    alpha_threshold: u8,
    #[arg(long, default_value_t = 1)]
    min_opaque_pixels: u32,
    #[arg(long)]
    bg_hex: Option<String>,
    #[arg(long, default_value_t = 0)]
    bg_threshold: u8,
    #[arg(long, default_value = "frames.toml")]
    manifest_name: String,
}

#[derive(Args, Debug)]
struct DetectArgs {
    #[arg(long)]
    input: PathBuf,
    #[arg(long)]
    output: PathBuf,
    #[arg(long, default_value_t = 0)]
    alpha_threshold: u8,
    #[arg(long, default_value_t = 64)]
    min_opaque_pixels: u32,
    #[arg(long, default_value_t = 0)]
    padding: u32,
    #[arg(long, default_value_t = 24)]
    row_tolerance: u32,
    #[arg(long)]
    bg_hex: Option<String>,
    #[arg(long, default_value_t = 0)]
    bg_threshold: u8,
    #[arg(long, default_value = "frames.toml")]
    manifest_name: String,
}

#[derive(Args, Debug)]
struct GroupArgsCli {
    #[arg(long)]
    manifest: PathBuf,
    #[arg(long)]
    config: PathBuf,
    #[arg(long)]
    output: PathBuf,
}

#[derive(Args, Debug)]
struct GifArgs {
    #[arg(long)]
    input: PathBuf,
    #[arg(long)]
    output: PathBuf,
    #[arg(long, default_value_t = 8)]
    fps: u16,
    #[arg(long, default_value_t = 0)]
    repeat: u16,
    #[arg(long, default_value_t = 0)]
    pad: u32,
}

#[derive(Args, Debug)]
struct RemoveBgArgs {
    #[arg(long)]
    input: PathBuf,
    #[arg(long)]
    output: PathBuf,
    #[arg(long, default_value = "#000000")]
    bg_hex: String,
    #[arg(long, default_value_t = 8)]
    threshold: u8,
    #[arg(long, default_value_t = 0)]
    alpha_threshold: u8,
}

#[derive(Args, Debug)]
struct NormalizeArgsCli {
    #[arg(long)]
    input: PathBuf,
    #[arg(long)]
    output: PathBuf,
    #[arg(long)]
    width: Option<u32>,
    #[arg(long)]
    height: Option<u32>,
    #[arg(long, default_value = "center")]
    anchor_x: String,
    #[arg(long, default_value = "bottom")]
    anchor_y: String,
    #[arg(long, default_value_t = 0)]
    pad: u32,
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    match cli.command {
        Commands::Slice(args) => {
            let output = slice_sheet(SliceOptions {
                input: args.input,
                output: args.output,
                frame_width: args.frame_width,
                frame_height: args.frame_height,
                columns: args.columns,
                rows: args.rows,
                offset_x: args.offset_x,
                offset_y: args.offset_y,
                gap_x: args.gap_x,
                gap_y: args.gap_y,
                skip_empty: args.skip_empty,
                alpha_threshold: args.alpha_threshold,
                min_opaque_pixels: args.min_opaque_pixels,
                bg_hex: args.bg_hex,
                bg_threshold: args.bg_threshold,
                manifest_name: args.manifest_name,
            })?;
            println!("saved manifest: {}", output.manifest_path.display());
            println!("saved frame map: {}", output.index_map_path.display());
            println!(
                "exported {} frames ({} kept)",
                output.frame_count, output.kept_frames
            );
        }
        Commands::Detect(args) => {
            let output = detect_frames(DetectOptions {
                input: args.input,
                output: args.output,
                alpha_threshold: args.alpha_threshold,
                min_opaque_pixels: args.min_opaque_pixels,
                padding: args.padding,
                row_tolerance: args.row_tolerance,
                bg_hex: args.bg_hex,
                bg_threshold: args.bg_threshold,
                manifest_name: args.manifest_name,
            })?;
            println!("saved manifest: {}", output.manifest_path.display());
            println!("saved frame map: {}", output.index_map_path.display());
            println!(
                "detected {} frames in {} rows",
                output.detected_frames, output.rows
            );
        }
        Commands::Group(args) => {
            let output = group_actions(GroupOptions {
                manifest: args.manifest,
                config: args.config,
                output: args.output,
            })?;
            println!("saved grouped actions: {}", output.manifest_path.display());
            for action in output.actions {
                println!("action {}: {} frames", action.name, action.frame_count);
            }
        }
        Commands::Gif(args) => {
            let output = export_gif(GifOptions {
                input: args.input,
                output: args.output,
                fps: args.fps,
                repeat: args.repeat,
                pad: args.pad,
            })?;
            println!("saved gif: {}", output.output_path.display());
            println!("frames: {}", output.frame_count);
            println!("canvas: {}x{}", output.canvas_width, output.canvas_height);
            println!("fps: {}", output.fps);
        }
        Commands::RemoveBg(args) => {
            let output = remove_background(RemoveBgOptions {
                input: args.input,
                output: args.output,
                bg_hex: args.bg_hex,
                threshold: args.threshold,
                alpha_threshold: args.alpha_threshold,
            })?;
            println!("saved transparent png: {}", output.output_path.display());
            println!("removed background pixels: {}", output.removed_pixels);
        }
        Commands::Normalize(args) => {
            let output = normalize_frames(NormalizeOptions {
                input: args.input,
                output: args.output,
                width: args.width,
                height: args.height,
                anchor_x: args
                    .anchor_x
                    .parse::<AnchorX>()
                    .map_err(|error| anyhow::anyhow!(error))?,
                anchor_y: args
                    .anchor_y
                    .parse::<AnchorY>()
                    .map_err(|error| anyhow::anyhow!(error))?,
                pad: args.pad,
            })?;
            println!("saved normalized frames: {}", output.output_dir.display());
            println!("canvas: {}x{}", output.canvas_width, output.canvas_height);
            println!("frames: {}", output.frame_count);
            println!("anchor: {} {}", output.anchor_x, output.anchor_y);
        }
    }
    Ok(())
}