use std::collections::HashMap;
use std::collections::VecDeque;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use anyhow::{Context, Result, bail};
use gif::{DisposalMethod, Encoder, Frame, Repeat};
use image::{Rgba, RgbaImage, imageops::FilterType};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct SliceOptions {
pub input: PathBuf,
pub output: PathBuf,
pub frame_width: u32,
pub frame_height: u32,
pub columns: Option<u32>,
pub rows: Option<u32>,
pub offset_x: u32,
pub offset_y: u32,
pub gap_x: u32,
pub gap_y: u32,
pub skip_empty: bool,
pub alpha_threshold: u8,
pub min_opaque_pixels: u32,
pub bg_hex: Option<String>,
pub bg_threshold: u8,
pub manifest_name: String,
}
#[derive(Debug, Clone)]
pub struct DetectOptions {
pub input: PathBuf,
pub output: PathBuf,
pub alpha_threshold: u8,
pub min_opaque_pixels: u32,
pub padding: u32,
pub row_tolerance: u32,
pub bg_hex: Option<String>,
pub bg_threshold: u8,
pub manifest_name: String,
}
#[derive(Debug, Clone)]
pub struct GroupOptions {
pub manifest: PathBuf,
pub config: PathBuf,
pub output: PathBuf,
}
#[derive(Debug, Clone)]
pub struct GifOptions {
pub input: PathBuf,
pub output: PathBuf,
pub fps: u16,
pub repeat: u16,
pub pad: u32,
}
#[derive(Debug, Clone)]
pub struct RemoveBgOptions {
pub input: PathBuf,
pub output: PathBuf,
pub bg_hex: String,
pub threshold: u8,
pub alpha_threshold: u8,
}
#[derive(Debug, Clone)]
pub struct NormalizeOptions {
pub input: PathBuf,
pub output: PathBuf,
pub width: Option<u32>,
pub height: Option<u32>,
pub anchor_x: AnchorX,
pub anchor_y: AnchorY,
pub pad: u32,
}
#[derive(Debug, Clone)]
pub struct SliceOutput {
pub manifest_path: PathBuf,
pub index_map_path: PathBuf,
pub frame_count: usize,
pub kept_frames: usize,
}
#[derive(Debug, Clone)]
pub struct DetectOutput {
pub manifest_path: PathBuf,
pub index_map_path: PathBuf,
pub detected_frames: usize,
pub rows: usize,
}
#[derive(Debug, Clone)]
pub struct GroupedActionSummary {
pub name: String,
pub frame_count: usize,
}
#[derive(Debug, Clone)]
pub struct GroupOutputSummary {
pub manifest_path: PathBuf,
pub actions: Vec<GroupedActionSummary>,
}
#[derive(Debug, Clone)]
pub struct GifOutput {
pub output_path: PathBuf,
pub frame_count: usize,
pub canvas_width: u32,
pub canvas_height: u32,
pub fps: u16,
}
#[derive(Debug, Clone)]
pub struct RemoveBgOutput {
pub output_path: PathBuf,
pub removed_pixels: u32,
}
#[derive(Debug, Clone)]
pub struct NormalizeOutput {
pub output_dir: PathBuf,
pub frame_count: usize,
pub canvas_width: u32,
pub canvas_height: u32,
pub anchor_x: AnchorX,
pub anchor_y: AnchorY,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FrameAlign {
Center,
Bottom,
Feet,
}
impl FrameAlign {
fn to_anchor_y(self) -> AnchorY {
match self {
Self::Center => AnchorY::Center,
Self::Bottom | Self::Feet => AnchorY::Bottom,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentMode {
All,
Largest,
}
#[derive(Debug, Clone)]
pub struct ProcessSheetOptions {
pub input: PathBuf,
pub output_dir: PathBuf,
pub rows: u32,
pub cols: u32,
pub cell_size: u32,
pub bg_hex: String,
pub threshold: u8,
pub edge_threshold: u8,
pub fit_scale: f32,
pub trim_border: u32,
pub edge_clean_depth: u32,
pub align: FrameAlign,
pub shared_scale: bool,
pub component_mode: ComponentMode,
pub component_padding: u32,
pub min_component_area: u32,
pub edge_touch_margin: u32,
pub reject_edge_touch: bool,
pub gif_delay: u16,
pub frame_labels: Option<Vec<String>>,
pub prompt: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ProcessSheetOutput {
pub output_dir: PathBuf,
pub sheet_path: PathBuf,
pub gif_path: PathBuf,
pub metadata_path: PathBuf,
pub frame_paths: Vec<PathBuf>,
pub frame_count: usize,
pub edge_touch_frames: Vec<[u32; 2]>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessSheetMetadata {
pub input: PathBuf,
pub raw_sheet: PathBuf,
pub raw_sheet_clean: PathBuf,
pub rows: u32,
pub cols: u32,
pub cell_size: u32,
pub threshold: u8,
pub edge_threshold: u8,
pub fit_scale: f32,
pub trim_border: u32,
pub edge_clean_depth: u32,
pub align: FrameAlign,
pub shared_scale: bool,
pub component_mode: ComponentMode,
pub component_padding: u32,
pub min_component_area: u32,
pub edge_touch_margin: u32,
pub reject_edge_touch: bool,
pub gif_delay: u16,
pub frame_labels: Vec<String>,
pub edge_touch_frames: Vec<[u32; 2]>,
pub frames: Vec<ProcessedFrameInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessedFrameInfo {
pub grid: [u32; 2],
pub source_box: [u32; 4],
pub component_mode: ComponentMode,
pub component_count: usize,
pub selected_component_area: Option<u32>,
pub selected_component_bbox: Option<[u32; 4]>,
pub crop_bbox: Option<[u32; 4]>,
pub edge_touch: bool,
pub output_size: [u32; 2],
pub paste_position: [u32; 2],
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SliceManifest {
pub source: PathBuf,
pub frame_width: u32,
pub frame_height: u32,
pub columns: u32,
pub rows: u32,
pub offset_x: u32,
pub offset_y: u32,
pub gap_x: u32,
pub gap_y: u32,
pub alpha_threshold: u8,
pub min_opaque_pixels: u32,
pub bg_hex: Option<String>,
pub bg_threshold: u8,
pub detection: DetectionMode,
pub frames: Vec<FrameRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrameRecord {
pub index: usize,
pub row: u32,
pub column: u32,
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
pub opaque_pixels: u32,
pub kept: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DetectionMode {
Grid,
ConnectedComponents,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ActionSpec {
pub name: String,
pub frames: Vec<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnchorX {
Left,
Center,
Right,
}
impl fmt::Display for AnchorX {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = match self {
Self::Left => "left",
Self::Center => "center",
Self::Right => "right",
};
f.write_str(value)
}
}
impl FromStr for AnchorX {
type Err = String;
fn from_str(input: &str) -> std::result::Result<Self, Self::Err> {
match input {
"left" => Ok(Self::Left),
"center" => Ok(Self::Center),
"right" => Ok(Self::Right),
_ => Err(format!("invalid anchor-x: {input}; use left|center|right")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnchorY {
Top,
Center,
Bottom,
}
impl fmt::Display for AnchorY {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = match self {
Self::Top => "top",
Self::Center => "center",
Self::Bottom => "bottom",
};
f.write_str(value)
}
}
impl FromStr for AnchorY {
type Err = String;
fn from_str(input: &str) -> std::result::Result<Self, Self::Err> {
match input {
"top" => Ok(Self::Top),
"center" => Ok(Self::Center),
"bottom" => Ok(Self::Bottom),
_ => Err(format!("invalid anchor-y: {input}; use top|center|bottom")),
}
}
}
#[derive(Debug, Deserialize)]
struct ActionConfig {
actions: Vec<ActionSpec>,
}
#[derive(Debug, Serialize)]
struct GroupManifest {
source_manifest: PathBuf,
actions: Vec<GroupManifestAction>,
}
#[derive(Debug, Serialize)]
struct GroupManifestAction {
name: String,
source_frames: Vec<usize>,
files: Vec<String>,
}
#[derive(Debug, Clone)]
struct ComponentBounds {
x: u32,
y: u32,
width: u32,
height: u32,
opaque_pixels: u32,
center_y: f32,
}
pub fn slice_sheet(options: SliceOptions) -> Result<SliceOutput> {
let image = image::open(&options.input)
.with_context(|| format!("failed to open image {}", options.input.display()))?
.to_rgba8();
let columns = options.columns.unwrap_or_else(|| {
derive_grid_count(
image.width(),
options.offset_x,
options.frame_width,
options.gap_x,
)
});
let rows = options.rows.unwrap_or_else(|| {
derive_grid_count(
image.height(),
options.offset_y,
options.frame_height,
options.gap_y,
)
});
validate_grid(
image.width(),
image.height(),
columns,
rows,
options.offset_x,
options.offset_y,
options.frame_width,
options.frame_height,
options.gap_x,
options.gap_y,
)?;
let bg_color = match options.bg_hex.as_deref() {
Some(value) => Some(parse_hex_color(value)?),
None => None,
};
fs::create_dir_all(&options.output)
.with_context(|| format!("failed to create {}", options.output.display()))?;
let frames_dir = options.output.join("frames");
fs::create_dir_all(&frames_dir)
.with_context(|| format!("failed to create {}", frames_dir.display()))?;
let mut frames = Vec::with_capacity((columns * rows) as usize);
for row in 0..rows {
for column in 0..columns {
let x = options.offset_x + column * (options.frame_width + options.gap_x);
let y = options.offset_y + row * (options.frame_height + options.gap_y);
let tile =
image::imageops::crop_imm(&image, x, y, options.frame_width, options.frame_height)
.to_image();
let opaque_pixels = count_foreground_pixels(
&tile,
bg_color,
options.bg_threshold,
options.alpha_threshold,
);
let kept = !options.skip_empty || opaque_pixels >= options.min_opaque_pixels;
let index = (row * columns + column) as usize;
let file = if kept {
let file_name = format!("frame_{index:04}_r{row:02}_c{column:02}.png");
let relative = PathBuf::from("frames").join(file_name);
let full_path = options.output.join(&relative);
tile.save(&full_path).with_context(|| {
format!("failed to save sliced frame {}", full_path.display())
})?;
Some(relative.to_string_lossy().to_string())
} else {
None
};
frames.push(FrameRecord {
index,
row,
column,
x,
y,
width: options.frame_width,
height: options.frame_height,
opaque_pixels,
kept,
file,
});
}
}
let manifest = SliceManifest {
source: canonicalize_if_possible(&options.input),
frame_width: options.frame_width,
frame_height: options.frame_height,
columns,
rows,
offset_x: options.offset_x,
offset_y: options.offset_y,
gap_x: options.gap_x,
gap_y: options.gap_y,
alpha_threshold: options.alpha_threshold,
min_opaque_pixels: options.min_opaque_pixels,
bg_hex: options.bg_hex,
bg_threshold: options.bg_threshold,
detection: DetectionMode::Grid,
frames,
};
let manifest_path = options.output.join(&options.manifest_name);
fs::write(&manifest_path, toml::to_string_pretty(&manifest)?)
.with_context(|| format!("failed to write {}", manifest_path.display()))?;
let index_map_path = options.output.join("index-map.txt");
fs::write(&index_map_path, build_index_map(&manifest))
.with_context(|| format!("failed to write {}", index_map_path.display()))?;
Ok(SliceOutput {
manifest_path,
index_map_path,
frame_count: manifest.frames.len(),
kept_frames: manifest.frames.iter().filter(|frame| frame.kept).count(),
})
}
pub fn detect_frames(options: DetectOptions) -> Result<DetectOutput> {
let image = image::open(&options.input)
.with_context(|| format!("failed to open image {}", options.input.display()))?
.to_rgba8();
let bg_color = match options.bg_hex.as_deref() {
Some(value) => Some(parse_hex_color(value)?),
None => None,
};
let components = detect_components(
&image,
bg_color,
options.bg_threshold,
options.alpha_threshold,
options.min_opaque_pixels,
options.padding,
);
if components.is_empty() {
bail!("no components matched; lower --min-opaque-pixels or adjust thresholds");
}
let rows = assign_rows(&components, options.row_tolerance);
let max_columns = rows.iter().map(|row| row.len()).max().unwrap_or(0) as u32;
fs::create_dir_all(&options.output)
.with_context(|| format!("failed to create {}", options.output.display()))?;
let frames_dir = options.output.join("frames");
fs::create_dir_all(&frames_dir)
.with_context(|| format!("failed to create {}", frames_dir.display()))?;
let mut frames = Vec::new();
for (row_index, row) in rows.iter().enumerate() {
for (column_index, component_index) in row.iter().enumerate() {
let component = &components[*component_index];
let tile = image::imageops::crop_imm(
&image,
component.x,
component.y,
component.width,
component.height,
)
.to_image();
let index = frames.len();
let file_name = format!("frame_{index:04}_r{row_index:02}_c{column_index:02}.png");
let relative = PathBuf::from("frames").join(file_name);
let full_path = options.output.join(&relative);
tile.save(&full_path).with_context(|| {
format!("failed to save detected frame {}", full_path.display())
})?;
frames.push(FrameRecord {
index,
row: row_index as u32,
column: column_index as u32,
x: component.x,
y: component.y,
width: component.width,
height: component.height,
opaque_pixels: component.opaque_pixels,
kept: true,
file: Some(relative.to_string_lossy().to_string()),
});
}
}
let manifest = SliceManifest {
source: canonicalize_if_possible(&options.input),
frame_width: 0,
frame_height: 0,
columns: max_columns,
rows: rows.len() as u32,
offset_x: 0,
offset_y: 0,
gap_x: 0,
gap_y: 0,
alpha_threshold: options.alpha_threshold,
min_opaque_pixels: options.min_opaque_pixels,
bg_hex: options.bg_hex,
bg_threshold: options.bg_threshold,
detection: DetectionMode::ConnectedComponents,
frames,
};
let manifest_path = options.output.join(&options.manifest_name);
fs::write(&manifest_path, toml::to_string_pretty(&manifest)?)
.with_context(|| format!("failed to write {}", manifest_path.display()))?;
let index_map_path = options.output.join("index-map.txt");
fs::write(
&index_map_path,
build_sparse_index_map(&rows, &manifest.frames),
)
.with_context(|| format!("failed to write {}", index_map_path.display()))?;
Ok(DetectOutput {
manifest_path,
index_map_path,
detected_frames: manifest.frames.len(),
rows: rows.len(),
})
}
pub fn group_actions(options: GroupOptions) -> Result<GroupOutputSummary> {
let manifest_text = fs::read_to_string(&options.manifest)
.with_context(|| format!("failed to read {}", options.manifest.display()))?;
let manifest: SliceManifest = toml::from_str(&manifest_text)
.with_context(|| format!("failed to parse {}", options.manifest.display()))?;
let config_text = fs::read_to_string(&options.config)
.with_context(|| format!("failed to read {}", options.config.display()))?;
let config: ActionConfig = toml::from_str(&config_text)
.with_context(|| format!("failed to parse {}", options.config.display()))?;
fs::create_dir_all(&options.output)
.with_context(|| format!("failed to create {}", options.output.display()))?;
let frame_lookup: HashMap<usize, &FrameRecord> = manifest
.frames
.iter()
.map(|frame| (frame.index, frame))
.collect();
let manifest_root = options
.manifest
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let mut manifest_actions = Vec::with_capacity(config.actions.len());
let mut summary = Vec::with_capacity(config.actions.len());
for action in config.actions {
let action_dir = options.output.join(&action.name);
fs::create_dir_all(&action_dir)
.with_context(|| format!("failed to create {}", action_dir.display()))?;
let mut exported_files = Vec::with_capacity(action.frames.len());
for (sequence, frame_index) in action.frames.iter().enumerate() {
let frame = frame_lookup
.get(frame_index)
.copied()
.with_context(|| format!("frame index {frame_index} is not present in manifest"))?;
let relative_file = frame.file.as_deref().with_context(|| {
format!("frame index {frame_index} was not exported; try disabling --skip-empty")
})?;
let source_path = manifest_root.join(relative_file);
let destination_name = format!("{sequence:04}.png");
let destination_path = action_dir.join(&destination_name);
fs::copy(&source_path, &destination_path).with_context(|| {
format!(
"failed to copy {} to {}",
source_path.display(),
destination_path.display()
)
})?;
exported_files.push(
PathBuf::from(&action.name)
.join(destination_name)
.to_string_lossy()
.to_string(),
);
}
summary.push(GroupedActionSummary {
name: action.name.clone(),
frame_count: action.frames.len(),
});
manifest_actions.push(GroupManifestAction {
name: action.name,
source_frames: action.frames,
files: exported_files,
});
}
let grouped_manifest = GroupManifest {
source_manifest: canonicalize_if_possible(&options.manifest),
actions: manifest_actions,
};
let manifest_path = options.output.join("actions.toml");
fs::write(&manifest_path, toml::to_string_pretty(&grouped_manifest)?)
.with_context(|| format!("failed to write {}", manifest_path.display()))?;
Ok(GroupOutputSummary {
manifest_path,
actions: summary,
})
}
pub fn export_gif(options: GifOptions) -> Result<GifOutput> {
let mut frame_paths = collect_png_files(&options.input)?;
if frame_paths.is_empty() {
bail!("no png frames found under {}", options.input.display());
}
frame_paths.sort();
let mut decoded_frames = Vec::with_capacity(frame_paths.len());
let mut canvas_width = 0_u32;
let mut canvas_height = 0_u32;
for path in &frame_paths {
let image = image::open(path)
.with_context(|| format!("failed to open frame {}", path.display()))?
.to_rgba8();
canvas_width = canvas_width.max(image.width());
canvas_height = canvas_height.max(image.height());
decoded_frames.push(image);
}
canvas_width += options.pad * 2;
canvas_height += options.pad * 2;
if canvas_width > u16::MAX as u32 || canvas_height > u16::MAX as u32 {
bail!("gif canvas too large: {}x{}", canvas_width, canvas_height);
}
if let Some(parent) = options.output.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let file = fs::File::create(&options.output)
.with_context(|| format!("failed to create {}", options.output.display()))?;
let mut encoder = Encoder::new(file, canvas_width as u16, canvas_height as u16, &[])
.with_context(|| format!("failed to initialize gif {}", options.output.display()))?;
if options.repeat == 0 {
encoder.set_repeat(Repeat::Infinite)?;
} else {
encoder.set_repeat(Repeat::Finite(options.repeat))?;
}
let delay = fps_to_gif_delay(options.fps);
for image in decoded_frames {
let mut canvas = RgbaImage::new(canvas_width, canvas_height);
let offset_x = ((canvas_width - image.width()) / 2) as i64;
let offset_y = ((canvas_height - image.height()) / 2) as i64;
image::imageops::overlay(&mut canvas, &image, offset_x, offset_y);
let mut rgba = canvas.into_raw();
let mut frame =
Frame::from_rgba_speed(canvas_width as u16, canvas_height as u16, &mut rgba, 10);
frame.delay = delay;
encoder
.write_frame(&frame)
.with_context(|| format!("failed writing gif frame to {}", options.output.display()))?;
}
Ok(GifOutput {
output_path: options.output,
frame_count: frame_paths.len(),
canvas_width,
canvas_height,
fps: options.fps,
})
}
pub fn remove_background(options: RemoveBgOptions) -> Result<RemoveBgOutput> {
let mut image = image::open(&options.input)
.with_context(|| format!("failed to open image {}", options.input.display()))?
.to_rgba8();
let bg_color = parse_hex_color(&options.bg_hex)?;
let removed_pixels = remove_connected_background(
&mut image,
bg_color,
options.threshold,
options.alpha_threshold,
);
if let Some(parent) = options.output.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
image
.save(&options.output)
.with_context(|| format!("failed to save {}", options.output.display()))?;
Ok(RemoveBgOutput {
output_path: options.output,
removed_pixels,
})
}
pub fn normalize_frames(options: NormalizeOptions) -> Result<NormalizeOutput> {
let mut frame_paths = collect_png_files(&options.input)?;
if frame_paths.is_empty() {
bail!("no png frames found under {}", options.input.display());
}
frame_paths.sort();
let mut images = Vec::with_capacity(frame_paths.len());
let mut target_width = options.width.unwrap_or(0);
let mut target_height = options.height.unwrap_or(0);
for path in &frame_paths {
let image = image::open(path)
.with_context(|| format!("failed to open frame {}", path.display()))?
.to_rgba8();
target_width = target_width.max(image.width());
target_height = target_height.max(image.height());
images.push(image);
}
target_width += options.pad * 2;
target_height += options.pad * 2;
fs::create_dir_all(&options.output)
.with_context(|| format!("failed to create {}", options.output.display()))?;
for (index, image) in images.into_iter().enumerate() {
let mut canvas = RgbaImage::new(target_width, target_height);
let offset_x =
horizontal_offset(target_width, image.width(), options.pad, options.anchor_x);
let offset_y =
vertical_offset(target_height, image.height(), options.pad, options.anchor_y);
image::imageops::overlay(&mut canvas, &image, offset_x as i64, offset_y as i64);
let output_name = format!("{index:04}.png");
let output_path = options.output.join(output_name);
canvas
.save(&output_path)
.with_context(|| format!("failed to save {}", output_path.display()))?;
}
Ok(NormalizeOutput {
output_dir: options.output,
frame_count: frame_paths.len(),
canvas_width: target_width,
canvas_height: target_height,
anchor_x: options.anchor_x,
anchor_y: options.anchor_y,
})
}
pub fn process_sprite_sheet(options: ProcessSheetOptions) -> Result<ProcessSheetOutput> {
if options.rows == 0 || options.cols == 0 {
bail!("rows and cols must be greater than zero");
}
if !(0.0 < options.fit_scale && options.fit_scale <= 1.0) {
bail!("fit_scale must be within (0, 1]");
}
if options.cell_size == 0 {
bail!("cell_size must be greater than zero");
}
let frame_count = (options.rows * options.cols) as usize;
let labels = match options.frame_labels.clone() {
Some(labels) => {
if labels.len() != frame_count {
bail!(
"frame_labels length mismatch: expected {}, got {}",
frame_count,
labels.len()
);
}
labels
}
None => (0..frame_count)
.map(|index| format!("frame-{:04}", index + 1))
.collect(),
};
fs::create_dir_all(&options.output_dir)
.with_context(|| format!("failed to create {}", options.output_dir.display()))?;
let raw = image::open(&options.input)
.with_context(|| format!("failed to open image {}", options.input.display()))?
.to_rgba8();
validate_sheet_divisible(raw.width(), raw.height(), options.rows, options.cols)?;
let raw_sheet_path = options.output_dir.join("raw-sheet.png");
raw.save(&raw_sheet_path)
.with_context(|| format!("failed to save {}", raw_sheet_path.display()))?;
let bg_color = parse_hex_color(&options.bg_hex)?;
let mut cleaned_sheet = raw.clone();
remove_bg_by_distance(
&mut cleaned_sheet,
bg_color,
options.threshold,
options.edge_threshold,
);
let raw_sheet_clean_path = options.output_dir.join("raw-sheet-clean.png");
cleaned_sheet
.save(&raw_sheet_clean_path)
.with_context(|| format!("failed to save {}", raw_sheet_clean_path.display()))?;
let (frames, frame_info) = split_and_normalize_grid(&cleaned_sheet, &options)?;
let edge_touch_frames: Vec<[u32; 2]> = frame_info
.iter()
.filter(|info| info.edge_touch)
.map(|info| info.grid)
.collect();
if options.reject_edge_touch && !edge_touch_frames.is_empty() {
bail!("frames touch a cell edge: {:?}", edge_touch_frames);
}
let mut frame_paths = Vec::with_capacity(frames.len());
for (label, frame) in labels.iter().zip(&frames) {
let path = options.output_dir.join(format!("{label}.png"));
frame
.save(&path)
.with_context(|| format!("failed to save {}", path.display()))?;
frame_paths.push(path);
}
let sheet = compose_grid_sheet(&frames, options.rows, options.cols, options.cell_size);
let sheet_path = options.output_dir.join("sheet-transparent.png");
sheet
.save(&sheet_path)
.with_context(|| format!("failed to save {}", sheet_path.display()))?;
let gif_path = options.output_dir.join("animation.gif");
save_transparent_gif_frames(&frames, &gif_path, options.gif_delay)?;
if let Some(prompt) = options.prompt {
let prompt_path = options.output_dir.join("prompt-used.txt");
fs::write(&prompt_path, prompt)
.with_context(|| format!("failed to write {}", prompt_path.display()))?;
}
let metadata = ProcessSheetMetadata {
input: canonicalize_if_possible(&options.input),
raw_sheet: raw_sheet_path.clone(),
raw_sheet_clean: raw_sheet_clean_path.clone(),
rows: options.rows,
cols: options.cols,
cell_size: options.cell_size,
threshold: options.threshold,
edge_threshold: options.edge_threshold,
fit_scale: options.fit_scale,
trim_border: options.trim_border,
edge_clean_depth: options.edge_clean_depth,
align: options.align,
shared_scale: options.shared_scale,
component_mode: options.component_mode,
component_padding: options.component_padding,
min_component_area: options.min_component_area,
edge_touch_margin: options.edge_touch_margin,
reject_edge_touch: options.reject_edge_touch,
gif_delay: options.gif_delay,
frame_labels: labels,
edge_touch_frames,
frames: frame_info,
};
let metadata_path = options.output_dir.join("pipeline-meta.json");
fs::write(&metadata_path, serde_json::to_string_pretty(&metadata)?)
.with_context(|| format!("failed to write {}", metadata_path.display()))?;
Ok(ProcessSheetOutput {
output_dir: options.output_dir,
sheet_path,
gif_path,
metadata_path,
frame_paths,
frame_count,
edge_touch_frames: metadata.edge_touch_frames.clone(),
})
}
fn derive_grid_count(total: u32, offset: u32, frame: u32, gap: u32) -> u32 {
if total <= offset || total < offset + frame {
return 0;
}
let step = frame + gap;
1 + (total - offset - frame) / step
}
fn validate_grid(
image_width: u32,
image_height: u32,
columns: u32,
rows: u32,
offset_x: u32,
offset_y: u32,
frame_width: u32,
frame_height: u32,
gap_x: u32,
gap_y: u32,
) -> Result<()> {
if columns == 0 || rows == 0 {
bail!("grid resolved to zero columns or rows");
}
let last_right = offset_x + columns * frame_width + columns.saturating_sub(1) * gap_x;
let last_bottom = offset_y + rows * frame_height + rows.saturating_sub(1) * gap_y;
if last_right > image_width || last_bottom > image_height {
bail!(
"grid exceeds image bounds: need {}x{}, image is {}x{}",
last_right,
last_bottom,
image_width,
image_height
);
}
Ok(())
}
fn parse_hex_color(input: &str) -> Result<[u8; 3]> {
let trimmed = input.trim().trim_start_matches('#');
if trimmed.len() != 6 {
bail!("background color must be a 6-digit hex value, got {input}");
}
let red = u8::from_str_radix(&trimmed[0..2], 16)
.with_context(|| format!("invalid red channel in {input}"))?;
let green = u8::from_str_radix(&trimmed[2..4], 16)
.with_context(|| format!("invalid green channel in {input}"))?;
let blue = u8::from_str_radix(&trimmed[4..6], 16)
.with_context(|| format!("invalid blue channel in {input}"))?;
Ok([red, green, blue])
}
fn count_foreground_pixels(
image: &RgbaImage,
bg_color: Option<[u8; 3]>,
bg_threshold: u8,
alpha_threshold: u8,
) -> u32 {
image
.pixels()
.filter(|pixel| {
if pixel[3] <= alpha_threshold {
return false;
}
match bg_color {
Some(color) => !channels_close([pixel[0], pixel[1], pixel[2]], color, bg_threshold),
None => true,
}
})
.count() as u32
}
fn detect_components(
image: &RgbaImage,
bg_color: Option<[u8; 3]>,
bg_threshold: u8,
alpha_threshold: u8,
min_opaque_pixels: u32,
padding: u32,
) -> Vec<ComponentBounds> {
let width = image.width() as usize;
let height = image.height() as usize;
let mut foreground = vec![false; width * height];
for y in 0..height {
for x in 0..width {
let pixel = image.get_pixel(x as u32, y as u32);
let is_foreground = pixel[3] > alpha_threshold
&& match bg_color {
Some(color) => {
!channels_close([pixel[0], pixel[1], pixel[2]], color, bg_threshold)
}
None => true,
};
foreground[y * width + x] = is_foreground;
}
}
let mut visited = vec![false; width * height];
let mut components = Vec::new();
for y in 0..height {
for x in 0..width {
let start = y * width + x;
if !foreground[start] || visited[start] {
continue;
}
let mut queue = VecDeque::from([(x as u32, y as u32)]);
visited[start] = true;
let mut min_x = x as u32;
let mut max_x = x as u32;
let mut min_y = y as u32;
let mut max_y = y as u32;
let mut opaque_pixels = 0_u32;
while let Some((cx, cy)) = queue.pop_front() {
opaque_pixels += 1;
min_x = min_x.min(cx);
max_x = max_x.max(cx);
min_y = min_y.min(cy);
max_y = max_y.max(cy);
for (nx, ny) in neighbors(cx, cy, image.width(), image.height()) {
let idx = ny as usize * width + nx as usize;
if foreground[idx] && !visited[idx] {
visited[idx] = true;
queue.push_back((nx, ny));
}
}
}
if opaque_pixels < min_opaque_pixels {
continue;
}
let padded_x = min_x.saturating_sub(padding);
let padded_y = min_y.saturating_sub(padding);
let padded_right = (max_x + 1 + padding).min(image.width());
let padded_bottom = (max_y + 1 + padding).min(image.height());
let padded_width = padded_right - padded_x;
let padded_height = padded_bottom - padded_y;
components.push(ComponentBounds {
x: padded_x,
y: padded_y,
width: padded_width,
height: padded_height,
opaque_pixels,
center_y: (min_y + max_y) as f32 / 2.0,
});
}
}
components.sort_by(|left, right| {
left.y
.cmp(&right.y)
.then(left.x.cmp(&right.x))
.then(right.opaque_pixels.cmp(&left.opaque_pixels))
});
components
}
fn neighbors(x: u32, y: u32, width: u32, height: u32) -> impl Iterator<Item = (u32, u32)> {
let mut items = Vec::with_capacity(8);
for dy in -1_i32..=1 {
for dx in -1_i32..=1 {
if dx == 0 && dy == 0 {
continue;
}
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if nx >= 0 && ny >= 0 && nx < width as i32 && ny < height as i32 {
items.push((nx as u32, ny as u32));
}
}
}
items.into_iter()
}
fn assign_rows(components: &[ComponentBounds], row_tolerance: u32) -> Vec<Vec<usize>> {
let mut order: Vec<usize> = (0..components.len()).collect();
order.sort_by(|left, right| {
components[*left]
.center_y
.total_cmp(&components[*right].center_y)
.then(components[*left].x.cmp(&components[*right].x))
});
let mut rows: Vec<Vec<usize>> = Vec::new();
let mut row_centers: Vec<f32> = Vec::new();
for component_index in order {
let center_y = components[component_index].center_y;
if let Some((row_index, _)) = row_centers
.iter()
.enumerate()
.find(|(_, row_center)| (center_y - **row_center).abs() <= row_tolerance as f32)
{
rows[row_index].push(component_index);
let count = rows[row_index].len() as f32;
row_centers[row_index] = ((row_centers[row_index] * (count - 1.0)) + center_y) / count;
} else {
rows.push(vec![component_index]);
row_centers.push(center_y);
}
}
for row in &mut rows {
row.sort_by_key(|component_index| components[*component_index].x);
}
rows.sort_by_key(|row| components[row[0]].y);
rows
}
fn channels_close(lhs: [u8; 3], rhs: [u8; 3], threshold: u8) -> bool {
lhs.into_iter()
.zip(rhs)
.all(|(left, right)| left.abs_diff(right) <= threshold)
}
fn build_index_map(manifest: &SliceManifest) -> String {
let digits = manifest
.frames
.len()
.saturating_sub(1)
.to_string()
.len()
.max(4);
let mut output = String::new();
for row in 0..manifest.rows {
for column in 0..manifest.columns {
let index = (row * manifest.columns + column) as usize;
let frame = &manifest.frames[index];
if frame.kept {
output.push_str(&format!("{:0digits$}", frame.index));
} else {
output.push_str(&"-".repeat(digits));
}
if column + 1 < manifest.columns {
output.push(' ');
}
}
output.push('\n');
}
output
}
fn build_sparse_index_map(rows: &[Vec<usize>], frames: &[FrameRecord]) -> String {
let digits = frames.len().saturating_sub(1).to_string().len().max(4);
let mut output = String::new();
for row in rows {
for (position, frame_index) in row.iter().enumerate() {
output.push_str(&format!("{:0digits$}", frames[*frame_index].index));
if position + 1 < row.len() {
output.push(' ');
}
}
output.push('\n');
}
output
}
fn collect_png_files(input: &Path) -> Result<Vec<PathBuf>> {
if input.is_file() {
if is_png(input) {
return Ok(vec![input.to_path_buf()]);
}
bail!("input file is not a png: {}", input.display());
}
if !input.is_dir() {
bail!("input path does not exist: {}", input.display());
}
let mut files = Vec::new();
for entry in
fs::read_dir(input).with_context(|| format!("failed to read {}", input.display()))?
{
let path = entry?.path();
if path.is_file() && is_png(&path) {
files.push(path);
}
}
Ok(files)
}
fn is_png(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("png"))
.unwrap_or(false)
}
fn fps_to_gif_delay(fps: u16) -> u16 {
let fps = fps.max(1) as f32;
((100.0 / fps).round() as u16).max(1)
}
fn remove_connected_background(
image: &mut RgbaImage,
bg_color: [u8; 3],
threshold: u8,
alpha_threshold: u8,
) -> u32 {
let width = image.width() as usize;
let height = image.height() as usize;
let mut visited = vec![false; width * height];
let mut queue = VecDeque::new();
for x in 0..image.width() {
queue_if_background(
image,
x,
0,
bg_color,
threshold,
alpha_threshold,
&mut visited,
&mut queue,
);
if image.height() > 1 {
queue_if_background(
image,
x,
image.height() - 1,
bg_color,
threshold,
alpha_threshold,
&mut visited,
&mut queue,
);
}
}
for y in 0..image.height() {
queue_if_background(
image,
0,
y,
bg_color,
threshold,
alpha_threshold,
&mut visited,
&mut queue,
);
if image.width() > 1 {
queue_if_background(
image,
image.width() - 1,
y,
bg_color,
threshold,
alpha_threshold,
&mut visited,
&mut queue,
);
}
}
let mut removed = 0_u32;
while let Some((x, y)) = queue.pop_front() {
let pixel = image.get_pixel_mut(x, y);
if pixel[3] != 0 {
pixel[3] = 0;
removed += 1;
}
for (nx, ny) in neighbors(x, y, image.width(), image.height()) {
let idx = ny as usize * width + nx as usize;
if visited[idx] {
continue;
}
let neighbor = image.get_pixel(nx, ny);
if neighbor[3] <= alpha_threshold {
visited[idx] = true;
continue;
}
if channels_close([neighbor[0], neighbor[1], neighbor[2]], bg_color, threshold) {
visited[idx] = true;
queue.push_back((nx, ny));
}
}
}
removed
}
fn queue_if_background(
image: &RgbaImage,
x: u32,
y: u32,
bg_color: [u8; 3],
threshold: u8,
alpha_threshold: u8,
visited: &mut [bool],
queue: &mut VecDeque<(u32, u32)>,
) {
let idx = y as usize * image.width() as usize + x as usize;
if visited[idx] {
return;
}
let pixel = image.get_pixel(x, y);
if pixel[3] <= alpha_threshold
|| channels_close([pixel[0], pixel[1], pixel[2]], bg_color, threshold)
{
visited[idx] = true;
queue.push_back((x, y));
}
}
fn horizontal_offset(target_width: u32, frame_width: u32, pad: u32, anchor: AnchorX) -> u32 {
match anchor {
AnchorX::Left => pad,
AnchorX::Center => (target_width.saturating_sub(frame_width)) / 2,
AnchorX::Right => target_width.saturating_sub(frame_width + pad),
}
}
fn vertical_offset(target_height: u32, frame_height: u32, pad: u32, anchor: AnchorY) -> u32 {
match anchor {
AnchorY::Top => pad,
AnchorY::Center => (target_height.saturating_sub(frame_height)) / 2,
AnchorY::Bottom => target_height.saturating_sub(frame_height + pad),
}
}
fn canonicalize_if_possible(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn validate_sheet_divisible(width: u32, height: u32, rows: u32, cols: u32) -> Result<()> {
if width % cols != 0 || height % rows != 0 {
bail!(
"sheet dimensions must divide evenly by the grid: image {}x{}, grid {}x{}",
width,
height,
rows,
cols
);
}
Ok(())
}
fn remove_bg_by_distance(
image: &mut RgbaImage,
bg_color: [u8; 3],
threshold: u8,
edge_threshold: u8,
) {
for pixel in image.pixels_mut() {
if pixel[3] == 0 {
continue;
}
if rgb_distance([pixel[0], pixel[1], pixel[2]], bg_color) < threshold as f32 {
*pixel = Rgba([0, 0, 0, 0]);
}
}
let width = image.width() as usize;
let height = image.height() as usize;
let mut visited = vec![false; width * height];
let mut queue = VecDeque::new();
for x in 0..image.width() {
queue_border_pixel(
image,
x,
0,
bg_color,
edge_threshold,
&mut visited,
&mut queue,
);
if image.height() > 1 {
queue_border_pixel(
image,
x,
image.height() - 1,
bg_color,
edge_threshold,
&mut visited,
&mut queue,
);
}
}
for y in 0..image.height() {
queue_border_pixel(
image,
0,
y,
bg_color,
edge_threshold,
&mut visited,
&mut queue,
);
if image.width() > 1 {
queue_border_pixel(
image,
image.width() - 1,
y,
bg_color,
edge_threshold,
&mut visited,
&mut queue,
);
}
}
while let Some((x, y)) = queue.pop_front() {
let pixel = image.get_pixel_mut(x, y);
let was_opaque = pixel[3] != 0;
*pixel = Rgba([0, 0, 0, 0]);
if !was_opaque {
continue;
}
for (nx, ny) in neighbors(x, y, image.width(), image.height()) {
let idx = ny as usize * width + nx as usize;
if visited[idx] {
continue;
}
visited[idx] = true;
let neighbor = image.get_pixel(nx, ny);
if neighbor[3] == 0
|| rgb_distance([neighbor[0], neighbor[1], neighbor[2]], bg_color)
< edge_threshold as f32
{
queue.push_back((nx, ny));
}
}
}
}
fn queue_border_pixel(
image: &RgbaImage,
x: u32,
y: u32,
bg_color: [u8; 3],
edge_threshold: u8,
visited: &mut [bool],
queue: &mut VecDeque<(u32, u32)>,
) {
let idx = y as usize * image.width() as usize + x as usize;
if visited[idx] {
return;
}
visited[idx] = true;
let pixel = image.get_pixel(x, y);
if pixel[3] == 0
|| rgb_distance([pixel[0], pixel[1], pixel[2]], bg_color) < edge_threshold as f32
{
queue.push_back((x, y));
}
}
fn rgb_distance(lhs: [u8; 3], rhs: [u8; 3]) -> f32 {
let dr = lhs[0] as f32 - rhs[0] as f32;
let dg = lhs[1] as f32 - rhs[1] as f32;
let db = lhs[2] as f32 - rhs[2] as f32;
(dr * dr + dg * dg + db * db).sqrt()
}
fn split_and_normalize_grid(
sheet: &RgbaImage,
options: &ProcessSheetOptions,
) -> Result<(Vec<RgbaImage>, Vec<ProcessedFrameInfo>)> {
let cell_width = sheet.width() / options.cols;
let cell_height = sheet.height() / options.rows;
let mut cropped = Vec::with_capacity((options.rows * options.cols) as usize);
let mut infos = Vec::with_capacity((options.rows * options.cols) as usize);
for row in 0..options.rows {
for col in 0..options.cols {
let source_box = [
col * cell_width,
row * cell_height,
(col + 1) * cell_width,
(row + 1) * cell_height,
];
let mut frame = image::imageops::crop_imm(
sheet,
source_box[0],
source_box[1],
cell_width,
cell_height,
)
.to_image();
if options.trim_border > 0 {
frame = trim_rgba_border(&frame, options.trim_border);
}
if options.edge_clean_depth > 0 {
clean_frame_edges(&mut frame, options.edge_clean_depth, [255, 0, 255]);
}
let components = detect_alpha_components(&frame, options.min_component_area);
let (selected_component, crop_bbox) = match options.component_mode {
ComponentMode::Largest => {
let component = components.first().cloned();
let bbox = component.as_ref().map(|component| {
pad_bbox(
component.bbox,
options.component_padding,
frame.width(),
frame.height(),
)
});
(component, bbox)
}
ComponentMode::All => (None, alpha_bbox(&frame)),
};
let edge_touch = crop_bbox
.map(|bbox| {
bbox_touches_edge(
bbox,
frame.width(),
frame.height(),
options.edge_touch_margin,
)
})
.unwrap_or(false);
let cropped_frame = crop_bbox
.map(|bbox| crop_bbox_image(&frame, bbox))
.unwrap_or_else(|| RgbaImage::new(0, 0));
infos.push(ProcessedFrameInfo {
grid: [row, col],
source_box,
component_mode: options.component_mode,
component_count: components.len(),
selected_component_area: selected_component
.as_ref()
.map(|component| component.area),
selected_component_bbox: selected_component
.as_ref()
.map(|component| bbox_to_array(component.bbox)),
crop_bbox: crop_bbox.map(bbox_to_array),
edge_touch,
output_size: [0, 0],
paste_position: [0, 0],
});
cropped.push(cropped_frame);
}
}
let shared_scale = if options.shared_scale {
let max_width = cropped.iter().map(RgbaImage::width).max().unwrap_or(0);
let max_height = cropped.iter().map(RgbaImage::height).max().unwrap_or(0);
if max_width == 0 || max_height == 0 {
None
} else {
Some(
(options.cell_size as f32 / max_width as f32)
.min(options.cell_size as f32 / max_height as f32)
* options.fit_scale,
)
}
} else {
None
};
let mut output = Vec::with_capacity(cropped.len());
for (index, frame) in cropped.into_iter().enumerate() {
let mut canvas = RgbaImage::new(options.cell_size, options.cell_size);
if frame.width() == 0 || frame.height() == 0 {
output.push(canvas);
continue;
}
let scale = shared_scale.unwrap_or_else(|| {
(options.cell_size as f32 / frame.width() as f32)
.min(options.cell_size as f32 / frame.height() as f32)
* options.fit_scale
});
let output_width = ((frame.width() as f32 * scale).floor() as u32).max(1);
let output_height = ((frame.height() as f32 * scale).floor() as u32).max(1);
let resized =
image::imageops::resize(&frame, output_width, output_height, FilterType::Lanczos3);
let paste_x = horizontal_offset(options.cell_size, output_width, 0, AnchorX::Center);
let pad = (options.cell_size as f32 * (1.0 - options.fit_scale) * 0.5).floor() as u32;
let paste_y = vertical_offset(
options.cell_size,
output_height,
pad,
options.align.to_anchor_y(),
);
image::imageops::overlay(&mut canvas, &resized, paste_x as i64, paste_y as i64);
infos[index].output_size = [output_width, output_height];
infos[index].paste_position = [paste_x, paste_y];
output.push(canvas);
}
Ok((output, infos))
}
fn compose_grid_sheet(frames: &[RgbaImage], rows: u32, cols: u32, cell_size: u32) -> RgbaImage {
let mut canvas = RgbaImage::new(cols * cell_size, rows * cell_size);
for (index, frame) in frames.iter().enumerate() {
let row = index as u32 / cols;
let col = index as u32 % cols;
image::imageops::overlay(
&mut canvas,
frame,
(col * cell_size) as i64,
(row * cell_size) as i64,
);
}
canvas
}
fn save_transparent_gif_frames(frames: &[RgbaImage], output: &Path, delay: u16) -> Result<()> {
if frames.is_empty() {
bail!("no frames to encode");
}
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let width = frames[0].width();
let height = frames[0].height();
if width > u16::MAX as u32 || height > u16::MAX as u32 {
bail!("gif canvas too large: {}x{}", width, height);
}
let file = fs::File::create(output)
.with_context(|| format!("failed to create {}", output.display()))?;
let mut encoder = Encoder::new(file, width as u16, height as u16, &[])
.with_context(|| format!("failed to initialize gif {}", output.display()))?;
encoder.set_repeat(Repeat::Infinite)?;
let key = [255_u8, 0_u8, 254_u8, 0_u8];
let stacked_height = height
.checked_mul(frames.len() as u32)
.with_context(|| format!("stacked gif height overflow for {}", output.display()))?;
if stacked_height > u16::MAX as u32 {
bail!("stacked gif canvas too large: {}x{}", width, stacked_height);
}
let mut stacked = vec![0_u8; (width * stacked_height * 4) as usize];
for pixel in stacked.chunks_exact_mut(4) {
pixel.copy_from_slice(&key);
}
for (frame_index, frame_image) in frames.iter().enumerate() {
for y in 0..height {
for x in 0..width {
let src = frame_image.get_pixel(x, y);
if src[3] < 128 {
continue;
}
let dest_y = y + frame_index as u32 * height;
let idx = ((dest_y * width + x) * 4) as usize;
stacked[idx] = src[0];
stacked[idx + 1] = src[1];
stacked[idx + 2] = src[2];
stacked[idx + 3] = 255;
}
}
}
let stacked_frame =
Frame::from_rgba_speed(width as u16, stacked_height as u16, &mut stacked, 10);
let palette = stacked_frame
.palette
.clone()
.with_context(|| format!("failed to derive gif palette for {}", output.display()))?;
let transparent = stacked_frame.transparent;
let indexed = stacked_frame.buffer.into_owned();
let frame_len = (width * height) as usize;
for frame_index in 0..frames.len() {
let start = frame_index * frame_len;
let end = start + frame_len;
let frame = Frame {
width: width as u16,
height: height as u16,
buffer: std::borrow::Cow::Owned(indexed[start..end].to_vec()),
palette: Some(palette.clone()),
transparent,
delay: delay.max(1),
dispose: DisposalMethod::Background,
..Frame::default()
};
encoder
.write_frame(&frame)
.with_context(|| format!("failed writing gif frame to {}", output.display()))?;
}
Ok(())
}
fn trim_rgba_border(image: &RgbaImage, trim: u32) -> RgbaImage {
if image.width() <= trim.saturating_mul(2) || image.height() <= trim.saturating_mul(2) {
return image.clone();
}
image::imageops::crop_imm(
image,
trim,
trim,
image.width() - trim * 2,
image.height() - trim * 2,
)
.to_image()
}
fn clean_frame_edges(image: &mut RgbaImage, depth: u32, bg_color: [u8; 3]) {
let width = image.width();
let height = image.height();
for d in 0..depth {
if d >= width || d >= height {
break;
}
for x in 0..width {
maybe_clear_edge_pixel(image, x, d, bg_color);
if height > 1 + d {
maybe_clear_edge_pixel(image, x, height - 1 - d, bg_color);
}
}
for y in 0..height {
maybe_clear_edge_pixel(image, d, y, bg_color);
if width > 1 + d {
maybe_clear_edge_pixel(image, width - 1 - d, y, bg_color);
}
}
}
}
fn maybe_clear_edge_pixel(image: &mut RgbaImage, x: u32, y: u32, bg_color: [u8; 3]) {
let pixel = image.get_pixel_mut(x, y);
if pixel[3] == 0 {
return;
}
let dark = pixel[0] < 40 && pixel[1] < 40 && pixel[2] < 40;
let near_bg = rgb_distance([pixel[0], pixel[1], pixel[2]], bg_color) < 150.0;
if dark || near_bg {
*pixel = Rgba([0, 0, 0, 0]);
}
}
#[derive(Debug, Clone)]
struct AlphaComponent {
area: u32,
bbox: (u32, u32, u32, u32),
}
fn detect_alpha_components(image: &RgbaImage, min_area: u32) -> Vec<AlphaComponent> {
let width = image.width() as usize;
let height = image.height() as usize;
let mut visited = vec![false; width * height];
let mut components = Vec::new();
for y in 0..height {
for x in 0..width {
let idx = y * width + x;
if visited[idx] || image.get_pixel(x as u32, y as u32)[3] == 0 {
continue;
}
visited[idx] = true;
let mut queue = VecDeque::from([(x as u32, y as u32)]);
let mut area = 0_u32;
let mut min_x = x as u32;
let mut min_y = y as u32;
let mut max_x = x as u32;
let mut max_y = y as u32;
while let Some((cx, cy)) = queue.pop_front() {
area += 1;
min_x = min_x.min(cx);
min_y = min_y.min(cy);
max_x = max_x.max(cx);
max_y = max_y.max(cy);
for (nx, ny) in orthogonal_neighbors(cx, cy, image.width(), image.height()) {
let nidx = ny as usize * width + nx as usize;
if visited[nidx] || image.get_pixel(nx, ny)[3] == 0 {
continue;
}
visited[nidx] = true;
queue.push_back((nx, ny));
}
}
if area >= min_area {
components.push(AlphaComponent {
area,
bbox: (min_x, min_y, max_x + 1, max_y + 1),
});
}
}
}
components.sort_by(|left, right| right.area.cmp(&left.area));
components
}
fn orthogonal_neighbors(
x: u32,
y: u32,
width: u32,
height: u32,
) -> impl Iterator<Item = (u32, u32)> {
let mut items = Vec::with_capacity(4);
if x > 0 {
items.push((x - 1, y));
}
if x + 1 < width {
items.push((x + 1, y));
}
if y > 0 {
items.push((x, y - 1));
}
if y + 1 < height {
items.push((x, y + 1));
}
items.into_iter()
}
fn alpha_bbox(image: &RgbaImage) -> Option<(u32, u32, u32, u32)> {
let mut min_x = u32::MAX;
let mut min_y = u32::MAX;
let mut max_x = 0;
let mut max_y = 0;
let mut seen = false;
for (x, y, pixel) in image.enumerate_pixels() {
if pixel[3] == 0 {
continue;
}
seen = true;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
seen.then_some((min_x, min_y, max_x + 1, max_y + 1))
}
fn pad_bbox(
bbox: (u32, u32, u32, u32),
padding: u32,
width: u32,
height: u32,
) -> (u32, u32, u32, u32) {
(
bbox.0.saturating_sub(padding),
bbox.1.saturating_sub(padding),
(bbox.2 + padding).min(width),
(bbox.3 + padding).min(height),
)
}
fn crop_bbox_image(image: &RgbaImage, bbox: (u32, u32, u32, u32)) -> RgbaImage {
image::imageops::crop_imm(image, bbox.0, bbox.1, bbox.2 - bbox.0, bbox.3 - bbox.1).to_image()
}
fn bbox_touches_edge(bbox: (u32, u32, u32, u32), width: u32, height: u32, margin: u32) -> bool {
bbox.0 <= margin
|| bbox.1 <= margin
|| bbox.2 >= width.saturating_sub(margin)
|| bbox.3 >= height.saturating_sub(margin)
}
fn bbox_to_array(bbox: (u32, u32, u32, u32)) -> [u32; 4] {
[bbox.0, bbox.1, bbox.2, bbox.3]
}
#[cfg(test)]
mod tests {
use super::{
AnchorX, AnchorY, ComponentBounds, ComponentMode, DetectionMode, FrameAlign, FrameRecord,
ProcessSheetOptions, SliceManifest, alpha_bbox, assign_rows, bbox_touches_edge,
build_index_map, build_sparse_index_map, channels_close, derive_grid_count,
fps_to_gif_delay, horizontal_offset, parse_hex_color, process_sprite_sheet,
remove_bg_by_distance, vertical_offset,
};
use image::{Rgba, RgbaImage};
use std::path::PathBuf;
use std::{fs, time::SystemTime};
#[test]
fn parses_hex_color() {
assert_eq!(parse_hex_color("#12abEF").unwrap(), [0x12, 0xab, 0xef]);
assert!(parse_hex_color("xyz").is_err());
}
#[test]
fn derives_grid_count_from_image_size() {
assert_eq!(derive_grid_count(256, 0, 64, 0), 4);
assert_eq!(derive_grid_count(250, 10, 60, 5), 3);
}
#[test]
fn compares_channels_with_threshold() {
assert!(channels_close([0, 0, 0], [1, 1, 1], 1));
assert!(!channels_close([0, 0, 0], [2, 2, 2], 1));
}
#[test]
fn builds_index_map_with_empty_cells() {
let manifest = SliceManifest {
source: PathBuf::from("sheet.png"),
frame_width: 64,
frame_height: 64,
columns: 2,
rows: 2,
offset_x: 0,
offset_y: 0,
gap_x: 0,
gap_y: 0,
alpha_threshold: 0,
min_opaque_pixels: 1,
bg_hex: None,
bg_threshold: 0,
detection: DetectionMode::Grid,
frames: vec![
FrameRecord {
index: 0,
row: 0,
column: 0,
x: 0,
y: 0,
width: 64,
height: 64,
opaque_pixels: 10,
kept: true,
file: Some("frames/frame_0000.png".to_string()),
},
FrameRecord {
index: 1,
row: 0,
column: 1,
x: 64,
y: 0,
width: 64,
height: 64,
opaque_pixels: 0,
kept: false,
file: None,
},
FrameRecord {
index: 2,
row: 1,
column: 0,
x: 0,
y: 64,
width: 64,
height: 64,
opaque_pixels: 8,
kept: true,
file: Some("frames/frame_0002.png".to_string()),
},
FrameRecord {
index: 3,
row: 1,
column: 1,
x: 64,
y: 64,
width: 64,
height: 64,
opaque_pixels: 9,
kept: true,
file: Some("frames/frame_0003.png".to_string()),
},
],
};
assert_eq!(build_index_map(&manifest), "0000 ----\n0002 0003\n");
}
#[test]
fn assigns_rows_from_detected_components() {
let components = vec![
ComponentBounds {
x: 10,
y: 10,
width: 20,
height: 20,
opaque_pixels: 100,
center_y: 20.0,
},
ComponentBounds {
x: 60,
y: 12,
width: 20,
height: 20,
opaque_pixels: 100,
center_y: 22.0,
},
ComponentBounds {
x: 15,
y: 70,
width: 20,
height: 20,
opaque_pixels: 100,
center_y: 80.0,
},
];
let rows = assign_rows(&components, 8);
assert_eq!(rows, vec![vec![0, 1], vec![2]]);
}
#[test]
fn builds_sparse_index_map_for_detected_layout() {
let rows = vec![vec![0, 1], vec![2]];
let frames = vec![
FrameRecord {
index: 0,
row: 0,
column: 0,
x: 0,
y: 0,
width: 10,
height: 10,
opaque_pixels: 10,
kept: true,
file: Some("frames/0.png".to_string()),
},
FrameRecord {
index: 1,
row: 0,
column: 1,
x: 10,
y: 0,
width: 10,
height: 10,
opaque_pixels: 10,
kept: true,
file: Some("frames/1.png".to_string()),
},
FrameRecord {
index: 2,
row: 1,
column: 0,
x: 0,
y: 10,
width: 10,
height: 10,
opaque_pixels: 10,
kept: true,
file: Some("frames/2.png".to_string()),
},
];
assert_eq!(build_sparse_index_map(&rows, &frames), "0000 0001\n0002\n");
}
#[test]
fn converts_fps_to_gif_delay() {
assert_eq!(fps_to_gif_delay(10), 10);
assert_eq!(fps_to_gif_delay(8), 13);
assert_eq!(fps_to_gif_delay(0), 100);
}
#[test]
fn computes_offsets() {
assert_eq!(horizontal_offset(128, 64, 4, AnchorX::Center), 32);
assert_eq!(horizontal_offset(128, 64, 4, AnchorX::Right), 60);
assert_eq!(vertical_offset(128, 64, 4, AnchorY::Bottom), 60);
assert_eq!(vertical_offset(128, 64, 4, AnchorY::Top), 4);
}
#[test]
fn removes_magenta_background_by_distance() {
let mut image = RgbaImage::from_pixel(4, 4, Rgba([255, 0, 255, 255]));
image.put_pixel(1, 1, Rgba([10, 20, 30, 255]));
remove_bg_by_distance(&mut image, [255, 0, 255], 100, 150);
assert_eq!(image.get_pixel(0, 0)[3], 0);
assert_eq!(image.get_pixel(1, 1)[3], 255);
}
#[test]
fn computes_alpha_bounding_box() {
let mut image = RgbaImage::new(8, 8);
image.put_pixel(2, 3, Rgba([255, 255, 255, 255]));
image.put_pixel(5, 6, Rgba([255, 255, 255, 255]));
assert_eq!(alpha_bbox(&image), Some((2, 3, 6, 7)));
}
#[test]
fn detects_bbox_edge_touch_with_margin() {
assert!(bbox_touches_edge((1, 2, 7, 8), 8, 8, 1));
assert!(!bbox_touches_edge((2, 2, 6, 6), 8, 8, 1));
}
#[test]
fn processes_sheet_and_emits_metadata() {
let root = unique_test_dir("pipeline");
let input = root.join("input.png");
let output = root.join("out");
let mut image = RgbaImage::from_pixel(16, 16, Rgba([255, 0, 255, 255]));
for y in 2..6 {
for x in 2..6 {
image.put_pixel(x, y, Rgba([0, 255, 0, 255]));
}
}
for y in 10..14 {
for x in 10..14 {
image.put_pixel(x, y, Rgba([0, 200, 255, 255]));
}
}
image.save(&input).unwrap();
let output_summary = process_sprite_sheet(ProcessSheetOptions {
input: input.clone(),
output_dir: output.clone(),
rows: 2,
cols: 2,
cell_size: 32,
bg_hex: "#FF00FF".to_string(),
threshold: 100,
edge_threshold: 150,
fit_scale: 0.85,
trim_border: 0,
edge_clean_depth: 0,
align: FrameAlign::Center,
shared_scale: true,
component_mode: ComponentMode::All,
component_padding: 0,
min_component_area: 1,
edge_touch_margin: 0,
reject_edge_touch: true,
gif_delay: 10,
frame_labels: Some(vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string(),
]),
prompt: Some("demo".to_string()),
})
.unwrap();
assert!(output_summary.sheet_path.exists());
assert!(output_summary.gif_path.exists());
assert!(output_summary.metadata_path.exists());
assert_eq!(output_summary.frame_paths.len(), 4);
assert_eq!(output_summary.frame_count, 4);
assert!(output_summary.edge_touch_frames.is_empty());
let metadata_text = fs::read_to_string(output_summary.metadata_path).unwrap();
assert!(metadata_text.contains("\"edge_touch_frames\": []"));
assert!(metadata_text.contains("\"frame_labels\""));
}
fn unique_test_dir(name: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("sprite-slicer-{name}-{nonce}"));
fs::create_dir_all(&dir).unwrap();
dir
}
}