Skip to main content

sprite_slicer/
lib.rs

1//! `sprite-slicer` provides file-oriented helpers for common 2D sprite-sheet
2//! workflows.
3//!
4//! It supports:
5//!
6//! - slicing a fixed sprite grid into frames
7//! - detecting sprites from a transparent sheet
8//! - grouping frames into actions
9//! - exporting GIF previews
10//! - removing a connected background color
11//! - normalizing frames onto a shared canvas
12
13use std::collections::HashMap;
14use std::collections::VecDeque;
15use std::fmt;
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::str::FromStr;
19
20use anyhow::{Context, Result, bail};
21use gif::{DisposalMethod, Encoder, Frame, Repeat};
22use image::{Rgba, RgbaImage, imageops::FilterType};
23use serde::{Deserialize, Serialize};
24
25/// Options for slicing a regular sprite sheet into fixed-size frames.
26#[derive(Debug, Clone)]
27pub struct SliceOptions {
28    /// Input sprite sheet path.
29    pub input: PathBuf,
30    /// Output directory where frames and manifests will be written.
31    pub output: PathBuf,
32    /// Width of a single frame.
33    pub frame_width: u32,
34    /// Height of a single frame.
35    pub frame_height: u32,
36    /// Number of columns in the sheet. When `None`, the value is derived.
37    pub columns: Option<u32>,
38    /// Number of rows in the sheet. When `None`, the value is derived.
39    pub rows: Option<u32>,
40    /// Horizontal offset from the left edge of the sheet.
41    pub offset_x: u32,
42    /// Vertical offset from the top edge of the sheet.
43    pub offset_y: u32,
44    /// Horizontal gap between frames.
45    pub gap_x: u32,
46    /// Vertical gap between frames.
47    pub gap_y: u32,
48    /// When `true`, frames below `min_opaque_pixels` are not exported.
49    pub skip_empty: bool,
50    /// Pixels with alpha less than or equal to this value count as transparent.
51    pub alpha_threshold: u8,
52    /// Minimum number of foreground pixels required to keep a frame.
53    pub min_opaque_pixels: u32,
54    /// Optional background color used to ignore solid-color backdrops.
55    pub bg_hex: Option<String>,
56    /// Allowed per-channel distance when comparing to `bg_hex`.
57    pub bg_threshold: u8,
58    /// Output file name for the manifest, usually `frames.toml`.
59    pub manifest_name: String,
60}
61
62/// Options for detecting disconnected sprites from a transparent or filtered sheet.
63#[derive(Debug, Clone)]
64pub struct DetectOptions {
65    /// Input image path.
66    pub input: PathBuf,
67    /// Output directory where frames and manifests will be written.
68    pub output: PathBuf,
69    /// Pixels with alpha less than or equal to this value count as transparent.
70    pub alpha_threshold: u8,
71    /// Minimum foreground pixel count required for a connected component to be kept.
72    pub min_opaque_pixels: u32,
73    /// Extra padding added around each detected component.
74    pub padding: u32,
75    /// Vertical tolerance used when clustering detected components into rows.
76    pub row_tolerance: u32,
77    /// Optional background color used to ignore solid-color backdrops.
78    pub bg_hex: Option<String>,
79    /// Allowed per-channel distance when comparing to `bg_hex`.
80    pub bg_threshold: u8,
81    /// Output file name for the manifest, usually `frames.toml`.
82    pub manifest_name: String,
83}
84
85/// Options for regrouping sliced frames into named animation folders.
86#[derive(Debug, Clone)]
87pub struct GroupOptions {
88    /// Path to the `frames.toml` manifest generated by `slice_sheet` or `detect_frames`.
89    pub manifest: PathBuf,
90    /// Path to an action config TOML file.
91    pub config: PathBuf,
92    /// Output directory where grouped actions will be written.
93    pub output: PathBuf,
94}
95
96/// Options for exporting PNG frames as a GIF preview.
97#[derive(Debug, Clone)]
98pub struct GifOptions {
99    /// Input PNG file or directory containing PNG frames.
100    pub input: PathBuf,
101    /// Output GIF file path.
102    pub output: PathBuf,
103    /// Frames per second for the exported GIF.
104    pub fps: u16,
105    /// Number of repeats. `0` means infinite looping.
106    pub repeat: u16,
107    /// Extra padding added around the largest frame.
108    pub pad: u32,
109}
110
111/// Options for removing a connected background color and producing a transparent PNG.
112#[derive(Debug, Clone)]
113pub struct RemoveBgOptions {
114    /// Input image path.
115    pub input: PathBuf,
116    /// Output PNG path.
117    pub output: PathBuf,
118    /// Background color in `#RRGGBB` form.
119    pub bg_hex: String,
120    /// Allowed per-channel distance when comparing to `bg_hex`.
121    pub threshold: u8,
122    /// Pixels with alpha less than or equal to this value are treated as already transparent.
123    pub alpha_threshold: u8,
124}
125
126/// Options for normalizing multiple frames onto a shared canvas and anchor.
127#[derive(Debug, Clone)]
128pub struct NormalizeOptions {
129    /// Input PNG file or directory containing PNG frames.
130    pub input: PathBuf,
131    /// Output directory for normalized PNG frames.
132    pub output: PathBuf,
133    /// Target canvas width. When `None`, the maximum input width is used.
134    pub width: Option<u32>,
135    /// Target canvas height. When `None`, the maximum input height is used.
136    pub height: Option<u32>,
137    /// Horizontal anchor used to place each frame on the canvas.
138    pub anchor_x: AnchorX,
139    /// Vertical anchor used to place each frame on the canvas.
140    pub anchor_y: AnchorY,
141    /// Extra padding added around the final canvas.
142    pub pad: u32,
143}
144
145/// Result returned by `slice_sheet`.
146#[derive(Debug, Clone)]
147pub struct SliceOutput {
148    /// Path to the generated manifest file.
149    pub manifest_path: PathBuf,
150    /// Path to the generated index map text file.
151    pub index_map_path: PathBuf,
152    /// Total number of grid cells examined.
153    pub frame_count: usize,
154    /// Number of frames actually exported.
155    pub kept_frames: usize,
156}
157
158/// Result returned by `detect_frames`.
159#[derive(Debug, Clone)]
160pub struct DetectOutput {
161    /// Path to the generated manifest file.
162    pub manifest_path: PathBuf,
163    /// Path to the generated index map text file.
164    pub index_map_path: PathBuf,
165    /// Number of detected frames exported.
166    pub detected_frames: usize,
167    /// Number of row groups inferred from detected components.
168    pub rows: usize,
169}
170
171/// Summary for one grouped animation action.
172#[derive(Debug, Clone)]
173pub struct GroupedActionSummary {
174    /// Action name, such as `idle` or `walk`.
175    pub name: String,
176    /// Number of frames exported into that action folder.
177    pub frame_count: usize,
178}
179
180/// Result returned by `group_actions`.
181#[derive(Debug, Clone)]
182pub struct GroupOutputSummary {
183    /// Path to the generated `actions.toml` summary file.
184    pub manifest_path: PathBuf,
185    /// Per-action export summaries.
186    pub actions: Vec<GroupedActionSummary>,
187}
188
189/// Result returned by `export_gif`.
190#[derive(Debug, Clone)]
191pub struct GifOutput {
192    /// Output GIF file path.
193    pub output_path: PathBuf,
194    /// Number of input frames used.
195    pub frame_count: usize,
196    /// Final GIF canvas width.
197    pub canvas_width: u32,
198    /// Final GIF canvas height.
199    pub canvas_height: u32,
200    /// Effective frames per second.
201    pub fps: u16,
202}
203
204/// Result returned by `remove_background`.
205#[derive(Debug, Clone)]
206pub struct RemoveBgOutput {
207    /// Output PNG file path.
208    pub output_path: PathBuf,
209    /// Number of pixels made transparent.
210    pub removed_pixels: u32,
211}
212
213/// Result returned by `normalize_frames`.
214#[derive(Debug, Clone)]
215pub struct NormalizeOutput {
216    /// Output directory containing normalized PNG frames.
217    pub output_dir: PathBuf,
218    /// Number of frames written.
219    pub frame_count: usize,
220    /// Final canvas width.
221    pub canvas_width: u32,
222    /// Final canvas height.
223    pub canvas_height: u32,
224    /// Horizontal anchor used during export.
225    pub anchor_x: AnchorX,
226    /// Vertical anchor used during export.
227    pub anchor_y: AnchorY,
228}
229
230/// Vertical placement policy for sprite-sheet postprocessing.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(rename_all = "snake_case")]
233pub enum FrameAlign {
234    /// Center the cropped content in the output cell.
235    Center,
236    /// Align the cropped content to the bottom of the output cell.
237    Bottom,
238    /// Alias for bottom alignment used for grounded characters.
239    Feet,
240}
241
242impl FrameAlign {
243    fn to_anchor_y(self) -> AnchorY {
244        match self {
245            Self::Center => AnchorY::Center,
246            Self::Bottom | Self::Feet => AnchorY::Bottom,
247        }
248    }
249}
250
251/// Connected-component selection mode for per-cell cropping.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "snake_case")]
254pub enum ComponentMode {
255    /// Use the bounding box of every non-transparent pixel in the cell.
256    All,
257    /// Use the bounding box of the largest connected component only.
258    Largest,
259}
260
261/// Options for postprocessing a magenta-backed sprite sheet into normalized frames.
262#[derive(Debug, Clone)]
263pub struct ProcessSheetOptions {
264    /// Input sprite-sheet image path.
265    pub input: PathBuf,
266    /// Output directory where all generated files will be written.
267    pub output_dir: PathBuf,
268    /// Number of rows in the source grid.
269    pub rows: u32,
270    /// Number of columns in the source grid.
271    pub cols: u32,
272    /// Output square cell size for each exported frame.
273    pub cell_size: u32,
274    /// RGB background color to remove, usually magenta.
275    pub bg_hex: String,
276    /// Distance threshold used for chroma-key removal.
277    pub threshold: u8,
278    /// More permissive edge cleanup threshold used for boundary flood fill.
279    pub edge_threshold: u8,
280    /// Scale multiplier applied after fit-to-cell sizing.
281    pub fit_scale: f32,
282    /// Number of pixels trimmed from each frame border before component analysis.
283    pub trim_border: u32,
284    /// Number of outer pixel layers cleaned to suppress border noise.
285    pub edge_clean_depth: u32,
286    /// Vertical placement policy in the output cell.
287    pub align: FrameAlign,
288    /// Use a shared scale across all frames when `true`.
289    pub shared_scale: bool,
290    /// Component selection strategy.
291    pub component_mode: ComponentMode,
292    /// Extra padding applied around the selected component bounding box.
293    pub component_padding: u32,
294    /// Minimum connected-component area to consider during analysis.
295    pub min_component_area: u32,
296    /// Margin used when deciding whether content touches a source cell edge.
297    pub edge_touch_margin: u32,
298    /// Fail processing when any source frame touches the cell edge.
299    pub reject_edge_touch: bool,
300    /// GIF frame duration in centiseconds.
301    pub gif_delay: u16,
302    /// Optional logical frame labels. Length must equal `rows * cols` when provided.
303    pub frame_labels: Option<Vec<String>>,
304    /// Optional prompt text to persist as `prompt-used.txt`.
305    pub prompt: Option<String>,
306}
307
308/// Output summary for `process_sprite_sheet`.
309#[derive(Debug, Clone)]
310pub struct ProcessSheetOutput {
311    /// Output directory containing generated assets.
312    pub output_dir: PathBuf,
313    /// Transparent recomposed sheet path.
314    pub sheet_path: PathBuf,
315    /// GIF preview path.
316    pub gif_path: PathBuf,
317    /// QC metadata path.
318    pub metadata_path: PathBuf,
319    /// Exported frame image paths in source order.
320    pub frame_paths: Vec<PathBuf>,
321    /// Number of exported frames.
322    pub frame_count: usize,
323    /// Source grid positions that touched a cell edge during QC.
324    pub edge_touch_frames: Vec<[u32; 2]>,
325}
326
327/// JSON-friendly metadata emitted by `process_sprite_sheet`.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct ProcessSheetMetadata {
330    pub input: PathBuf,
331    pub raw_sheet: PathBuf,
332    pub raw_sheet_clean: PathBuf,
333    pub rows: u32,
334    pub cols: u32,
335    pub cell_size: u32,
336    pub threshold: u8,
337    pub edge_threshold: u8,
338    pub fit_scale: f32,
339    pub trim_border: u32,
340    pub edge_clean_depth: u32,
341    pub align: FrameAlign,
342    pub shared_scale: bool,
343    pub component_mode: ComponentMode,
344    pub component_padding: u32,
345    pub min_component_area: u32,
346    pub edge_touch_margin: u32,
347    pub reject_edge_touch: bool,
348    pub gif_delay: u16,
349    pub frame_labels: Vec<String>,
350    pub edge_touch_frames: Vec<[u32; 2]>,
351    pub frames: Vec<ProcessedFrameInfo>,
352}
353
354/// Per-frame QC details emitted by `process_sprite_sheet`.
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct ProcessedFrameInfo {
357    pub grid: [u32; 2],
358    pub source_box: [u32; 4],
359    pub component_mode: ComponentMode,
360    pub component_count: usize,
361    pub selected_component_area: Option<u32>,
362    pub selected_component_bbox: Option<[u32; 4]>,
363    pub crop_bbox: Option<[u32; 4]>,
364    pub edge_touch: bool,
365    pub output_size: [u32; 2],
366    pub paste_position: [u32; 2],
367}
368
369/// Manifest written by `slice_sheet` and `detect_frames`.
370#[derive(Debug, Serialize, Deserialize)]
371pub struct SliceManifest {
372    /// Absolute or original source image path.
373    pub source: PathBuf,
374    /// Frame width used for grid slicing. `0` for connected-component detection.
375    pub frame_width: u32,
376    /// Frame height used for grid slicing. `0` for connected-component detection.
377    pub frame_height: u32,
378    /// Number of columns in the exported layout.
379    pub columns: u32,
380    /// Number of rows in the exported layout.
381    pub rows: u32,
382    /// Horizontal source offset used for grid slicing.
383    pub offset_x: u32,
384    /// Vertical source offset used for grid slicing.
385    pub offset_y: u32,
386    /// Horizontal frame gap used for grid slicing.
387    pub gap_x: u32,
388    /// Vertical frame gap used for grid slicing.
389    pub gap_y: u32,
390    /// Transparency threshold used during detection.
391    pub alpha_threshold: u8,
392    /// Minimum opaque pixel threshold used during detection.
393    pub min_opaque_pixels: u32,
394    /// Optional filtered background color.
395    pub bg_hex: Option<String>,
396    /// Per-channel background comparison threshold.
397    pub bg_threshold: u8,
398    /// Detection strategy that produced this manifest.
399    pub detection: DetectionMode,
400    /// Ordered list of exported or scanned frames.
401    pub frames: Vec<FrameRecord>,
402}
403
404/// One frame entry in a `SliceManifest`.
405#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct FrameRecord {
407    /// Stable frame index used by `group_actions`.
408    pub index: usize,
409    /// Row position in the logical layout.
410    pub row: u32,
411    /// Column position in the logical layout.
412    pub column: u32,
413    /// Left edge of the crop rectangle in the source image.
414    pub x: u32,
415    /// Top edge of the crop rectangle in the source image.
416    pub y: u32,
417    /// Width of the crop rectangle.
418    pub width: u32,
419    /// Height of the crop rectangle.
420    pub height: u32,
421    /// Count of foreground pixels used to evaluate the frame.
422    pub opaque_pixels: u32,
423    /// Whether the frame was kept and exported.
424    pub kept: bool,
425    /// Relative output file path if the frame was exported.
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub file: Option<String>,
428}
429
430/// Detection mode recorded in `SliceManifest`.
431#[derive(Debug, Clone, Serialize, Deserialize)]
432#[serde(rename_all = "snake_case")]
433pub enum DetectionMode {
434    /// Frames were extracted from a fixed row/column grid.
435    Grid,
436    /// Frames were extracted by connected-component detection.
437    ConnectedComponents,
438}
439
440/// One action entry in a grouping config file.
441#[derive(Debug, Clone, Deserialize, Serialize)]
442pub struct ActionSpec {
443    /// Action name, such as `idle`, `walk`, or `attack`.
444    pub name: String,
445    /// Ordered source frame indices included in this action.
446    pub frames: Vec<usize>,
447}
448
449/// Horizontal anchor used by `normalize_frames`.
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
451pub enum AnchorX {
452    /// Align frames to the left side of the target canvas.
453    Left,
454    /// Center frames horizontally on the target canvas.
455    Center,
456    /// Align frames to the right side of the target canvas.
457    Right,
458}
459
460impl fmt::Display for AnchorX {
461    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
462        let value = match self {
463            Self::Left => "left",
464            Self::Center => "center",
465            Self::Right => "right",
466        };
467        f.write_str(value)
468    }
469}
470
471impl FromStr for AnchorX {
472    type Err = String;
473
474    fn from_str(input: &str) -> std::result::Result<Self, Self::Err> {
475        match input {
476            "left" => Ok(Self::Left),
477            "center" => Ok(Self::Center),
478            "right" => Ok(Self::Right),
479            _ => Err(format!("invalid anchor-x: {input}; use left|center|right")),
480        }
481    }
482}
483
484/// Vertical anchor used by `normalize_frames`.
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486pub enum AnchorY {
487    /// Align frames to the top of the target canvas.
488    Top,
489    /// Center frames vertically on the target canvas.
490    Center,
491    /// Align frames to the bottom of the target canvas.
492    Bottom,
493}
494
495impl fmt::Display for AnchorY {
496    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
497        let value = match self {
498            Self::Top => "top",
499            Self::Center => "center",
500            Self::Bottom => "bottom",
501        };
502        f.write_str(value)
503    }
504}
505
506impl FromStr for AnchorY {
507    type Err = String;
508
509    fn from_str(input: &str) -> std::result::Result<Self, Self::Err> {
510        match input {
511            "top" => Ok(Self::Top),
512            "center" => Ok(Self::Center),
513            "bottom" => Ok(Self::Bottom),
514            _ => Err(format!("invalid anchor-y: {input}; use top|center|bottom")),
515        }
516    }
517}
518
519#[derive(Debug, Deserialize)]
520struct ActionConfig {
521    actions: Vec<ActionSpec>,
522}
523
524#[derive(Debug, Serialize)]
525struct GroupManifest {
526    source_manifest: PathBuf,
527    actions: Vec<GroupManifestAction>,
528}
529
530#[derive(Debug, Serialize)]
531struct GroupManifestAction {
532    name: String,
533    source_frames: Vec<usize>,
534    files: Vec<String>,
535}
536
537#[derive(Debug, Clone)]
538struct ComponentBounds {
539    x: u32,
540    y: u32,
541    width: u32,
542    height: u32,
543    opaque_pixels: u32,
544    center_y: f32,
545}
546
547/// Slice a regular sprite sheet into fixed-size PNG frames.
548///
549/// This function is intended for sheets with a stable grid layout. It writes
550/// `frames/`, a manifest TOML file, and `index-map.txt` into `options.output`.
551pub fn slice_sheet(options: SliceOptions) -> Result<SliceOutput> {
552    let image = image::open(&options.input)
553        .with_context(|| format!("failed to open image {}", options.input.display()))?
554        .to_rgba8();
555
556    let columns = options.columns.unwrap_or_else(|| {
557        derive_grid_count(
558            image.width(),
559            options.offset_x,
560            options.frame_width,
561            options.gap_x,
562        )
563    });
564    let rows = options.rows.unwrap_or_else(|| {
565        derive_grid_count(
566            image.height(),
567            options.offset_y,
568            options.frame_height,
569            options.gap_y,
570        )
571    });
572
573    validate_grid(
574        image.width(),
575        image.height(),
576        columns,
577        rows,
578        options.offset_x,
579        options.offset_y,
580        options.frame_width,
581        options.frame_height,
582        options.gap_x,
583        options.gap_y,
584    )?;
585
586    let bg_color = match options.bg_hex.as_deref() {
587        Some(value) => Some(parse_hex_color(value)?),
588        None => None,
589    };
590
591    fs::create_dir_all(&options.output)
592        .with_context(|| format!("failed to create {}", options.output.display()))?;
593    let frames_dir = options.output.join("frames");
594    fs::create_dir_all(&frames_dir)
595        .with_context(|| format!("failed to create {}", frames_dir.display()))?;
596
597    let mut frames = Vec::with_capacity((columns * rows) as usize);
598    for row in 0..rows {
599        for column in 0..columns {
600            let x = options.offset_x + column * (options.frame_width + options.gap_x);
601            let y = options.offset_y + row * (options.frame_height + options.gap_y);
602            let tile =
603                image::imageops::crop_imm(&image, x, y, options.frame_width, options.frame_height)
604                    .to_image();
605            let opaque_pixels = count_foreground_pixels(
606                &tile,
607                bg_color,
608                options.bg_threshold,
609                options.alpha_threshold,
610            );
611            let kept = !options.skip_empty || opaque_pixels >= options.min_opaque_pixels;
612            let index = (row * columns + column) as usize;
613            let file = if kept {
614                let file_name = format!("frame_{index:04}_r{row:02}_c{column:02}.png");
615                let relative = PathBuf::from("frames").join(file_name);
616                let full_path = options.output.join(&relative);
617                tile.save(&full_path).with_context(|| {
618                    format!("failed to save sliced frame {}", full_path.display())
619                })?;
620                Some(relative.to_string_lossy().to_string())
621            } else {
622                None
623            };
624
625            frames.push(FrameRecord {
626                index,
627                row,
628                column,
629                x,
630                y,
631                width: options.frame_width,
632                height: options.frame_height,
633                opaque_pixels,
634                kept,
635                file,
636            });
637        }
638    }
639
640    let manifest = SliceManifest {
641        source: canonicalize_if_possible(&options.input),
642        frame_width: options.frame_width,
643        frame_height: options.frame_height,
644        columns,
645        rows,
646        offset_x: options.offset_x,
647        offset_y: options.offset_y,
648        gap_x: options.gap_x,
649        gap_y: options.gap_y,
650        alpha_threshold: options.alpha_threshold,
651        min_opaque_pixels: options.min_opaque_pixels,
652        bg_hex: options.bg_hex,
653        bg_threshold: options.bg_threshold,
654        detection: DetectionMode::Grid,
655        frames,
656    };
657
658    let manifest_path = options.output.join(&options.manifest_name);
659    fs::write(&manifest_path, toml::to_string_pretty(&manifest)?)
660        .with_context(|| format!("failed to write {}", manifest_path.display()))?;
661
662    let index_map_path = options.output.join("index-map.txt");
663    fs::write(&index_map_path, build_index_map(&manifest))
664        .with_context(|| format!("failed to write {}", index_map_path.display()))?;
665
666    Ok(SliceOutput {
667        manifest_path,
668        index_map_path,
669        frame_count: manifest.frames.len(),
670        kept_frames: manifest.frames.iter().filter(|frame| frame.kept).count(),
671    })
672}
673
674/// Detect disconnected sprites from a transparent or filtered sprite sheet.
675///
676/// This function groups connected foreground components, clusters them into
677/// rows, and writes PNG frames plus a manifest into `options.output`.
678pub fn detect_frames(options: DetectOptions) -> Result<DetectOutput> {
679    let image = image::open(&options.input)
680        .with_context(|| format!("failed to open image {}", options.input.display()))?
681        .to_rgba8();
682    let bg_color = match options.bg_hex.as_deref() {
683        Some(value) => Some(parse_hex_color(value)?),
684        None => None,
685    };
686
687    let components = detect_components(
688        &image,
689        bg_color,
690        options.bg_threshold,
691        options.alpha_threshold,
692        options.min_opaque_pixels,
693        options.padding,
694    );
695    if components.is_empty() {
696        bail!("no components matched; lower --min-opaque-pixels or adjust thresholds");
697    }
698
699    let rows = assign_rows(&components, options.row_tolerance);
700    let max_columns = rows.iter().map(|row| row.len()).max().unwrap_or(0) as u32;
701
702    fs::create_dir_all(&options.output)
703        .with_context(|| format!("failed to create {}", options.output.display()))?;
704    let frames_dir = options.output.join("frames");
705    fs::create_dir_all(&frames_dir)
706        .with_context(|| format!("failed to create {}", frames_dir.display()))?;
707
708    let mut frames = Vec::new();
709    for (row_index, row) in rows.iter().enumerate() {
710        for (column_index, component_index) in row.iter().enumerate() {
711            let component = &components[*component_index];
712            let tile = image::imageops::crop_imm(
713                &image,
714                component.x,
715                component.y,
716                component.width,
717                component.height,
718            )
719            .to_image();
720            let index = frames.len();
721            let file_name = format!("frame_{index:04}_r{row_index:02}_c{column_index:02}.png");
722            let relative = PathBuf::from("frames").join(file_name);
723            let full_path = options.output.join(&relative);
724            tile.save(&full_path).with_context(|| {
725                format!("failed to save detected frame {}", full_path.display())
726            })?;
727
728            frames.push(FrameRecord {
729                index,
730                row: row_index as u32,
731                column: column_index as u32,
732                x: component.x,
733                y: component.y,
734                width: component.width,
735                height: component.height,
736                opaque_pixels: component.opaque_pixels,
737                kept: true,
738                file: Some(relative.to_string_lossy().to_string()),
739            });
740        }
741    }
742
743    let manifest = SliceManifest {
744        source: canonicalize_if_possible(&options.input),
745        frame_width: 0,
746        frame_height: 0,
747        columns: max_columns,
748        rows: rows.len() as u32,
749        offset_x: 0,
750        offset_y: 0,
751        gap_x: 0,
752        gap_y: 0,
753        alpha_threshold: options.alpha_threshold,
754        min_opaque_pixels: options.min_opaque_pixels,
755        bg_hex: options.bg_hex,
756        bg_threshold: options.bg_threshold,
757        detection: DetectionMode::ConnectedComponents,
758        frames,
759    };
760
761    let manifest_path = options.output.join(&options.manifest_name);
762    fs::write(&manifest_path, toml::to_string_pretty(&manifest)?)
763        .with_context(|| format!("failed to write {}", manifest_path.display()))?;
764
765    let index_map_path = options.output.join("index-map.txt");
766    fs::write(
767        &index_map_path,
768        build_sparse_index_map(&rows, &manifest.frames),
769    )
770    .with_context(|| format!("failed to write {}", index_map_path.display()))?;
771
772    Ok(DetectOutput {
773        manifest_path,
774        index_map_path,
775        detected_frames: manifest.frames.len(),
776        rows: rows.len(),
777    })
778}
779
780/// Regroup frames from a manifest into named animation folders.
781///
782/// The config file is a TOML document with repeated `[[actions]]` tables
783/// containing `name` and `frames`.
784pub fn group_actions(options: GroupOptions) -> Result<GroupOutputSummary> {
785    let manifest_text = fs::read_to_string(&options.manifest)
786        .with_context(|| format!("failed to read {}", options.manifest.display()))?;
787    let manifest: SliceManifest = toml::from_str(&manifest_text)
788        .with_context(|| format!("failed to parse {}", options.manifest.display()))?;
789    let config_text = fs::read_to_string(&options.config)
790        .with_context(|| format!("failed to read {}", options.config.display()))?;
791    let config: ActionConfig = toml::from_str(&config_text)
792        .with_context(|| format!("failed to parse {}", options.config.display()))?;
793
794    fs::create_dir_all(&options.output)
795        .with_context(|| format!("failed to create {}", options.output.display()))?;
796
797    let frame_lookup: HashMap<usize, &FrameRecord> = manifest
798        .frames
799        .iter()
800        .map(|frame| (frame.index, frame))
801        .collect();
802    let manifest_root = options
803        .manifest
804        .parent()
805        .map(Path::to_path_buf)
806        .unwrap_or_else(|| PathBuf::from("."));
807
808    let mut manifest_actions = Vec::with_capacity(config.actions.len());
809    let mut summary = Vec::with_capacity(config.actions.len());
810
811    for action in config.actions {
812        let action_dir = options.output.join(&action.name);
813        fs::create_dir_all(&action_dir)
814            .with_context(|| format!("failed to create {}", action_dir.display()))?;
815
816        let mut exported_files = Vec::with_capacity(action.frames.len());
817        for (sequence, frame_index) in action.frames.iter().enumerate() {
818            let frame = frame_lookup
819                .get(frame_index)
820                .copied()
821                .with_context(|| format!("frame index {frame_index} is not present in manifest"))?;
822            let relative_file = frame.file.as_deref().with_context(|| {
823                format!("frame index {frame_index} was not exported; try disabling --skip-empty")
824            })?;
825            let source_path = manifest_root.join(relative_file);
826            let destination_name = format!("{sequence:04}.png");
827            let destination_path = action_dir.join(&destination_name);
828            fs::copy(&source_path, &destination_path).with_context(|| {
829                format!(
830                    "failed to copy {} to {}",
831                    source_path.display(),
832                    destination_path.display()
833                )
834            })?;
835            exported_files.push(
836                PathBuf::from(&action.name)
837                    .join(destination_name)
838                    .to_string_lossy()
839                    .to_string(),
840            );
841        }
842
843        summary.push(GroupedActionSummary {
844            name: action.name.clone(),
845            frame_count: action.frames.len(),
846        });
847        manifest_actions.push(GroupManifestAction {
848            name: action.name,
849            source_frames: action.frames,
850            files: exported_files,
851        });
852    }
853
854    let grouped_manifest = GroupManifest {
855        source_manifest: canonicalize_if_possible(&options.manifest),
856        actions: manifest_actions,
857    };
858    let manifest_path = options.output.join("actions.toml");
859    fs::write(&manifest_path, toml::to_string_pretty(&grouped_manifest)?)
860        .with_context(|| format!("failed to write {}", manifest_path.display()))?;
861
862    Ok(GroupOutputSummary {
863        manifest_path,
864        actions: summary,
865    })
866}
867
868/// Export one PNG or a directory of PNG frames into a preview GIF.
869///
870/// When the input is a directory, only the top-level PNG files are read and
871/// then sorted by file name before export.
872pub fn export_gif(options: GifOptions) -> Result<GifOutput> {
873    let mut frame_paths = collect_png_files(&options.input)?;
874    if frame_paths.is_empty() {
875        bail!("no png frames found under {}", options.input.display());
876    }
877    frame_paths.sort();
878
879    let mut decoded_frames = Vec::with_capacity(frame_paths.len());
880    let mut canvas_width = 0_u32;
881    let mut canvas_height = 0_u32;
882
883    for path in &frame_paths {
884        let image = image::open(path)
885            .with_context(|| format!("failed to open frame {}", path.display()))?
886            .to_rgba8();
887        canvas_width = canvas_width.max(image.width());
888        canvas_height = canvas_height.max(image.height());
889        decoded_frames.push(image);
890    }
891
892    canvas_width += options.pad * 2;
893    canvas_height += options.pad * 2;
894
895    if canvas_width > u16::MAX as u32 || canvas_height > u16::MAX as u32 {
896        bail!("gif canvas too large: {}x{}", canvas_width, canvas_height);
897    }
898
899    if let Some(parent) = options.output.parent() {
900        fs::create_dir_all(parent)
901            .with_context(|| format!("failed to create {}", parent.display()))?;
902    }
903
904    let file = fs::File::create(&options.output)
905        .with_context(|| format!("failed to create {}", options.output.display()))?;
906    let mut encoder = Encoder::new(file, canvas_width as u16, canvas_height as u16, &[])
907        .with_context(|| format!("failed to initialize gif {}", options.output.display()))?;
908
909    if options.repeat == 0 {
910        encoder.set_repeat(Repeat::Infinite)?;
911    } else {
912        encoder.set_repeat(Repeat::Finite(options.repeat))?;
913    }
914
915    let delay = fps_to_gif_delay(options.fps);
916
917    for image in decoded_frames {
918        let mut canvas = RgbaImage::new(canvas_width, canvas_height);
919        let offset_x = ((canvas_width - image.width()) / 2) as i64;
920        let offset_y = ((canvas_height - image.height()) / 2) as i64;
921        image::imageops::overlay(&mut canvas, &image, offset_x, offset_y);
922
923        let mut rgba = canvas.into_raw();
924        let mut frame =
925            Frame::from_rgba_speed(canvas_width as u16, canvas_height as u16, &mut rgba, 10);
926        frame.delay = delay;
927        encoder
928            .write_frame(&frame)
929            .with_context(|| format!("failed writing gif frame to {}", options.output.display()))?;
930    }
931
932    Ok(GifOutput {
933        output_path: options.output,
934        frame_count: frame_paths.len(),
935        canvas_width,
936        canvas_height,
937        fps: options.fps,
938    })
939}
940
941/// Remove a connected background color and save the result as a transparent PNG.
942///
943/// Only regions connected to the image boundary are removed, which helps keep
944/// internal dark outlines or details intact.
945pub fn remove_background(options: RemoveBgOptions) -> Result<RemoveBgOutput> {
946    let mut image = image::open(&options.input)
947        .with_context(|| format!("failed to open image {}", options.input.display()))?
948        .to_rgba8();
949    let bg_color = parse_hex_color(&options.bg_hex)?;
950    let removed_pixels = remove_connected_background(
951        &mut image,
952        bg_color,
953        options.threshold,
954        options.alpha_threshold,
955    );
956
957    if let Some(parent) = options.output.parent() {
958        fs::create_dir_all(parent)
959            .with_context(|| format!("failed to create {}", parent.display()))?;
960    }
961    image
962        .save(&options.output)
963        .with_context(|| format!("failed to save {}", options.output.display()))?;
964
965    Ok(RemoveBgOutput {
966        output_path: options.output,
967        removed_pixels,
968    })
969}
970
971/// Normalize one PNG or a directory of PNG frames onto a shared canvas.
972///
973/// This is typically used before importing animation frames into a game engine
974/// so that position changes between actions stay visually stable.
975pub fn normalize_frames(options: NormalizeOptions) -> Result<NormalizeOutput> {
976    let mut frame_paths = collect_png_files(&options.input)?;
977    if frame_paths.is_empty() {
978        bail!("no png frames found under {}", options.input.display());
979    }
980    frame_paths.sort();
981
982    let mut images = Vec::with_capacity(frame_paths.len());
983    let mut target_width = options.width.unwrap_or(0);
984    let mut target_height = options.height.unwrap_or(0);
985
986    for path in &frame_paths {
987        let image = image::open(path)
988            .with_context(|| format!("failed to open frame {}", path.display()))?
989            .to_rgba8();
990        target_width = target_width.max(image.width());
991        target_height = target_height.max(image.height());
992        images.push(image);
993    }
994
995    target_width += options.pad * 2;
996    target_height += options.pad * 2;
997
998    fs::create_dir_all(&options.output)
999        .with_context(|| format!("failed to create {}", options.output.display()))?;
1000
1001    for (index, image) in images.into_iter().enumerate() {
1002        let mut canvas = RgbaImage::new(target_width, target_height);
1003        let offset_x =
1004            horizontal_offset(target_width, image.width(), options.pad, options.anchor_x);
1005        let offset_y =
1006            vertical_offset(target_height, image.height(), options.pad, options.anchor_y);
1007        image::imageops::overlay(&mut canvas, &image, offset_x as i64, offset_y as i64);
1008
1009        let output_name = format!("{index:04}.png");
1010        let output_path = options.output.join(output_name);
1011        canvas
1012            .save(&output_path)
1013            .with_context(|| format!("failed to save {}", output_path.display()))?;
1014    }
1015
1016    Ok(NormalizeOutput {
1017        output_dir: options.output,
1018        frame_count: frame_paths.len(),
1019        canvas_width: target_width,
1020        canvas_height: target_height,
1021        anchor_x: options.anchor_x,
1022        anchor_y: options.anchor_y,
1023    })
1024}
1025
1026/// Postprocess a magenta-backed sprite sheet into normalized transparent frames,
1027/// a recomposed transparent sheet, a GIF preview, and QC metadata.
1028pub fn process_sprite_sheet(options: ProcessSheetOptions) -> Result<ProcessSheetOutput> {
1029    if options.rows == 0 || options.cols == 0 {
1030        bail!("rows and cols must be greater than zero");
1031    }
1032    if !(0.0 < options.fit_scale && options.fit_scale <= 1.0) {
1033        bail!("fit_scale must be within (0, 1]");
1034    }
1035    if options.cell_size == 0 {
1036        bail!("cell_size must be greater than zero");
1037    }
1038
1039    let frame_count = (options.rows * options.cols) as usize;
1040    let labels = match options.frame_labels.clone() {
1041        Some(labels) => {
1042            if labels.len() != frame_count {
1043                bail!(
1044                    "frame_labels length mismatch: expected {}, got {}",
1045                    frame_count,
1046                    labels.len()
1047                );
1048            }
1049            labels
1050        }
1051        None => (0..frame_count)
1052            .map(|index| format!("frame-{:04}", index + 1))
1053            .collect(),
1054    };
1055
1056    fs::create_dir_all(&options.output_dir)
1057        .with_context(|| format!("failed to create {}", options.output_dir.display()))?;
1058
1059    let raw = image::open(&options.input)
1060        .with_context(|| format!("failed to open image {}", options.input.display()))?
1061        .to_rgba8();
1062    validate_sheet_divisible(raw.width(), raw.height(), options.rows, options.cols)?;
1063
1064    let raw_sheet_path = options.output_dir.join("raw-sheet.png");
1065    raw.save(&raw_sheet_path)
1066        .with_context(|| format!("failed to save {}", raw_sheet_path.display()))?;
1067
1068    let bg_color = parse_hex_color(&options.bg_hex)?;
1069    let mut cleaned_sheet = raw.clone();
1070    remove_bg_by_distance(
1071        &mut cleaned_sheet,
1072        bg_color,
1073        options.threshold,
1074        options.edge_threshold,
1075    );
1076    let raw_sheet_clean_path = options.output_dir.join("raw-sheet-clean.png");
1077    cleaned_sheet
1078        .save(&raw_sheet_clean_path)
1079        .with_context(|| format!("failed to save {}", raw_sheet_clean_path.display()))?;
1080
1081    let (frames, frame_info) = split_and_normalize_grid(&cleaned_sheet, &options)?;
1082    let edge_touch_frames: Vec<[u32; 2]> = frame_info
1083        .iter()
1084        .filter(|info| info.edge_touch)
1085        .map(|info| info.grid)
1086        .collect();
1087    if options.reject_edge_touch && !edge_touch_frames.is_empty() {
1088        bail!("frames touch a cell edge: {:?}", edge_touch_frames);
1089    }
1090
1091    let mut frame_paths = Vec::with_capacity(frames.len());
1092    for (label, frame) in labels.iter().zip(&frames) {
1093        let path = options.output_dir.join(format!("{label}.png"));
1094        frame
1095            .save(&path)
1096            .with_context(|| format!("failed to save {}", path.display()))?;
1097        frame_paths.push(path);
1098    }
1099
1100    let sheet = compose_grid_sheet(&frames, options.rows, options.cols, options.cell_size);
1101    let sheet_path = options.output_dir.join("sheet-transparent.png");
1102    sheet
1103        .save(&sheet_path)
1104        .with_context(|| format!("failed to save {}", sheet_path.display()))?;
1105
1106    let gif_path = options.output_dir.join("animation.gif");
1107    save_transparent_gif_frames(&frames, &gif_path, options.gif_delay)?;
1108
1109    if let Some(prompt) = options.prompt {
1110        let prompt_path = options.output_dir.join("prompt-used.txt");
1111        fs::write(&prompt_path, prompt)
1112            .with_context(|| format!("failed to write {}", prompt_path.display()))?;
1113    }
1114
1115    let metadata = ProcessSheetMetadata {
1116        input: canonicalize_if_possible(&options.input),
1117        raw_sheet: raw_sheet_path.clone(),
1118        raw_sheet_clean: raw_sheet_clean_path.clone(),
1119        rows: options.rows,
1120        cols: options.cols,
1121        cell_size: options.cell_size,
1122        threshold: options.threshold,
1123        edge_threshold: options.edge_threshold,
1124        fit_scale: options.fit_scale,
1125        trim_border: options.trim_border,
1126        edge_clean_depth: options.edge_clean_depth,
1127        align: options.align,
1128        shared_scale: options.shared_scale,
1129        component_mode: options.component_mode,
1130        component_padding: options.component_padding,
1131        min_component_area: options.min_component_area,
1132        edge_touch_margin: options.edge_touch_margin,
1133        reject_edge_touch: options.reject_edge_touch,
1134        gif_delay: options.gif_delay,
1135        frame_labels: labels,
1136        edge_touch_frames,
1137        frames: frame_info,
1138    };
1139    let metadata_path = options.output_dir.join("pipeline-meta.json");
1140    fs::write(&metadata_path, serde_json::to_string_pretty(&metadata)?)
1141        .with_context(|| format!("failed to write {}", metadata_path.display()))?;
1142
1143    Ok(ProcessSheetOutput {
1144        output_dir: options.output_dir,
1145        sheet_path,
1146        gif_path,
1147        metadata_path,
1148        frame_paths,
1149        frame_count,
1150        edge_touch_frames: metadata.edge_touch_frames.clone(),
1151    })
1152}
1153
1154fn derive_grid_count(total: u32, offset: u32, frame: u32, gap: u32) -> u32 {
1155    if total <= offset || total < offset + frame {
1156        return 0;
1157    }
1158    let step = frame + gap;
1159    1 + (total - offset - frame) / step
1160}
1161
1162fn validate_grid(
1163    image_width: u32,
1164    image_height: u32,
1165    columns: u32,
1166    rows: u32,
1167    offset_x: u32,
1168    offset_y: u32,
1169    frame_width: u32,
1170    frame_height: u32,
1171    gap_x: u32,
1172    gap_y: u32,
1173) -> Result<()> {
1174    if columns == 0 || rows == 0 {
1175        bail!("grid resolved to zero columns or rows");
1176    }
1177
1178    let last_right = offset_x + columns * frame_width + columns.saturating_sub(1) * gap_x;
1179    let last_bottom = offset_y + rows * frame_height + rows.saturating_sub(1) * gap_y;
1180    if last_right > image_width || last_bottom > image_height {
1181        bail!(
1182            "grid exceeds image bounds: need {}x{}, image is {}x{}",
1183            last_right,
1184            last_bottom,
1185            image_width,
1186            image_height
1187        );
1188    }
1189
1190    Ok(())
1191}
1192
1193fn parse_hex_color(input: &str) -> Result<[u8; 3]> {
1194    let trimmed = input.trim().trim_start_matches('#');
1195    if trimmed.len() != 6 {
1196        bail!("background color must be a 6-digit hex value, got {input}");
1197    }
1198
1199    let red = u8::from_str_radix(&trimmed[0..2], 16)
1200        .with_context(|| format!("invalid red channel in {input}"))?;
1201    let green = u8::from_str_radix(&trimmed[2..4], 16)
1202        .with_context(|| format!("invalid green channel in {input}"))?;
1203    let blue = u8::from_str_radix(&trimmed[4..6], 16)
1204        .with_context(|| format!("invalid blue channel in {input}"))?;
1205
1206    Ok([red, green, blue])
1207}
1208
1209fn count_foreground_pixels(
1210    image: &RgbaImage,
1211    bg_color: Option<[u8; 3]>,
1212    bg_threshold: u8,
1213    alpha_threshold: u8,
1214) -> u32 {
1215    image
1216        .pixels()
1217        .filter(|pixel| {
1218            if pixel[3] <= alpha_threshold {
1219                return false;
1220            }
1221
1222            match bg_color {
1223                Some(color) => !channels_close([pixel[0], pixel[1], pixel[2]], color, bg_threshold),
1224                None => true,
1225            }
1226        })
1227        .count() as u32
1228}
1229
1230fn detect_components(
1231    image: &RgbaImage,
1232    bg_color: Option<[u8; 3]>,
1233    bg_threshold: u8,
1234    alpha_threshold: u8,
1235    min_opaque_pixels: u32,
1236    padding: u32,
1237) -> Vec<ComponentBounds> {
1238    let width = image.width() as usize;
1239    let height = image.height() as usize;
1240    let mut foreground = vec![false; width * height];
1241
1242    for y in 0..height {
1243        for x in 0..width {
1244            let pixel = image.get_pixel(x as u32, y as u32);
1245            let is_foreground = pixel[3] > alpha_threshold
1246                && match bg_color {
1247                    Some(color) => {
1248                        !channels_close([pixel[0], pixel[1], pixel[2]], color, bg_threshold)
1249                    }
1250                    None => true,
1251                };
1252            foreground[y * width + x] = is_foreground;
1253        }
1254    }
1255
1256    let mut visited = vec![false; width * height];
1257    let mut components = Vec::new();
1258
1259    for y in 0..height {
1260        for x in 0..width {
1261            let start = y * width + x;
1262            if !foreground[start] || visited[start] {
1263                continue;
1264            }
1265
1266            let mut queue = VecDeque::from([(x as u32, y as u32)]);
1267            visited[start] = true;
1268
1269            let mut min_x = x as u32;
1270            let mut max_x = x as u32;
1271            let mut min_y = y as u32;
1272            let mut max_y = y as u32;
1273            let mut opaque_pixels = 0_u32;
1274
1275            while let Some((cx, cy)) = queue.pop_front() {
1276                opaque_pixels += 1;
1277                min_x = min_x.min(cx);
1278                max_x = max_x.max(cx);
1279                min_y = min_y.min(cy);
1280                max_y = max_y.max(cy);
1281
1282                for (nx, ny) in neighbors(cx, cy, image.width(), image.height()) {
1283                    let idx = ny as usize * width + nx as usize;
1284                    if foreground[idx] && !visited[idx] {
1285                        visited[idx] = true;
1286                        queue.push_back((nx, ny));
1287                    }
1288                }
1289            }
1290
1291            if opaque_pixels < min_opaque_pixels {
1292                continue;
1293            }
1294
1295            let padded_x = min_x.saturating_sub(padding);
1296            let padded_y = min_y.saturating_sub(padding);
1297            let padded_right = (max_x + 1 + padding).min(image.width());
1298            let padded_bottom = (max_y + 1 + padding).min(image.height());
1299            let padded_width = padded_right - padded_x;
1300            let padded_height = padded_bottom - padded_y;
1301
1302            components.push(ComponentBounds {
1303                x: padded_x,
1304                y: padded_y,
1305                width: padded_width,
1306                height: padded_height,
1307                opaque_pixels,
1308                center_y: (min_y + max_y) as f32 / 2.0,
1309            });
1310        }
1311    }
1312
1313    components.sort_by(|left, right| {
1314        left.y
1315            .cmp(&right.y)
1316            .then(left.x.cmp(&right.x))
1317            .then(right.opaque_pixels.cmp(&left.opaque_pixels))
1318    });
1319    components
1320}
1321
1322fn neighbors(x: u32, y: u32, width: u32, height: u32) -> impl Iterator<Item = (u32, u32)> {
1323    let mut items = Vec::with_capacity(8);
1324    for dy in -1_i32..=1 {
1325        for dx in -1_i32..=1 {
1326            if dx == 0 && dy == 0 {
1327                continue;
1328            }
1329            let nx = x as i32 + dx;
1330            let ny = y as i32 + dy;
1331            if nx >= 0 && ny >= 0 && nx < width as i32 && ny < height as i32 {
1332                items.push((nx as u32, ny as u32));
1333            }
1334        }
1335    }
1336    items.into_iter()
1337}
1338
1339fn assign_rows(components: &[ComponentBounds], row_tolerance: u32) -> Vec<Vec<usize>> {
1340    let mut order: Vec<usize> = (0..components.len()).collect();
1341    order.sort_by(|left, right| {
1342        components[*left]
1343            .center_y
1344            .total_cmp(&components[*right].center_y)
1345            .then(components[*left].x.cmp(&components[*right].x))
1346    });
1347
1348    let mut rows: Vec<Vec<usize>> = Vec::new();
1349    let mut row_centers: Vec<f32> = Vec::new();
1350
1351    for component_index in order {
1352        let center_y = components[component_index].center_y;
1353        if let Some((row_index, _)) = row_centers
1354            .iter()
1355            .enumerate()
1356            .find(|(_, row_center)| (center_y - **row_center).abs() <= row_tolerance as f32)
1357        {
1358            rows[row_index].push(component_index);
1359            let count = rows[row_index].len() as f32;
1360            row_centers[row_index] = ((row_centers[row_index] * (count - 1.0)) + center_y) / count;
1361        } else {
1362            rows.push(vec![component_index]);
1363            row_centers.push(center_y);
1364        }
1365    }
1366
1367    for row in &mut rows {
1368        row.sort_by_key(|component_index| components[*component_index].x);
1369    }
1370
1371    rows.sort_by_key(|row| components[row[0]].y);
1372    rows
1373}
1374
1375fn channels_close(lhs: [u8; 3], rhs: [u8; 3], threshold: u8) -> bool {
1376    lhs.into_iter()
1377        .zip(rhs)
1378        .all(|(left, right)| left.abs_diff(right) <= threshold)
1379}
1380
1381fn build_index_map(manifest: &SliceManifest) -> String {
1382    let digits = manifest
1383        .frames
1384        .len()
1385        .saturating_sub(1)
1386        .to_string()
1387        .len()
1388        .max(4);
1389    let mut output = String::new();
1390
1391    for row in 0..manifest.rows {
1392        for column in 0..manifest.columns {
1393            let index = (row * manifest.columns + column) as usize;
1394            let frame = &manifest.frames[index];
1395            if frame.kept {
1396                output.push_str(&format!("{:0digits$}", frame.index));
1397            } else {
1398                output.push_str(&"-".repeat(digits));
1399            }
1400            if column + 1 < manifest.columns {
1401                output.push(' ');
1402            }
1403        }
1404        output.push('\n');
1405    }
1406
1407    output
1408}
1409
1410fn build_sparse_index_map(rows: &[Vec<usize>], frames: &[FrameRecord]) -> String {
1411    let digits = frames.len().saturating_sub(1).to_string().len().max(4);
1412    let mut output = String::new();
1413
1414    for row in rows {
1415        for (position, frame_index) in row.iter().enumerate() {
1416            output.push_str(&format!("{:0digits$}", frames[*frame_index].index));
1417            if position + 1 < row.len() {
1418                output.push(' ');
1419            }
1420        }
1421        output.push('\n');
1422    }
1423
1424    output
1425}
1426
1427fn collect_png_files(input: &Path) -> Result<Vec<PathBuf>> {
1428    if input.is_file() {
1429        if is_png(input) {
1430            return Ok(vec![input.to_path_buf()]);
1431        }
1432        bail!("input file is not a png: {}", input.display());
1433    }
1434
1435    if !input.is_dir() {
1436        bail!("input path does not exist: {}", input.display());
1437    }
1438
1439    let mut files = Vec::new();
1440    for entry in
1441        fs::read_dir(input).with_context(|| format!("failed to read {}", input.display()))?
1442    {
1443        let path = entry?.path();
1444        if path.is_file() && is_png(&path) {
1445            files.push(path);
1446        }
1447    }
1448    Ok(files)
1449}
1450
1451fn is_png(path: &Path) -> bool {
1452    path.extension()
1453        .and_then(|ext| ext.to_str())
1454        .map(|ext| ext.eq_ignore_ascii_case("png"))
1455        .unwrap_or(false)
1456}
1457
1458fn fps_to_gif_delay(fps: u16) -> u16 {
1459    let fps = fps.max(1) as f32;
1460    ((100.0 / fps).round() as u16).max(1)
1461}
1462
1463fn remove_connected_background(
1464    image: &mut RgbaImage,
1465    bg_color: [u8; 3],
1466    threshold: u8,
1467    alpha_threshold: u8,
1468) -> u32 {
1469    let width = image.width() as usize;
1470    let height = image.height() as usize;
1471    let mut visited = vec![false; width * height];
1472    let mut queue = VecDeque::new();
1473
1474    for x in 0..image.width() {
1475        queue_if_background(
1476            image,
1477            x,
1478            0,
1479            bg_color,
1480            threshold,
1481            alpha_threshold,
1482            &mut visited,
1483            &mut queue,
1484        );
1485        if image.height() > 1 {
1486            queue_if_background(
1487                image,
1488                x,
1489                image.height() - 1,
1490                bg_color,
1491                threshold,
1492                alpha_threshold,
1493                &mut visited,
1494                &mut queue,
1495            );
1496        }
1497    }
1498
1499    for y in 0..image.height() {
1500        queue_if_background(
1501            image,
1502            0,
1503            y,
1504            bg_color,
1505            threshold,
1506            alpha_threshold,
1507            &mut visited,
1508            &mut queue,
1509        );
1510        if image.width() > 1 {
1511            queue_if_background(
1512                image,
1513                image.width() - 1,
1514                y,
1515                bg_color,
1516                threshold,
1517                alpha_threshold,
1518                &mut visited,
1519                &mut queue,
1520            );
1521        }
1522    }
1523
1524    let mut removed = 0_u32;
1525    while let Some((x, y)) = queue.pop_front() {
1526        let pixel = image.get_pixel_mut(x, y);
1527        if pixel[3] != 0 {
1528            pixel[3] = 0;
1529            removed += 1;
1530        }
1531
1532        for (nx, ny) in neighbors(x, y, image.width(), image.height()) {
1533            let idx = ny as usize * width + nx as usize;
1534            if visited[idx] {
1535                continue;
1536            }
1537            let neighbor = image.get_pixel(nx, ny);
1538            if neighbor[3] <= alpha_threshold {
1539                visited[idx] = true;
1540                continue;
1541            }
1542            if channels_close([neighbor[0], neighbor[1], neighbor[2]], bg_color, threshold) {
1543                visited[idx] = true;
1544                queue.push_back((nx, ny));
1545            }
1546        }
1547    }
1548
1549    removed
1550}
1551
1552fn queue_if_background(
1553    image: &RgbaImage,
1554    x: u32,
1555    y: u32,
1556    bg_color: [u8; 3],
1557    threshold: u8,
1558    alpha_threshold: u8,
1559    visited: &mut [bool],
1560    queue: &mut VecDeque<(u32, u32)>,
1561) {
1562    let idx = y as usize * image.width() as usize + x as usize;
1563    if visited[idx] {
1564        return;
1565    }
1566    let pixel = image.get_pixel(x, y);
1567    if pixel[3] <= alpha_threshold
1568        || channels_close([pixel[0], pixel[1], pixel[2]], bg_color, threshold)
1569    {
1570        visited[idx] = true;
1571        queue.push_back((x, y));
1572    }
1573}
1574
1575fn horizontal_offset(target_width: u32, frame_width: u32, pad: u32, anchor: AnchorX) -> u32 {
1576    match anchor {
1577        AnchorX::Left => pad,
1578        AnchorX::Center => (target_width.saturating_sub(frame_width)) / 2,
1579        AnchorX::Right => target_width.saturating_sub(frame_width + pad),
1580    }
1581}
1582
1583fn vertical_offset(target_height: u32, frame_height: u32, pad: u32, anchor: AnchorY) -> u32 {
1584    match anchor {
1585        AnchorY::Top => pad,
1586        AnchorY::Center => (target_height.saturating_sub(frame_height)) / 2,
1587        AnchorY::Bottom => target_height.saturating_sub(frame_height + pad),
1588    }
1589}
1590
1591fn canonicalize_if_possible(path: &Path) -> PathBuf {
1592    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1593}
1594
1595fn validate_sheet_divisible(width: u32, height: u32, rows: u32, cols: u32) -> Result<()> {
1596    if width % cols != 0 || height % rows != 0 {
1597        bail!(
1598            "sheet dimensions must divide evenly by the grid: image {}x{}, grid {}x{}",
1599            width,
1600            height,
1601            rows,
1602            cols
1603        );
1604    }
1605    Ok(())
1606}
1607
1608fn remove_bg_by_distance(
1609    image: &mut RgbaImage,
1610    bg_color: [u8; 3],
1611    threshold: u8,
1612    edge_threshold: u8,
1613) {
1614    for pixel in image.pixels_mut() {
1615        if pixel[3] == 0 {
1616            continue;
1617        }
1618        if rgb_distance([pixel[0], pixel[1], pixel[2]], bg_color) < threshold as f32 {
1619            *pixel = Rgba([0, 0, 0, 0]);
1620        }
1621    }
1622
1623    let width = image.width() as usize;
1624    let height = image.height() as usize;
1625    let mut visited = vec![false; width * height];
1626    let mut queue = VecDeque::new();
1627
1628    for x in 0..image.width() {
1629        queue_border_pixel(
1630            image,
1631            x,
1632            0,
1633            bg_color,
1634            edge_threshold,
1635            &mut visited,
1636            &mut queue,
1637        );
1638        if image.height() > 1 {
1639            queue_border_pixel(
1640                image,
1641                x,
1642                image.height() - 1,
1643                bg_color,
1644                edge_threshold,
1645                &mut visited,
1646                &mut queue,
1647            );
1648        }
1649    }
1650    for y in 0..image.height() {
1651        queue_border_pixel(
1652            image,
1653            0,
1654            y,
1655            bg_color,
1656            edge_threshold,
1657            &mut visited,
1658            &mut queue,
1659        );
1660        if image.width() > 1 {
1661            queue_border_pixel(
1662                image,
1663                image.width() - 1,
1664                y,
1665                bg_color,
1666                edge_threshold,
1667                &mut visited,
1668                &mut queue,
1669            );
1670        }
1671    }
1672
1673    while let Some((x, y)) = queue.pop_front() {
1674        let pixel = image.get_pixel_mut(x, y);
1675        let was_opaque = pixel[3] != 0;
1676        *pixel = Rgba([0, 0, 0, 0]);
1677        if !was_opaque {
1678            continue;
1679        }
1680        for (nx, ny) in neighbors(x, y, image.width(), image.height()) {
1681            let idx = ny as usize * width + nx as usize;
1682            if visited[idx] {
1683                continue;
1684            }
1685            visited[idx] = true;
1686            let neighbor = image.get_pixel(nx, ny);
1687            if neighbor[3] == 0
1688                || rgb_distance([neighbor[0], neighbor[1], neighbor[2]], bg_color)
1689                    < edge_threshold as f32
1690            {
1691                queue.push_back((nx, ny));
1692            }
1693        }
1694    }
1695}
1696
1697fn queue_border_pixel(
1698    image: &RgbaImage,
1699    x: u32,
1700    y: u32,
1701    bg_color: [u8; 3],
1702    edge_threshold: u8,
1703    visited: &mut [bool],
1704    queue: &mut VecDeque<(u32, u32)>,
1705) {
1706    let idx = y as usize * image.width() as usize + x as usize;
1707    if visited[idx] {
1708        return;
1709    }
1710    visited[idx] = true;
1711    let pixel = image.get_pixel(x, y);
1712    if pixel[3] == 0
1713        || rgb_distance([pixel[0], pixel[1], pixel[2]], bg_color) < edge_threshold as f32
1714    {
1715        queue.push_back((x, y));
1716    }
1717}
1718
1719fn rgb_distance(lhs: [u8; 3], rhs: [u8; 3]) -> f32 {
1720    let dr = lhs[0] as f32 - rhs[0] as f32;
1721    let dg = lhs[1] as f32 - rhs[1] as f32;
1722    let db = lhs[2] as f32 - rhs[2] as f32;
1723    (dr * dr + dg * dg + db * db).sqrt()
1724}
1725
1726fn split_and_normalize_grid(
1727    sheet: &RgbaImage,
1728    options: &ProcessSheetOptions,
1729) -> Result<(Vec<RgbaImage>, Vec<ProcessedFrameInfo>)> {
1730    let cell_width = sheet.width() / options.cols;
1731    let cell_height = sheet.height() / options.rows;
1732
1733    let mut cropped = Vec::with_capacity((options.rows * options.cols) as usize);
1734    let mut infos = Vec::with_capacity((options.rows * options.cols) as usize);
1735
1736    for row in 0..options.rows {
1737        for col in 0..options.cols {
1738            let source_box = [
1739                col * cell_width,
1740                row * cell_height,
1741                (col + 1) * cell_width,
1742                (row + 1) * cell_height,
1743            ];
1744            let mut frame = image::imageops::crop_imm(
1745                sheet,
1746                source_box[0],
1747                source_box[1],
1748                cell_width,
1749                cell_height,
1750            )
1751            .to_image();
1752            if options.trim_border > 0 {
1753                frame = trim_rgba_border(&frame, options.trim_border);
1754            }
1755            if options.edge_clean_depth > 0 {
1756                clean_frame_edges(&mut frame, options.edge_clean_depth, [255, 0, 255]);
1757            }
1758
1759            let components = detect_alpha_components(&frame, options.min_component_area);
1760            let (selected_component, crop_bbox) = match options.component_mode {
1761                ComponentMode::Largest => {
1762                    let component = components.first().cloned();
1763                    let bbox = component.as_ref().map(|component| {
1764                        pad_bbox(
1765                            component.bbox,
1766                            options.component_padding,
1767                            frame.width(),
1768                            frame.height(),
1769                        )
1770                    });
1771                    (component, bbox)
1772                }
1773                ComponentMode::All => (None, alpha_bbox(&frame)),
1774            };
1775
1776            let edge_touch = crop_bbox
1777                .map(|bbox| {
1778                    bbox_touches_edge(
1779                        bbox,
1780                        frame.width(),
1781                        frame.height(),
1782                        options.edge_touch_margin,
1783                    )
1784                })
1785                .unwrap_or(false);
1786
1787            let cropped_frame = crop_bbox
1788                .map(|bbox| crop_bbox_image(&frame, bbox))
1789                .unwrap_or_else(|| RgbaImage::new(0, 0));
1790
1791            infos.push(ProcessedFrameInfo {
1792                grid: [row, col],
1793                source_box,
1794                component_mode: options.component_mode,
1795                component_count: components.len(),
1796                selected_component_area: selected_component
1797                    .as_ref()
1798                    .map(|component| component.area),
1799                selected_component_bbox: selected_component
1800                    .as_ref()
1801                    .map(|component| bbox_to_array(component.bbox)),
1802                crop_bbox: crop_bbox.map(bbox_to_array),
1803                edge_touch,
1804                output_size: [0, 0],
1805                paste_position: [0, 0],
1806            });
1807            cropped.push(cropped_frame);
1808        }
1809    }
1810
1811    let shared_scale = if options.shared_scale {
1812        let max_width = cropped.iter().map(RgbaImage::width).max().unwrap_or(0);
1813        let max_height = cropped.iter().map(RgbaImage::height).max().unwrap_or(0);
1814        if max_width == 0 || max_height == 0 {
1815            None
1816        } else {
1817            Some(
1818                (options.cell_size as f32 / max_width as f32)
1819                    .min(options.cell_size as f32 / max_height as f32)
1820                    * options.fit_scale,
1821            )
1822        }
1823    } else {
1824        None
1825    };
1826
1827    let mut output = Vec::with_capacity(cropped.len());
1828    for (index, frame) in cropped.into_iter().enumerate() {
1829        let mut canvas = RgbaImage::new(options.cell_size, options.cell_size);
1830        if frame.width() == 0 || frame.height() == 0 {
1831            output.push(canvas);
1832            continue;
1833        }
1834
1835        let scale = shared_scale.unwrap_or_else(|| {
1836            (options.cell_size as f32 / frame.width() as f32)
1837                .min(options.cell_size as f32 / frame.height() as f32)
1838                * options.fit_scale
1839        });
1840        let output_width = ((frame.width() as f32 * scale).floor() as u32).max(1);
1841        let output_height = ((frame.height() as f32 * scale).floor() as u32).max(1);
1842        let resized =
1843            image::imageops::resize(&frame, output_width, output_height, FilterType::Lanczos3);
1844        let paste_x = horizontal_offset(options.cell_size, output_width, 0, AnchorX::Center);
1845        let pad = (options.cell_size as f32 * (1.0 - options.fit_scale) * 0.5).floor() as u32;
1846        let paste_y = vertical_offset(
1847            options.cell_size,
1848            output_height,
1849            pad,
1850            options.align.to_anchor_y(),
1851        );
1852        image::imageops::overlay(&mut canvas, &resized, paste_x as i64, paste_y as i64);
1853        infos[index].output_size = [output_width, output_height];
1854        infos[index].paste_position = [paste_x, paste_y];
1855        output.push(canvas);
1856    }
1857
1858    Ok((output, infos))
1859}
1860
1861fn compose_grid_sheet(frames: &[RgbaImage], rows: u32, cols: u32, cell_size: u32) -> RgbaImage {
1862    let mut canvas = RgbaImage::new(cols * cell_size, rows * cell_size);
1863    for (index, frame) in frames.iter().enumerate() {
1864        let row = index as u32 / cols;
1865        let col = index as u32 % cols;
1866        image::imageops::overlay(
1867            &mut canvas,
1868            frame,
1869            (col * cell_size) as i64,
1870            (row * cell_size) as i64,
1871        );
1872    }
1873    canvas
1874}
1875
1876fn save_transparent_gif_frames(frames: &[RgbaImage], output: &Path, delay: u16) -> Result<()> {
1877    if frames.is_empty() {
1878        bail!("no frames to encode");
1879    }
1880    if let Some(parent) = output.parent() {
1881        fs::create_dir_all(parent)
1882            .with_context(|| format!("failed to create {}", parent.display()))?;
1883    }
1884
1885    let width = frames[0].width();
1886    let height = frames[0].height();
1887    if width > u16::MAX as u32 || height > u16::MAX as u32 {
1888        bail!("gif canvas too large: {}x{}", width, height);
1889    }
1890
1891    let file = fs::File::create(output)
1892        .with_context(|| format!("failed to create {}", output.display()))?;
1893    let mut encoder = Encoder::new(file, width as u16, height as u16, &[])
1894        .with_context(|| format!("failed to initialize gif {}", output.display()))?;
1895    encoder.set_repeat(Repeat::Infinite)?;
1896
1897    let key = [255_u8, 0_u8, 254_u8, 0_u8];
1898    let stacked_height = height
1899        .checked_mul(frames.len() as u32)
1900        .with_context(|| format!("stacked gif height overflow for {}", output.display()))?;
1901    if stacked_height > u16::MAX as u32 {
1902        bail!("stacked gif canvas too large: {}x{}", width, stacked_height);
1903    }
1904
1905    let mut stacked = vec![0_u8; (width * stacked_height * 4) as usize];
1906    for pixel in stacked.chunks_exact_mut(4) {
1907        pixel.copy_from_slice(&key);
1908    }
1909
1910    for (frame_index, frame_image) in frames.iter().enumerate() {
1911        for y in 0..height {
1912            for x in 0..width {
1913                let src = frame_image.get_pixel(x, y);
1914                if src[3] < 128 {
1915                    continue;
1916                }
1917                let dest_y = y + frame_index as u32 * height;
1918                let idx = ((dest_y * width + x) * 4) as usize;
1919                stacked[idx] = src[0];
1920                stacked[idx + 1] = src[1];
1921                stacked[idx + 2] = src[2];
1922                stacked[idx + 3] = 255;
1923            }
1924        }
1925    }
1926
1927    let stacked_frame =
1928        Frame::from_rgba_speed(width as u16, stacked_height as u16, &mut stacked, 10);
1929    let palette = stacked_frame
1930        .palette
1931        .clone()
1932        .with_context(|| format!("failed to derive gif palette for {}", output.display()))?;
1933    let transparent = stacked_frame.transparent;
1934    let indexed = stacked_frame.buffer.into_owned();
1935    let frame_len = (width * height) as usize;
1936
1937    for frame_index in 0..frames.len() {
1938        let start = frame_index * frame_len;
1939        let end = start + frame_len;
1940        let frame = Frame {
1941            width: width as u16,
1942            height: height as u16,
1943            buffer: std::borrow::Cow::Owned(indexed[start..end].to_vec()),
1944            palette: Some(palette.clone()),
1945            transparent,
1946            delay: delay.max(1),
1947            dispose: DisposalMethod::Background,
1948            ..Frame::default()
1949        };
1950        encoder
1951            .write_frame(&frame)
1952            .with_context(|| format!("failed writing gif frame to {}", output.display()))?;
1953    }
1954    Ok(())
1955}
1956
1957fn trim_rgba_border(image: &RgbaImage, trim: u32) -> RgbaImage {
1958    if image.width() <= trim.saturating_mul(2) || image.height() <= trim.saturating_mul(2) {
1959        return image.clone();
1960    }
1961    image::imageops::crop_imm(
1962        image,
1963        trim,
1964        trim,
1965        image.width() - trim * 2,
1966        image.height() - trim * 2,
1967    )
1968    .to_image()
1969}
1970
1971fn clean_frame_edges(image: &mut RgbaImage, depth: u32, bg_color: [u8; 3]) {
1972    let width = image.width();
1973    let height = image.height();
1974    for d in 0..depth {
1975        if d >= width || d >= height {
1976            break;
1977        }
1978        for x in 0..width {
1979            maybe_clear_edge_pixel(image, x, d, bg_color);
1980            if height > 1 + d {
1981                maybe_clear_edge_pixel(image, x, height - 1 - d, bg_color);
1982            }
1983        }
1984        for y in 0..height {
1985            maybe_clear_edge_pixel(image, d, y, bg_color);
1986            if width > 1 + d {
1987                maybe_clear_edge_pixel(image, width - 1 - d, y, bg_color);
1988            }
1989        }
1990    }
1991}
1992
1993fn maybe_clear_edge_pixel(image: &mut RgbaImage, x: u32, y: u32, bg_color: [u8; 3]) {
1994    let pixel = image.get_pixel_mut(x, y);
1995    if pixel[3] == 0 {
1996        return;
1997    }
1998    let dark = pixel[0] < 40 && pixel[1] < 40 && pixel[2] < 40;
1999    let near_bg = rgb_distance([pixel[0], pixel[1], pixel[2]], bg_color) < 150.0;
2000    if dark || near_bg {
2001        *pixel = Rgba([0, 0, 0, 0]);
2002    }
2003}
2004
2005#[derive(Debug, Clone)]
2006struct AlphaComponent {
2007    area: u32,
2008    bbox: (u32, u32, u32, u32),
2009}
2010
2011fn detect_alpha_components(image: &RgbaImage, min_area: u32) -> Vec<AlphaComponent> {
2012    let width = image.width() as usize;
2013    let height = image.height() as usize;
2014    let mut visited = vec![false; width * height];
2015    let mut components = Vec::new();
2016
2017    for y in 0..height {
2018        for x in 0..width {
2019            let idx = y * width + x;
2020            if visited[idx] || image.get_pixel(x as u32, y as u32)[3] == 0 {
2021                continue;
2022            }
2023            visited[idx] = true;
2024            let mut queue = VecDeque::from([(x as u32, y as u32)]);
2025            let mut area = 0_u32;
2026            let mut min_x = x as u32;
2027            let mut min_y = y as u32;
2028            let mut max_x = x as u32;
2029            let mut max_y = y as u32;
2030
2031            while let Some((cx, cy)) = queue.pop_front() {
2032                area += 1;
2033                min_x = min_x.min(cx);
2034                min_y = min_y.min(cy);
2035                max_x = max_x.max(cx);
2036                max_y = max_y.max(cy);
2037
2038                for (nx, ny) in orthogonal_neighbors(cx, cy, image.width(), image.height()) {
2039                    let nidx = ny as usize * width + nx as usize;
2040                    if visited[nidx] || image.get_pixel(nx, ny)[3] == 0 {
2041                        continue;
2042                    }
2043                    visited[nidx] = true;
2044                    queue.push_back((nx, ny));
2045                }
2046            }
2047
2048            if area >= min_area {
2049                components.push(AlphaComponent {
2050                    area,
2051                    bbox: (min_x, min_y, max_x + 1, max_y + 1),
2052                });
2053            }
2054        }
2055    }
2056
2057    components.sort_by(|left, right| right.area.cmp(&left.area));
2058    components
2059}
2060
2061fn orthogonal_neighbors(
2062    x: u32,
2063    y: u32,
2064    width: u32,
2065    height: u32,
2066) -> impl Iterator<Item = (u32, u32)> {
2067    let mut items = Vec::with_capacity(4);
2068    if x > 0 {
2069        items.push((x - 1, y));
2070    }
2071    if x + 1 < width {
2072        items.push((x + 1, y));
2073    }
2074    if y > 0 {
2075        items.push((x, y - 1));
2076    }
2077    if y + 1 < height {
2078        items.push((x, y + 1));
2079    }
2080    items.into_iter()
2081}
2082
2083fn alpha_bbox(image: &RgbaImage) -> Option<(u32, u32, u32, u32)> {
2084    let mut min_x = u32::MAX;
2085    let mut min_y = u32::MAX;
2086    let mut max_x = 0;
2087    let mut max_y = 0;
2088    let mut seen = false;
2089
2090    for (x, y, pixel) in image.enumerate_pixels() {
2091        if pixel[3] == 0 {
2092            continue;
2093        }
2094        seen = true;
2095        min_x = min_x.min(x);
2096        min_y = min_y.min(y);
2097        max_x = max_x.max(x);
2098        max_y = max_y.max(y);
2099    }
2100
2101    seen.then_some((min_x, min_y, max_x + 1, max_y + 1))
2102}
2103
2104fn pad_bbox(
2105    bbox: (u32, u32, u32, u32),
2106    padding: u32,
2107    width: u32,
2108    height: u32,
2109) -> (u32, u32, u32, u32) {
2110    (
2111        bbox.0.saturating_sub(padding),
2112        bbox.1.saturating_sub(padding),
2113        (bbox.2 + padding).min(width),
2114        (bbox.3 + padding).min(height),
2115    )
2116}
2117
2118fn crop_bbox_image(image: &RgbaImage, bbox: (u32, u32, u32, u32)) -> RgbaImage {
2119    image::imageops::crop_imm(image, bbox.0, bbox.1, bbox.2 - bbox.0, bbox.3 - bbox.1).to_image()
2120}
2121
2122fn bbox_touches_edge(bbox: (u32, u32, u32, u32), width: u32, height: u32, margin: u32) -> bool {
2123    bbox.0 <= margin
2124        || bbox.1 <= margin
2125        || bbox.2 >= width.saturating_sub(margin)
2126        || bbox.3 >= height.saturating_sub(margin)
2127}
2128
2129fn bbox_to_array(bbox: (u32, u32, u32, u32)) -> [u32; 4] {
2130    [bbox.0, bbox.1, bbox.2, bbox.3]
2131}
2132
2133#[cfg(test)]
2134mod tests {
2135    use super::{
2136        AnchorX, AnchorY, ComponentBounds, ComponentMode, DetectionMode, FrameAlign, FrameRecord,
2137        ProcessSheetOptions, SliceManifest, alpha_bbox, assign_rows, bbox_touches_edge,
2138        build_index_map, build_sparse_index_map, channels_close, derive_grid_count,
2139        fps_to_gif_delay, horizontal_offset, parse_hex_color, process_sprite_sheet,
2140        remove_bg_by_distance, vertical_offset,
2141    };
2142    use image::{Rgba, RgbaImage};
2143    use std::path::PathBuf;
2144    use std::{fs, time::SystemTime};
2145
2146    #[test]
2147    fn parses_hex_color() {
2148        assert_eq!(parse_hex_color("#12abEF").unwrap(), [0x12, 0xab, 0xef]);
2149        assert!(parse_hex_color("xyz").is_err());
2150    }
2151
2152    #[test]
2153    fn derives_grid_count_from_image_size() {
2154        assert_eq!(derive_grid_count(256, 0, 64, 0), 4);
2155        assert_eq!(derive_grid_count(250, 10, 60, 5), 3);
2156    }
2157
2158    #[test]
2159    fn compares_channels_with_threshold() {
2160        assert!(channels_close([0, 0, 0], [1, 1, 1], 1));
2161        assert!(!channels_close([0, 0, 0], [2, 2, 2], 1));
2162    }
2163
2164    #[test]
2165    fn builds_index_map_with_empty_cells() {
2166        let manifest = SliceManifest {
2167            source: PathBuf::from("sheet.png"),
2168            frame_width: 64,
2169            frame_height: 64,
2170            columns: 2,
2171            rows: 2,
2172            offset_x: 0,
2173            offset_y: 0,
2174            gap_x: 0,
2175            gap_y: 0,
2176            alpha_threshold: 0,
2177            min_opaque_pixels: 1,
2178            bg_hex: None,
2179            bg_threshold: 0,
2180            detection: DetectionMode::Grid,
2181            frames: vec![
2182                FrameRecord {
2183                    index: 0,
2184                    row: 0,
2185                    column: 0,
2186                    x: 0,
2187                    y: 0,
2188                    width: 64,
2189                    height: 64,
2190                    opaque_pixels: 10,
2191                    kept: true,
2192                    file: Some("frames/frame_0000.png".to_string()),
2193                },
2194                FrameRecord {
2195                    index: 1,
2196                    row: 0,
2197                    column: 1,
2198                    x: 64,
2199                    y: 0,
2200                    width: 64,
2201                    height: 64,
2202                    opaque_pixels: 0,
2203                    kept: false,
2204                    file: None,
2205                },
2206                FrameRecord {
2207                    index: 2,
2208                    row: 1,
2209                    column: 0,
2210                    x: 0,
2211                    y: 64,
2212                    width: 64,
2213                    height: 64,
2214                    opaque_pixels: 8,
2215                    kept: true,
2216                    file: Some("frames/frame_0002.png".to_string()),
2217                },
2218                FrameRecord {
2219                    index: 3,
2220                    row: 1,
2221                    column: 1,
2222                    x: 64,
2223                    y: 64,
2224                    width: 64,
2225                    height: 64,
2226                    opaque_pixels: 9,
2227                    kept: true,
2228                    file: Some("frames/frame_0003.png".to_string()),
2229                },
2230            ],
2231        };
2232
2233        assert_eq!(build_index_map(&manifest), "0000 ----\n0002 0003\n");
2234    }
2235
2236    #[test]
2237    fn assigns_rows_from_detected_components() {
2238        let components = vec![
2239            ComponentBounds {
2240                x: 10,
2241                y: 10,
2242                width: 20,
2243                height: 20,
2244                opaque_pixels: 100,
2245                center_y: 20.0,
2246            },
2247            ComponentBounds {
2248                x: 60,
2249                y: 12,
2250                width: 20,
2251                height: 20,
2252                opaque_pixels: 100,
2253                center_y: 22.0,
2254            },
2255            ComponentBounds {
2256                x: 15,
2257                y: 70,
2258                width: 20,
2259                height: 20,
2260                opaque_pixels: 100,
2261                center_y: 80.0,
2262            },
2263        ];
2264
2265        let rows = assign_rows(&components, 8);
2266        assert_eq!(rows, vec![vec![0, 1], vec![2]]);
2267    }
2268
2269    #[test]
2270    fn builds_sparse_index_map_for_detected_layout() {
2271        let rows = vec![vec![0, 1], vec![2]];
2272        let frames = vec![
2273            FrameRecord {
2274                index: 0,
2275                row: 0,
2276                column: 0,
2277                x: 0,
2278                y: 0,
2279                width: 10,
2280                height: 10,
2281                opaque_pixels: 10,
2282                kept: true,
2283                file: Some("frames/0.png".to_string()),
2284            },
2285            FrameRecord {
2286                index: 1,
2287                row: 0,
2288                column: 1,
2289                x: 10,
2290                y: 0,
2291                width: 10,
2292                height: 10,
2293                opaque_pixels: 10,
2294                kept: true,
2295                file: Some("frames/1.png".to_string()),
2296            },
2297            FrameRecord {
2298                index: 2,
2299                row: 1,
2300                column: 0,
2301                x: 0,
2302                y: 10,
2303                width: 10,
2304                height: 10,
2305                opaque_pixels: 10,
2306                kept: true,
2307                file: Some("frames/2.png".to_string()),
2308            },
2309        ];
2310
2311        assert_eq!(build_sparse_index_map(&rows, &frames), "0000 0001\n0002\n");
2312    }
2313
2314    #[test]
2315    fn converts_fps_to_gif_delay() {
2316        assert_eq!(fps_to_gif_delay(10), 10);
2317        assert_eq!(fps_to_gif_delay(8), 13);
2318        assert_eq!(fps_to_gif_delay(0), 100);
2319    }
2320
2321    #[test]
2322    fn computes_offsets() {
2323        assert_eq!(horizontal_offset(128, 64, 4, AnchorX::Center), 32);
2324        assert_eq!(horizontal_offset(128, 64, 4, AnchorX::Right), 60);
2325        assert_eq!(vertical_offset(128, 64, 4, AnchorY::Bottom), 60);
2326        assert_eq!(vertical_offset(128, 64, 4, AnchorY::Top), 4);
2327    }
2328
2329    #[test]
2330    fn removes_magenta_background_by_distance() {
2331        let mut image = RgbaImage::from_pixel(4, 4, Rgba([255, 0, 255, 255]));
2332        image.put_pixel(1, 1, Rgba([10, 20, 30, 255]));
2333        remove_bg_by_distance(&mut image, [255, 0, 255], 100, 150);
2334        assert_eq!(image.get_pixel(0, 0)[3], 0);
2335        assert_eq!(image.get_pixel(1, 1)[3], 255);
2336    }
2337
2338    #[test]
2339    fn computes_alpha_bounding_box() {
2340        let mut image = RgbaImage::new(8, 8);
2341        image.put_pixel(2, 3, Rgba([255, 255, 255, 255]));
2342        image.put_pixel(5, 6, Rgba([255, 255, 255, 255]));
2343        assert_eq!(alpha_bbox(&image), Some((2, 3, 6, 7)));
2344    }
2345
2346    #[test]
2347    fn detects_bbox_edge_touch_with_margin() {
2348        assert!(bbox_touches_edge((1, 2, 7, 8), 8, 8, 1));
2349        assert!(!bbox_touches_edge((2, 2, 6, 6), 8, 8, 1));
2350    }
2351
2352    #[test]
2353    fn processes_sheet_and_emits_metadata() {
2354        let root = unique_test_dir("pipeline");
2355        let input = root.join("input.png");
2356        let output = root.join("out");
2357
2358        let mut image = RgbaImage::from_pixel(16, 16, Rgba([255, 0, 255, 255]));
2359        for y in 2..6 {
2360            for x in 2..6 {
2361                image.put_pixel(x, y, Rgba([0, 255, 0, 255]));
2362            }
2363        }
2364        for y in 10..14 {
2365            for x in 10..14 {
2366                image.put_pixel(x, y, Rgba([0, 200, 255, 255]));
2367            }
2368        }
2369        image.save(&input).unwrap();
2370
2371        let output_summary = process_sprite_sheet(ProcessSheetOptions {
2372            input: input.clone(),
2373            output_dir: output.clone(),
2374            rows: 2,
2375            cols: 2,
2376            cell_size: 32,
2377            bg_hex: "#FF00FF".to_string(),
2378            threshold: 100,
2379            edge_threshold: 150,
2380            fit_scale: 0.85,
2381            trim_border: 0,
2382            edge_clean_depth: 0,
2383            align: FrameAlign::Center,
2384            shared_scale: true,
2385            component_mode: ComponentMode::All,
2386            component_padding: 0,
2387            min_component_area: 1,
2388            edge_touch_margin: 0,
2389            reject_edge_touch: true,
2390            gif_delay: 10,
2391            frame_labels: Some(vec![
2392                "a".to_string(),
2393                "b".to_string(),
2394                "c".to_string(),
2395                "d".to_string(),
2396            ]),
2397            prompt: Some("demo".to_string()),
2398        })
2399        .unwrap();
2400
2401        assert!(output_summary.sheet_path.exists());
2402        assert!(output_summary.gif_path.exists());
2403        assert!(output_summary.metadata_path.exists());
2404        assert_eq!(output_summary.frame_paths.len(), 4);
2405        assert_eq!(output_summary.frame_count, 4);
2406        assert!(output_summary.edge_touch_frames.is_empty());
2407
2408        let metadata_text = fs::read_to_string(output_summary.metadata_path).unwrap();
2409        assert!(metadata_text.contains("\"edge_touch_frames\": []"));
2410        assert!(metadata_text.contains("\"frame_labels\""));
2411    }
2412
2413    fn unique_test_dir(name: &str) -> PathBuf {
2414        let nonce = SystemTime::now()
2415            .duration_since(SystemTime::UNIX_EPOCH)
2416            .unwrap()
2417            .as_nanos();
2418        let dir = std::env::temp_dir().join(format!("sprite-slicer-{name}-{nonce}"));
2419        fs::create_dir_all(&dir).unwrap();
2420        dir
2421    }
2422}