sprite-slicer 0.1.2

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, ComponentMode, DetectOptions, FrameAlign, GifOptions, GroupOptions,
    NormalizeOptions, ProcessSheetOptions, RemoveBgOptions, SliceOptions, detect_frames,
    export_gif, group_actions, normalize_frames, process_sprite_sheet, 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),
    Process(ProcessArgs),
}

#[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,
}

#[derive(Args, Debug)]
struct ProcessArgs {
    #[arg(long)]
    input: PathBuf,
    #[arg(long)]
    output_dir: PathBuf,
    #[arg(long)]
    rows: u32,
    #[arg(long)]
    cols: u32,
    #[arg(long)]
    cell_size: u32,
    #[arg(long, default_value = "#FF00FF")]
    bg_hex: String,
    #[arg(long, default_value_t = 100)]
    threshold: u8,
    #[arg(long, default_value_t = 150)]
    edge_threshold: u8,
    #[arg(long, default_value_t = 0.85)]
    fit_scale: f32,
    #[arg(long, default_value_t = 4)]
    trim_border: u32,
    #[arg(long, default_value_t = 3)]
    edge_clean_depth: u32,
    #[arg(long, default_value = "center")]
    align: String,
    #[arg(long, default_value_t = true)]
    shared_scale: bool,
    #[arg(long, default_value = "all")]
    component_mode: String,
    #[arg(long, default_value_t = 0)]
    component_padding: u32,
    #[arg(long, default_value_t = 1)]
    min_component_area: u32,
    #[arg(long, default_value_t = 0)]
    edge_touch_margin: u32,
    #[arg(long, default_value_t = false)]
    reject_edge_touch: bool,
    #[arg(long, default_value_t = 20)]
    gif_delay: u16,
    #[arg(long)]
    prompt: Option<String>,
    #[arg(long, value_delimiter = ',')]
    frame_labels: Option<Vec<String>>,
}

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);
        }
        Commands::Process(args) => {
            let output = process_sprite_sheet(ProcessSheetOptions {
                input: args.input,
                output_dir: args.output_dir,
                rows: args.rows,
                cols: args.cols,
                cell_size: args.cell_size,
                bg_hex: args.bg_hex,
                threshold: args.threshold,
                edge_threshold: args.edge_threshold,
                fit_scale: args.fit_scale,
                trim_border: args.trim_border,
                edge_clean_depth: args.edge_clean_depth,
                align: parse_frame_align(&args.align)?,
                shared_scale: args.shared_scale,
                component_mode: parse_component_mode(&args.component_mode)?,
                component_padding: args.component_padding,
                min_component_area: args.min_component_area,
                edge_touch_margin: args.edge_touch_margin,
                reject_edge_touch: args.reject_edge_touch,
                gif_delay: args.gif_delay,
                frame_labels: args.frame_labels,
                prompt: args.prompt,
            })?;
            println!("output dir: {}", output.output_dir.display());
            println!("sheet: {}", output.sheet_path.display());
            println!("gif: {}", output.gif_path.display());
            println!("metadata: {}", output.metadata_path.display());
            println!("frames: {}", output.frame_count);
            println!("edge-touch frames: {:?}", output.edge_touch_frames);
        }
    }
    Ok(())
}

fn parse_frame_align(input: &str) -> Result<FrameAlign> {
    match input {
        "center" => Ok(FrameAlign::Center),
        "bottom" => Ok(FrameAlign::Bottom),
        "feet" => Ok(FrameAlign::Feet),
        _ => Err(anyhow::anyhow!(
            "invalid align: {input}; use center|bottom|feet"
        )),
    }
}

fn parse_component_mode(input: &str) -> Result<ComponentMode> {
    match input {
        "all" => Ok(ComponentMode::All),
        "largest" => Ok(ComponentMode::Largest),
        _ => Err(anyhow::anyhow!(
            "invalid component-mode: {input}; use all|largest"
        )),
    }
}