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::{Encoder, Frame, Repeat};
use image::RgbaImage;
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, 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,
})
}
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())
}
#[cfg(test)]
mod tests {
use super::{
AnchorX, AnchorY, ComponentBounds, DetectionMode, FrameRecord, SliceManifest, assign_rows,
build_index_map, build_sparse_index_map, channels_close, derive_grid_count,
fps_to_gif_delay, horizontal_offset, parse_hex_color, vertical_offset,
};
use std::path::PathBuf;
#[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);
}
}