1use 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#[derive(Debug, Clone)]
27pub struct SliceOptions {
28 pub input: PathBuf,
30 pub output: PathBuf,
32 pub frame_width: u32,
34 pub frame_height: u32,
36 pub columns: Option<u32>,
38 pub rows: Option<u32>,
40 pub offset_x: u32,
42 pub offset_y: u32,
44 pub gap_x: u32,
46 pub gap_y: u32,
48 pub skip_empty: bool,
50 pub alpha_threshold: u8,
52 pub min_opaque_pixels: u32,
54 pub bg_hex: Option<String>,
56 pub bg_threshold: u8,
58 pub manifest_name: String,
60}
61
62#[derive(Debug, Clone)]
64pub struct DetectOptions {
65 pub input: PathBuf,
67 pub output: PathBuf,
69 pub alpha_threshold: u8,
71 pub min_opaque_pixels: u32,
73 pub padding: u32,
75 pub row_tolerance: u32,
77 pub bg_hex: Option<String>,
79 pub bg_threshold: u8,
81 pub manifest_name: String,
83}
84
85#[derive(Debug, Clone)]
87pub struct GroupOptions {
88 pub manifest: PathBuf,
90 pub config: PathBuf,
92 pub output: PathBuf,
94}
95
96#[derive(Debug, Clone)]
98pub struct GifOptions {
99 pub input: PathBuf,
101 pub output: PathBuf,
103 pub fps: u16,
105 pub repeat: u16,
107 pub pad: u32,
109}
110
111#[derive(Debug, Clone)]
113pub struct RemoveBgOptions {
114 pub input: PathBuf,
116 pub output: PathBuf,
118 pub bg_hex: String,
120 pub threshold: u8,
122 pub alpha_threshold: u8,
124}
125
126#[derive(Debug, Clone)]
128pub struct NormalizeOptions {
129 pub input: PathBuf,
131 pub output: PathBuf,
133 pub width: Option<u32>,
135 pub height: Option<u32>,
137 pub anchor_x: AnchorX,
139 pub anchor_y: AnchorY,
141 pub pad: u32,
143}
144
145#[derive(Debug, Clone)]
147pub struct SliceOutput {
148 pub manifest_path: PathBuf,
150 pub index_map_path: PathBuf,
152 pub frame_count: usize,
154 pub kept_frames: usize,
156}
157
158#[derive(Debug, Clone)]
160pub struct DetectOutput {
161 pub manifest_path: PathBuf,
163 pub index_map_path: PathBuf,
165 pub detected_frames: usize,
167 pub rows: usize,
169}
170
171#[derive(Debug, Clone)]
173pub struct GroupedActionSummary {
174 pub name: String,
176 pub frame_count: usize,
178}
179
180#[derive(Debug, Clone)]
182pub struct GroupOutputSummary {
183 pub manifest_path: PathBuf,
185 pub actions: Vec<GroupedActionSummary>,
187}
188
189#[derive(Debug, Clone)]
191pub struct GifOutput {
192 pub output_path: PathBuf,
194 pub frame_count: usize,
196 pub canvas_width: u32,
198 pub canvas_height: u32,
200 pub fps: u16,
202}
203
204#[derive(Debug, Clone)]
206pub struct RemoveBgOutput {
207 pub output_path: PathBuf,
209 pub removed_pixels: u32,
211}
212
213#[derive(Debug, Clone)]
215pub struct NormalizeOutput {
216 pub output_dir: PathBuf,
218 pub frame_count: usize,
220 pub canvas_width: u32,
222 pub canvas_height: u32,
224 pub anchor_x: AnchorX,
226 pub anchor_y: AnchorY,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(rename_all = "snake_case")]
233pub enum FrameAlign {
234 Center,
236 Bottom,
238 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "snake_case")]
254pub enum ComponentMode {
255 All,
257 Largest,
259}
260
261#[derive(Debug, Clone)]
263pub struct ProcessSheetOptions {
264 pub input: PathBuf,
266 pub output_dir: PathBuf,
268 pub rows: u32,
270 pub cols: u32,
272 pub cell_size: u32,
274 pub bg_hex: String,
276 pub threshold: u8,
278 pub edge_threshold: u8,
280 pub fit_scale: f32,
282 pub trim_border: u32,
284 pub edge_clean_depth: u32,
286 pub align: FrameAlign,
288 pub shared_scale: bool,
290 pub component_mode: ComponentMode,
292 pub component_padding: u32,
294 pub min_component_area: u32,
296 pub edge_touch_margin: u32,
298 pub reject_edge_touch: bool,
300 pub gif_delay: u16,
302 pub frame_labels: Option<Vec<String>>,
304 pub prompt: Option<String>,
306}
307
308#[derive(Debug, Clone)]
310pub struct ProcessSheetOutput {
311 pub output_dir: PathBuf,
313 pub sheet_path: PathBuf,
315 pub gif_path: PathBuf,
317 pub metadata_path: PathBuf,
319 pub frame_paths: Vec<PathBuf>,
321 pub frame_count: usize,
323 pub edge_touch_frames: Vec<[u32; 2]>,
325}
326
327#[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#[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#[derive(Debug, Serialize, Deserialize)]
371pub struct SliceManifest {
372 pub source: PathBuf,
374 pub frame_width: u32,
376 pub frame_height: u32,
378 pub columns: u32,
380 pub rows: u32,
382 pub offset_x: u32,
384 pub offset_y: u32,
386 pub gap_x: u32,
388 pub gap_y: u32,
390 pub alpha_threshold: u8,
392 pub min_opaque_pixels: u32,
394 pub bg_hex: Option<String>,
396 pub bg_threshold: u8,
398 pub detection: DetectionMode,
400 pub frames: Vec<FrameRecord>,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct FrameRecord {
407 pub index: usize,
409 pub row: u32,
411 pub column: u32,
413 pub x: u32,
415 pub y: u32,
417 pub width: u32,
419 pub height: u32,
421 pub opaque_pixels: u32,
423 pub kept: bool,
425 #[serde(skip_serializing_if = "Option::is_none")]
427 pub file: Option<String>,
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
432#[serde(rename_all = "snake_case")]
433pub enum DetectionMode {
434 Grid,
436 ConnectedComponents,
438}
439
440#[derive(Debug, Clone, Deserialize, Serialize)]
442pub struct ActionSpec {
443 pub name: String,
445 pub frames: Vec<usize>,
447}
448
449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
451pub enum AnchorX {
452 Left,
454 Center,
456 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486pub enum AnchorY {
487 Top,
489 Center,
491 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
547pub 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
674pub 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
780pub 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
868pub 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
941pub 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
971pub 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
1026pub 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}