ruby_spriter 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+
6
+ module RubySpriter
7
+ # Manages PNG compression with metadata preservation
8
+ class CompressionManager
9
+ # Compress PNG file using ImageMagick with maximum compression
10
+ # @param input_file [String] Source PNG file
11
+ # @param output_file [String] Destination PNG file
12
+ # @param debug [Boolean] Enable debug output
13
+ def self.compress(input_file, output_file, debug: false)
14
+ Utils::FileHelper.validate_readable!(input_file)
15
+
16
+ cmd = build_compression_command(input_file, output_file)
17
+
18
+ if debug
19
+ Utils::OutputFormatter.indent("DEBUG: Compression command: #{cmd}")
20
+ end
21
+
22
+ stdout, stderr, status = Open3.capture3(cmd)
23
+
24
+ unless status.success?
25
+ raise ProcessingError, "Failed to compress PNG: #{stderr}"
26
+ end
27
+
28
+ Utils::FileHelper.validate_exists!(output_file)
29
+ end
30
+
31
+ # Compress PNG file while preserving embedded metadata
32
+ # @param input_file [String] Source PNG file
33
+ # @param output_file [String] Destination PNG file
34
+ # @param debug [Boolean] Enable debug output
35
+ def self.compress_with_metadata(input_file, output_file, debug: false)
36
+ # Read metadata before compression
37
+ metadata = MetadataManager.read(input_file)
38
+
39
+ # Compress the file
40
+ temp_file = output_file.gsub('.png', '_compress_temp.png')
41
+ compress(input_file, temp_file, debug: debug)
42
+
43
+ # Re-embed metadata if it existed
44
+ if metadata
45
+ MetadataManager.embed(
46
+ temp_file,
47
+ output_file,
48
+ columns: metadata[:columns],
49
+ rows: metadata[:rows],
50
+ frames: metadata[:frames],
51
+ debug: debug
52
+ )
53
+
54
+ # Clean up temp file
55
+ FileUtils.rm_f(temp_file) if File.exist?(temp_file)
56
+ else
57
+ # No metadata, just move temp to output
58
+ FileUtils.mv(temp_file, output_file)
59
+ end
60
+ end
61
+
62
+ # Get compression statistics
63
+ # @param original_file [String] Original file path
64
+ # @param compressed_file [String] Compressed file path
65
+ # @return [Hash] Statistics including sizes and reduction percentage
66
+ def self.compression_stats(original_file, compressed_file)
67
+ original_size = File.size(original_file)
68
+ compressed_size = File.size(compressed_file)
69
+ saved_bytes = original_size - compressed_size
70
+ reduction_percent = (saved_bytes.to_f / original_size * 100.0)
71
+
72
+ {
73
+ original_size: original_size,
74
+ compressed_size: compressed_size,
75
+ saved_bytes: saved_bytes,
76
+ reduction_percent: reduction_percent
77
+ }
78
+ end
79
+
80
+ private_class_method def self.build_compression_command(input_file, output_file)
81
+ magick_cmd = Platform.imagemagick_convert_cmd
82
+
83
+ # Use maximum PNG compression settings:
84
+ # - compression-level=9: Maximum zlib compression
85
+ # - compression-filter=5: Paeth filter (best for most images)
86
+ # - compression-strategy=1: Filtered strategy
87
+ # - quality=95: High quality
88
+ # - strip: Remove all metadata (we'll re-add it later)
89
+ [
90
+ magick_cmd,
91
+ Utils::PathHelper.quote_path(input_file),
92
+ '-strip',
93
+ '-define', 'png:compression-level=9',
94
+ '-define', 'png:compression-filter=5',
95
+ '-define', 'png:compression-strategy=1',
96
+ '-quality', '95',
97
+ Utils::PathHelper.quote_path(output_file)
98
+ ].join(' ')
99
+ end
100
+ end
101
+ end
@@ -58,6 +58,39 @@ module RubySpriter
58
58
  }
59
59
  end
60
60
 
61
+ # Find all PNG files with spritesheet metadata in a directory
62
+ # @param directory [String] Directory path to scan
63
+ # @return [Array<String>] Sorted array of spritesheet file paths
64
+ def find_spritesheets_in_directory(directory)
65
+ # Validate directory exists
66
+ unless File.directory?(directory)
67
+ raise ValidationError, "Directory not found: #{directory}"
68
+ end
69
+
70
+ # Find all PNG files
71
+ pattern = File.join(directory, '*.png')
72
+ png_files = Dir.glob(pattern)
73
+
74
+ # Filter to only files with metadata
75
+ spritesheets = png_files.select do |file|
76
+ metadata = MetadataManager.read(file)
77
+ !metadata.nil?
78
+ end
79
+
80
+ # Validate we found at least one
81
+ if spritesheets.empty?
82
+ raise ValidationError, "No PNG files with spritesheet metadata found in directory: #{directory}"
83
+ end
84
+
85
+ # Validate we have at least 2
86
+ if spritesheets.length < 2
87
+ raise ValidationError, "Found only #{spritesheets.length} spritesheet, need at least 2 for consolidation"
88
+ end
89
+
90
+ # Sort alphabetically by filename
91
+ spritesheets.sort
92
+ end
93
+
61
94
  private
62
95
 
63
96
  def validate_files!(files)
@@ -27,6 +27,8 @@ module RubySpriter
27
27
  @gimp_path = nil
28
28
  validate_numeric_options!
29
29
  validate_split_option!
30
+ validate_extract_option!
31
+ validate_add_meta_option!
30
32
  end
31
33
 
32
34
  # Run the processing workflow
@@ -74,24 +76,51 @@ module RubySpriter
74
76
  overwrite: false,
75
77
  save_frames: false,
76
78
  split: nil,
77
- override_md: false
79
+ override_md: false,
80
+ extract: nil,
81
+ add_meta: nil,
82
+ overwrite_meta: false
78
83
  }
79
84
  end
80
85
 
81
86
  def validate_options!
82
- input_modes = [options[:video], options[:image], options[:consolidate], options[:verify]].compact
87
+ input_modes = [options[:video], options[:image], options[:consolidate_mode], options[:verify], options[:batch]].compact
83
88
 
84
89
  if input_modes.empty?
85
- raise ValidationError, "Must specify --video, --image, --consolidate, or --verify"
90
+ raise ValidationError, "Must specify --video, --image, --consolidate, --verify, or --batch"
86
91
  end
87
92
 
88
93
  if input_modes.length > 1
89
94
  raise ValidationError, "Cannot use multiple input modes together. Choose one."
90
95
  end
91
96
 
97
+ validate_consolidate_options!
92
98
  validate_input_files!
93
99
  validate_numeric_options!
94
100
  validate_split_option!
101
+ validate_extract_option!
102
+ validate_add_meta_option!
103
+ end
104
+
105
+ def validate_consolidate_options!
106
+ return unless options[:consolidate_mode]
107
+
108
+ # Check for mutual exclusivity between file list and directory
109
+ if options[:consolidate] && options[:dir]
110
+ raise ValidationError, "Cannot use --dir with comma-separated file list for --consolidate. Choose one method."
111
+ end
112
+
113
+ # Require either file list or directory
114
+ unless options[:consolidate] || options[:dir]
115
+ raise ValidationError, "--consolidate requires either comma-separated files or --dir option"
116
+ end
117
+
118
+ # Validate directory if using directory mode
119
+ if options[:dir] && !options[:consolidate]
120
+ unless File.directory?(options[:dir])
121
+ raise ValidationError, "Directory not found: #{options[:dir]}"
122
+ end
123
+ end
95
124
  end
96
125
 
97
126
  def validate_input_files!
@@ -177,6 +206,119 @@ module RubySpriter
177
206
  @split_columns = columns
178
207
  end
179
208
 
209
+ def validate_extract_option!
210
+ return unless options[:extract]
211
+
212
+ # Parse extract format: comma-separated integers (allow negatives for better error messages)
213
+ unless options[:extract] =~ /^-?\d+(,-?\d+)*$/
214
+ raise ValidationError, "Invalid --extract format. Use comma-separated frame numbers (e.g., 1,2,4,5,8)"
215
+ end
216
+
217
+ # Parse frame numbers
218
+ frame_numbers = options[:extract].split(',').map(&:to_i)
219
+
220
+ # Validate minimum 2 frames
221
+ if frame_numbers.length < 2
222
+ raise ValidationError, "--extract requires at least 2 frames, got: #{frame_numbers.length}"
223
+ end
224
+
225
+ # Validate frame numbers are 1-indexed (no 0 or negative)
226
+ invalid_frames = frame_numbers.select { |n| n <= 0 }
227
+ if invalid_frames.any?
228
+ raise ValidationError, "Frame numbers must be 1-indexed (positive integers), got invalid: #{invalid_frames.join(', ')}"
229
+ end
230
+
231
+ # Check for metadata (required for extraction)
232
+ return unless options[:image] # Only validate bounds if we have an image path
233
+
234
+ image_file = options[:image]
235
+ metadata = MetadataManager.read(image_file)
236
+
237
+ unless metadata
238
+ raise ValidationError, "Image has no metadata. Cannot extract frames without knowing the grid layout. Use --add-meta first."
239
+ end
240
+
241
+ # Validate frame numbers are within bounds
242
+ total_frames = metadata[:frames]
243
+ out_of_bounds = frame_numbers.select { |n| n > total_frames }
244
+ if out_of_bounds.any?
245
+ first_oob = out_of_bounds.first
246
+ raise ValidationError, "Frame #{first_oob} is out of bounds (image only has #{total_frames} frames)"
247
+ end
248
+
249
+ # Set default columns if not specified
250
+ options[:columns] ||= 4
251
+ end
252
+
253
+ def validate_add_meta_option!
254
+ return unless options[:add_meta]
255
+
256
+ # Parse add-meta format: R:C
257
+ unless options[:add_meta] =~ /^\d+:\d+$/
258
+ raise ValidationError, "Invalid --add-meta format. Use R:C (e.g., 4:4)"
259
+ end
260
+
261
+ rows, columns = options[:add_meta].split(':').map(&:to_i)
262
+
263
+ # Validate ranges
264
+ if rows < 1 || rows > 99
265
+ raise ValidationError, "rows must be between 1 and 99, got: #{rows}"
266
+ end
267
+
268
+ if columns < 1 || columns > 99
269
+ raise ValidationError, "columns must be between 1 and 99, got: #{columns}"
270
+ end
271
+
272
+ # Validate total frames < 1000
273
+ total_frames = rows * columns
274
+ if total_frames >= 1000
275
+ raise ValidationError, "Total frames (#{total_frames}) must be less than 1000"
276
+ end
277
+
278
+ # Check if we need to validate against image file
279
+ return unless options[:image]
280
+
281
+ image_file = options[:image]
282
+ metadata = MetadataManager.read(image_file)
283
+
284
+ # Check for existing metadata
285
+ if metadata && !options[:overwrite_meta]
286
+ raise ValidationError, "Image already has spritesheet metadata. Use --overwrite-meta to replace it."
287
+ end
288
+
289
+ # Validate image dimensions divide evenly by grid
290
+ dimensions = get_image_dimensions(image_file)
291
+ tile_width = dimensions[:width] / columns.to_f
292
+ tile_height = dimensions[:height] / rows.to_f
293
+
294
+ unless tile_width == tile_width.to_i && tile_height == tile_height.to_i
295
+ raise ValidationError, "Image dimensions (#{dimensions[:width]}x#{dimensions[:height]}) must divide evenly by grid (#{rows}x#{columns}). Expected frame size: #{tile_width}x#{tile_height}"
296
+ end
297
+
298
+ # Validate custom frame count doesn't exceed grid size
299
+ if options[:frame_count] && options[:frame_count] > total_frames
300
+ raise ValidationError, "Frame count (#{options[:frame_count]}) exceeds grid size (#{total_frames})"
301
+ end
302
+ end
303
+
304
+ def get_image_dimensions(image_file)
305
+ cmd = [
306
+ 'magick',
307
+ 'identify',
308
+ '-format', '%wx%h',
309
+ Utils::PathHelper.quote_path(image_file)
310
+ ].join(' ')
311
+
312
+ stdout, stderr, status = Open3.capture3(cmd)
313
+
314
+ unless status.success?
315
+ raise ProcessingError, "Could not get image dimensions: #{stderr}"
316
+ end
317
+
318
+ width, height = stdout.strip.split('x').map(&:to_i)
319
+ { width: width, height: height }
320
+ end
321
+
180
322
  def check_dependencies!
181
323
  checker = DependencyChecker.new(verbose: options[:debug])
182
324
  results = checker.check_all
@@ -231,7 +373,9 @@ module RubySpriter
231
373
  return { mode: :verify, file: options[:verify] }
232
374
  end
233
375
 
234
- if options[:consolidate]
376
+ if options[:batch]
377
+ return execute_batch_workflow
378
+ elsif options[:consolidate_mode]
235
379
  return execute_consolidate_workflow
236
380
  elsif options[:image]
237
381
  return execute_image_workflow
@@ -277,7 +421,12 @@ module RubySpriter
277
421
  # Step 5: Clean up intermediate files
278
422
  cleanup_intermediate_files(intermediate_files)
279
423
 
280
- # Step 6: Extract individual frames if requested
424
+ # Step 6: Apply max compression if requested
425
+ if options[:max_compress]
426
+ working_file = apply_max_compression(working_file)
427
+ end
428
+
429
+ # Step 7: Extract individual frames if requested
281
430
  if options[:save_frames]
282
431
  split_frames_from_spritesheet(working_file, result[:columns], result[:rows], result[:frames])
283
432
  end
@@ -292,6 +441,16 @@ module RubySpriter
292
441
  working_file = options[:image]
293
442
  intermediate_files = []
294
443
 
444
+ # Handle metadata addition workflow first
445
+ if options[:add_meta]
446
+ return execute_add_meta_workflow
447
+ end
448
+
449
+ # Handle frame extraction workflow
450
+ if options[:extract]
451
+ return execute_extract_workflow
452
+ end
453
+
295
454
  # Apply GIMP processing if requested (GimpProcessor handles uniqueness)
296
455
  if needs_gimp?
297
456
  initial_file = working_file
@@ -317,6 +476,11 @@ module RubySpriter
317
476
  # Clean up intermediate files
318
477
  cleanup_intermediate_files(intermediate_files)
319
478
 
479
+ # Apply max compression if requested
480
+ if options[:max_compress]
481
+ working_file = apply_max_compression(working_file)
482
+ end
483
+
320
484
  # Determine if we should split the image into frames
321
485
  should_split = options[:save_frames] || options[:split]
322
486
 
@@ -338,13 +502,192 @@ module RubySpriter
338
502
  }
339
503
  end
340
504
 
505
+ def execute_extract_workflow
506
+ input_file = options[:image]
507
+ metadata = MetadataManager.read(input_file)
508
+ frame_numbers = options[:extract].split(',').map(&:to_i)
509
+ columns = options[:columns]
510
+
511
+ Utils::OutputFormatter.header("Frame Extraction")
512
+ Utils::OutputFormatter.indent("Input: #{input_file}")
513
+ Utils::OutputFormatter.indent("Frames to extract: #{frame_numbers.join(', ')}")
514
+ Utils::OutputFormatter.indent("Output columns: #{columns}")
515
+
516
+ # Step 1: Extract all frames to temp directory
517
+ temp_frames_dir = File.join(options[:temp_dir], 'extracted_frames')
518
+ splitter = Utils::SpritesheetSplitter.new
519
+ splitter.split_into_frames(input_file, temp_frames_dir, metadata[:columns], metadata[:rows], metadata[:frames])
520
+
521
+ # Step 2: Keep only requested frames, delete the rest
522
+ spritesheet_basename = File.basename(input_file, '.*')
523
+ all_frame_files = Dir.glob(File.join(temp_frames_dir, "FR*_#{spritesheet_basename}.png")).sort
524
+ requested_frame_files = frame_numbers.map do |frame_num|
525
+ # Frame files are named FR001, FR002, etc. (1-indexed)
526
+ File.join(temp_frames_dir, "FR#{format('%03d', frame_num)}_#{spritesheet_basename}.png")
527
+ end
528
+
529
+ # Delete unwanted frames
530
+ (all_frame_files - requested_frame_files).each { |f| FileUtils.rm_f(f) }
531
+
532
+ Utils::OutputFormatter.indent("Kept #{requested_frame_files.length} frames, deleted #{all_frame_files.length - requested_frame_files.length} frames")
533
+
534
+ # Step 3: Reassemble into new spritesheet
535
+ Utils::OutputFormatter.header("Reassembling Spritesheet")
536
+ reassembled_file = File.join(options[:temp_dir], "reassembled_#{spritesheet_basename}.png")
537
+ reassemble_frames(requested_frame_files, reassembled_file, columns)
538
+
539
+ working_file = reassembled_file
540
+ intermediate_files = []
541
+
542
+ # Step 4: Apply GIMP processing if requested
543
+ if needs_gimp?
544
+ initial_file = working_file
545
+ working_file = process_with_gimp(working_file)
546
+
547
+ if working_file != initial_file
548
+ intermediate_files = collect_intermediate_files(initial_file, working_file)
549
+ end
550
+ end
551
+
552
+ # Step 5: Determine final output filename
553
+ if options[:output]
554
+ final_output = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
555
+ else
556
+ # Auto-generate output filename with _extracted suffix
557
+ base = File.basename(input_file, '.*')
558
+ ext = File.extname(input_file)
559
+ desired_output = File.join(File.dirname(input_file), "#{base}_extracted#{ext}")
560
+ final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
561
+ end
562
+
563
+ # Step 6: Copy to final output
564
+ FileUtils.cp(working_file, final_output)
565
+ working_file = final_output
566
+
567
+ # Step 7: Clean up intermediate files
568
+ cleanup_intermediate_files(intermediate_files)
569
+
570
+ # Step 8: Apply max compression if requested
571
+ if options[:max_compress]
572
+ working_file = apply_max_compression(working_file)
573
+ end
574
+
575
+ # Step 9: Optionally save individual frames
576
+ if options[:save_frames]
577
+ frames_output_dir = File.join(File.dirname(working_file), "#{File.basename(working_file, '.*')}_frames")
578
+ FileUtils.mkdir_p(frames_output_dir)
579
+ requested_frame_files.each_with_index do |frame_file, idx|
580
+ frame_num = frame_numbers[idx]
581
+ dest = File.join(frames_output_dir, "FR#{format('%03d', frame_num)}_#{spritesheet_basename}.png")
582
+ FileUtils.cp(frame_file, dest)
583
+ end
584
+ Utils::OutputFormatter.indent("Saved #{requested_frame_files.length} frames to: #{frames_output_dir}")
585
+ end
586
+
587
+ Utils::OutputFormatter.header("SUCCESS!")
588
+ Utils::OutputFormatter.success("Extracted spritesheet: #{working_file}")
589
+
590
+ {
591
+ mode: :extract,
592
+ input_file: input_file,
593
+ output_file: working_file,
594
+ frames_extracted: frame_numbers.length,
595
+ columns: columns
596
+ }
597
+ end
598
+
599
+ def execute_add_meta_workflow
600
+ input_file = options[:image]
601
+ rows, columns = options[:add_meta].split(':').map(&:to_i)
602
+
603
+ # Determine frame count
604
+ frame_count = if options[:frame_count]
605
+ options[:frame_count]
606
+ else
607
+ rows * columns
608
+ end
609
+
610
+ Utils::OutputFormatter.header("Adding Metadata")
611
+ Utils::OutputFormatter.indent("Input: #{input_file}")
612
+ Utils::OutputFormatter.indent("Grid: #{rows}×#{columns} (#{frame_count} frames)")
613
+
614
+ # Determine output file
615
+ if options[:output]
616
+ # User specified explicit output
617
+ output_file = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
618
+
619
+ # Copy input to output
620
+ FileUtils.cp(input_file, output_file)
621
+ Utils::OutputFormatter.indent("Copied to: #{output_file}")
622
+ else
623
+ # In-place modification
624
+ if options[:overwrite]
625
+ output_file = input_file
626
+ Utils::OutputFormatter.indent("Modifying in-place (--overwrite specified)")
627
+ else
628
+ # Create unique filename
629
+ output_file = Utils::FileHelper.ensure_unique_output(input_file, overwrite: false)
630
+ FileUtils.cp(input_file, output_file)
631
+ Utils::OutputFormatter.indent("Created: #{output_file}")
632
+ end
633
+ end
634
+
635
+ # Embed metadata
636
+ MetadataManager.embed(output_file, columns, rows, frame_count)
637
+ Utils::OutputFormatter.indent("📝 Metadata embedded: #{columns}×#{rows} grid (#{frame_count} frames)")
638
+
639
+ Utils::OutputFormatter.header("SUCCESS!")
640
+ Utils::OutputFormatter.success("Metadata added to: #{output_file}")
641
+
642
+ {
643
+ mode: :add_meta,
644
+ input_file: input_file,
645
+ output_file: output_file,
646
+ columns: columns,
647
+ rows: rows,
648
+ frames: frame_count
649
+ }
650
+ end
651
+
341
652
  def execute_consolidate_workflow
342
653
  consolidator = Consolidator.new(options)
343
654
 
344
- desired_output = options[:output] || generate_consolidated_filename
655
+ # Determine file list: either from command line or from directory
656
+ files_to_consolidate = if options[:dir] && !options[:consolidate]
657
+ # Directory-based consolidation
658
+ consolidator.find_spritesheets_in_directory(options[:dir])
659
+ else
660
+ # File list consolidation
661
+ options[:consolidate]
662
+ end
663
+
664
+ # Determine output filename and directory
665
+ if options[:dir] && !options[:consolidate]
666
+ # Directory mode: output to dir or outputdir
667
+ output_dir = options[:outputdir] || options[:dir]
668
+ desired_output = if options[:output]
669
+ File.join(output_dir, File.basename(options[:output]))
670
+ else
671
+ File.join(output_dir, generate_consolidated_filename)
672
+ end
673
+ else
674
+ # File list mode: use current directory behavior
675
+ if options[:outputdir]
676
+ desired_output = File.join(options[:outputdir], options[:output] || generate_consolidated_filename)
677
+ else
678
+ desired_output = options[:output] || generate_consolidated_filename
679
+ end
680
+ end
681
+
345
682
  final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
346
683
 
347
- result = consolidator.consolidate(options[:consolidate], final_output)
684
+ result = consolidator.consolidate(files_to_consolidate, final_output)
685
+
686
+ # Apply max compression if requested
687
+ if options[:max_compress]
688
+ final_output = apply_max_compression(result[:output_file])
689
+ result[:output_file] = final_output
690
+ end
348
691
 
349
692
  Utils::OutputFormatter.header("SUCCESS!")
350
693
  Utils::OutputFormatter.success("Final output: #{result[:output_file]}")
@@ -352,6 +695,13 @@ module RubySpriter
352
695
  result.merge(mode: :consolidate)
353
696
  end
354
697
 
698
+ def execute_batch_workflow
699
+ batch_processor = BatchProcessor.new(options)
700
+ result = batch_processor.process
701
+
702
+ result.merge(mode: :batch)
703
+ end
704
+
355
705
  def process_with_gimp(input_file)
356
706
  gimp_processor = GimpProcessor.new(@gimp_path, options)
357
707
  gimp_processor.process(input_file)
@@ -371,6 +721,38 @@ module RubySpriter
371
721
  splitter.split_into_frames(spritesheet_file, frames_dir, columns, rows, frames)
372
722
  end
373
723
 
724
+ def reassemble_frames(frame_files, output_file, columns)
725
+ # Calculate rows needed for the specified columns
726
+ total_frames = frame_files.length
727
+ rows = (total_frames.to_f / columns).ceil
728
+
729
+ Utils::OutputFormatter.indent("Layout: #{columns}×#{rows} grid (#{total_frames} frames)")
730
+
731
+ # Use ImageMagick montage to create spritesheet
732
+ # Montage arranges images in a grid
733
+ cmd = [
734
+ 'magick',
735
+ 'montage',
736
+ frame_files.map { |f| Utils::PathHelper.quote_path(f) }.join(' '),
737
+ '-tile', "#{columns}x#{rows}",
738
+ '-geometry', '+0+0', # No spacing between tiles
739
+ '-background', 'none', # Transparent background
740
+ Utils::PathHelper.quote_path(output_file)
741
+ ].join(' ')
742
+
743
+ stdout, stderr, status = Open3.capture3(cmd)
744
+
745
+ unless status.success?
746
+ raise ProcessingError, "Failed to reassemble frames: #{stderr}"
747
+ end
748
+
749
+ # Embed metadata in the reassembled spritesheet
750
+ MetadataManager.embed(output_file, columns, rows, total_frames)
751
+
752
+ Utils::OutputFormatter.indent("✅ Reassembled into #{columns}×#{rows} spritesheet")
753
+ Utils::OutputFormatter.indent("📝 Metadata embedded: #{columns}×#{rows} grid (#{total_frames} frames)")
754
+ end
755
+
374
756
  def collect_intermediate_files(initial_file, final_file)
375
757
  # Find all files that were created during GIMP processing
376
758
  # Pattern: initial_file + suffixes like -nobg-fuzzy, -scaled-40pct, etc.
@@ -477,5 +859,28 @@ module RubySpriter
477
859
  raise ValidationError, "Image height (#{height}) not evenly divisible by #{rows} rows"
478
860
  end
479
861
  end
862
+
863
+ def apply_max_compression(file)
864
+ Utils::OutputFormatter.note("Applying maximum compression...")
865
+
866
+ original_size = File.size(file)
867
+ temp_file = file.gsub('.png', '_compressed_temp.png')
868
+
869
+ CompressionManager.compress_with_metadata(file, temp_file, debug: options[:debug])
870
+
871
+ # Show compression stats
872
+ stats = CompressionManager.compression_stats(file, temp_file)
873
+
874
+ if options[:debug] || stats[:saved_bytes] > 0
875
+ Utils::OutputFormatter.indent("Original: #{Utils::FileHelper.format_size(stats[:original_size])}")
876
+ Utils::OutputFormatter.indent("Compressed: #{Utils::FileHelper.format_size(stats[:compressed_size])}")
877
+ Utils::OutputFormatter.indent("Saved: #{Utils::FileHelper.format_size(stats[:saved_bytes])} (#{stats[:reduction_percent].round(1)}% reduction)")
878
+ end
879
+
880
+ # Replace original with compressed
881
+ FileUtils.mv(temp_file, file)
882
+
883
+ file
884
+ end
480
885
  end
481
886
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubySpriter
4
- VERSION = '0.6.6'
5
- VERSION_DATE = '2025-10-23'
4
+ VERSION = '0.6.7'
5
+ VERSION_DATE = '2025-10-24'
6
6
  METADATA_VERSION = '0.6'
7
7
  end
data/lib/ruby_spriter.rb CHANGED
@@ -26,6 +26,8 @@ require_relative 'ruby_spriter/metadata_manager'
26
26
  require_relative 'ruby_spriter/video_processor'
27
27
  require_relative 'ruby_spriter/gimp_processor'
28
28
  require_relative 'ruby_spriter/consolidator'
29
+ require_relative 'ruby_spriter/compression_manager'
30
+ require_relative 'ruby_spriter/batch_processor'
29
31
 
30
32
  # Load orchestration
31
33
  require_relative 'ruby_spriter/processor'