ruby_spriter 0.6.6 → 0.6.7.1

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.
@@ -25,8 +25,11 @@ module RubySpriter
25
25
  def initialize(options = {})
26
26
  @options = default_options.merge(options)
27
27
  @gimp_path = nil
28
+ @gimp_version = nil
28
29
  validate_numeric_options!
29
30
  validate_split_option!
31
+ validate_extract_option!
32
+ validate_add_meta_option!
30
33
  end
31
34
 
32
35
  # Run the processing workflow
@@ -74,24 +77,51 @@ module RubySpriter
74
77
  overwrite: false,
75
78
  save_frames: false,
76
79
  split: nil,
77
- override_md: false
80
+ override_md: false,
81
+ extract: nil,
82
+ add_meta: nil,
83
+ overwrite_meta: false
78
84
  }
79
85
  end
80
86
 
81
87
  def validate_options!
82
- input_modes = [options[:video], options[:image], options[:consolidate], options[:verify]].compact
88
+ input_modes = [options[:video], options[:image], options[:consolidate_mode], options[:verify], options[:batch]].compact
83
89
 
84
90
  if input_modes.empty?
85
- raise ValidationError, "Must specify --video, --image, --consolidate, or --verify"
91
+ raise ValidationError, "Must specify --video, --image, --consolidate, --verify, or --batch"
86
92
  end
87
93
 
88
94
  if input_modes.length > 1
89
95
  raise ValidationError, "Cannot use multiple input modes together. Choose one."
90
96
  end
91
97
 
98
+ validate_consolidate_options!
92
99
  validate_input_files!
93
100
  validate_numeric_options!
94
101
  validate_split_option!
102
+ validate_extract_option!
103
+ validate_add_meta_option!
104
+ end
105
+
106
+ def validate_consolidate_options!
107
+ return unless options[:consolidate_mode]
108
+
109
+ # Check for mutual exclusivity between file list and directory
110
+ if options[:consolidate] && options[:dir]
111
+ raise ValidationError, "Cannot use --dir with comma-separated file list for --consolidate. Choose one method."
112
+ end
113
+
114
+ # Require either file list or directory
115
+ unless options[:consolidate] || options[:dir]
116
+ raise ValidationError, "--consolidate requires either comma-separated files or --dir option"
117
+ end
118
+
119
+ # Validate directory if using directory mode
120
+ if options[:dir] && !options[:consolidate]
121
+ unless File.directory?(options[:dir])
122
+ raise ValidationError, "Directory not found: #{options[:dir]}"
123
+ end
124
+ end
95
125
  end
96
126
 
97
127
  def validate_input_files!
@@ -177,6 +207,119 @@ module RubySpriter
177
207
  @split_columns = columns
178
208
  end
179
209
 
210
+ def validate_extract_option!
211
+ return unless options[:extract]
212
+
213
+ # Parse extract format: comma-separated integers (allow negatives for better error messages)
214
+ unless options[:extract] =~ /^-?\d+(,-?\d+)*$/
215
+ raise ValidationError, "Invalid --extract format. Use comma-separated frame numbers (e.g., 1,2,4,5,8)"
216
+ end
217
+
218
+ # Parse frame numbers
219
+ frame_numbers = options[:extract].split(',').map(&:to_i)
220
+
221
+ # Validate minimum 2 frames
222
+ if frame_numbers.length < 2
223
+ raise ValidationError, "--extract requires at least 2 frames, got: #{frame_numbers.length}"
224
+ end
225
+
226
+ # Validate frame numbers are 1-indexed (no 0 or negative)
227
+ invalid_frames = frame_numbers.select { |n| n <= 0 }
228
+ if invalid_frames.any?
229
+ raise ValidationError, "Frame numbers must be 1-indexed (positive integers), got invalid: #{invalid_frames.join(', ')}"
230
+ end
231
+
232
+ # Check for metadata (required for extraction)
233
+ return unless options[:image] # Only validate bounds if we have an image path
234
+
235
+ image_file = options[:image]
236
+ metadata = MetadataManager.read(image_file)
237
+
238
+ unless metadata
239
+ raise ValidationError, "Image has no metadata. Cannot extract frames without knowing the grid layout. Use --add-meta first."
240
+ end
241
+
242
+ # Validate frame numbers are within bounds
243
+ total_frames = metadata[:frames]
244
+ out_of_bounds = frame_numbers.select { |n| n > total_frames }
245
+ if out_of_bounds.any?
246
+ first_oob = out_of_bounds.first
247
+ raise ValidationError, "Frame #{first_oob} is out of bounds (image only has #{total_frames} frames)"
248
+ end
249
+
250
+ # Set default columns if not specified
251
+ options[:columns] ||= 4
252
+ end
253
+
254
+ def validate_add_meta_option!
255
+ return unless options[:add_meta]
256
+
257
+ # Parse add-meta format: R:C
258
+ unless options[:add_meta] =~ /^\d+:\d+$/
259
+ raise ValidationError, "Invalid --add-meta format. Use R:C (e.g., 4:4)"
260
+ end
261
+
262
+ rows, columns = options[:add_meta].split(':').map(&:to_i)
263
+
264
+ # Validate ranges
265
+ if rows < 1 || rows > 99
266
+ raise ValidationError, "rows must be between 1 and 99, got: #{rows}"
267
+ end
268
+
269
+ if columns < 1 || columns > 99
270
+ raise ValidationError, "columns must be between 1 and 99, got: #{columns}"
271
+ end
272
+
273
+ # Validate total frames < 1000
274
+ total_frames = rows * columns
275
+ if total_frames >= 1000
276
+ raise ValidationError, "Total frames (#{total_frames}) must be less than 1000"
277
+ end
278
+
279
+ # Check if we need to validate against image file
280
+ return unless options[:image]
281
+
282
+ image_file = options[:image]
283
+ metadata = MetadataManager.read(image_file)
284
+
285
+ # Check for existing metadata
286
+ if metadata && !options[:overwrite_meta]
287
+ raise ValidationError, "Image already has spritesheet metadata. Use --overwrite-meta to replace it."
288
+ end
289
+
290
+ # Validate image dimensions divide evenly by grid
291
+ dimensions = get_image_dimensions(image_file)
292
+ tile_width = dimensions[:width] / columns.to_f
293
+ tile_height = dimensions[:height] / rows.to_f
294
+
295
+ unless tile_width == tile_width.to_i && tile_height == tile_height.to_i
296
+ 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}"
297
+ end
298
+
299
+ # Validate custom frame count doesn't exceed grid size
300
+ if options[:frame_count] && options[:frame_count] > total_frames
301
+ raise ValidationError, "Frame count (#{options[:frame_count]}) exceeds grid size (#{total_frames})"
302
+ end
303
+ end
304
+
305
+ def get_image_dimensions(image_file)
306
+ cmd = [
307
+ 'magick',
308
+ 'identify',
309
+ '-format', '%wx%h',
310
+ Utils::PathHelper.quote_path(image_file)
311
+ ].join(' ')
312
+
313
+ stdout, stderr, status = Open3.capture3(cmd)
314
+
315
+ unless status.success?
316
+ raise ProcessingError, "Could not get image dimensions: #{stderr}"
317
+ end
318
+
319
+ width, height = stdout.strip.split('x').map(&:to_i)
320
+ { width: width, height: height }
321
+ end
322
+
180
323
  def check_dependencies!
181
324
  checker = DependencyChecker.new(verbose: options[:debug])
182
325
  results = checker.check_all
@@ -198,7 +341,10 @@ module RubySpriter
198
341
  raise DependencyError, "Missing required dependencies: #{missing.join(', ')}"
199
342
  end
200
343
 
201
- @gimp_path = checker.gimp_path if results[:gimp][:available]
344
+ if results[:gimp][:available]
345
+ @gimp_path = checker.gimp_path
346
+ @gimp_version = checker.gimp_version
347
+ end
202
348
 
203
349
  if options[:debug]
204
350
  checker.print_report
@@ -231,7 +377,9 @@ module RubySpriter
231
377
  return { mode: :verify, file: options[:verify] }
232
378
  end
233
379
 
234
- if options[:consolidate]
380
+ if options[:batch]
381
+ return execute_batch_workflow
382
+ elsif options[:consolidate_mode]
235
383
  return execute_consolidate_workflow
236
384
  elsif options[:image]
237
385
  return execute_image_workflow
@@ -277,7 +425,12 @@ module RubySpriter
277
425
  # Step 5: Clean up intermediate files
278
426
  cleanup_intermediate_files(intermediate_files)
279
427
 
280
- # Step 6: Extract individual frames if requested
428
+ # Step 6: Apply max compression if requested
429
+ if options[:max_compress]
430
+ working_file = apply_max_compression(working_file)
431
+ end
432
+
433
+ # Step 7: Extract individual frames if requested
281
434
  if options[:save_frames]
282
435
  split_frames_from_spritesheet(working_file, result[:columns], result[:rows], result[:frames])
283
436
  end
@@ -292,6 +445,16 @@ module RubySpriter
292
445
  working_file = options[:image]
293
446
  intermediate_files = []
294
447
 
448
+ # Handle metadata addition workflow first
449
+ if options[:add_meta]
450
+ return execute_add_meta_workflow
451
+ end
452
+
453
+ # Handle frame extraction workflow
454
+ if options[:extract]
455
+ return execute_extract_workflow
456
+ end
457
+
295
458
  # Apply GIMP processing if requested (GimpProcessor handles uniqueness)
296
459
  if needs_gimp?
297
460
  initial_file = working_file
@@ -317,6 +480,11 @@ module RubySpriter
317
480
  # Clean up intermediate files
318
481
  cleanup_intermediate_files(intermediate_files)
319
482
 
483
+ # Apply max compression if requested
484
+ if options[:max_compress]
485
+ working_file = apply_max_compression(working_file)
486
+ end
487
+
320
488
  # Determine if we should split the image into frames
321
489
  should_split = options[:save_frames] || options[:split]
322
490
 
@@ -338,13 +506,192 @@ module RubySpriter
338
506
  }
339
507
  end
340
508
 
509
+ def execute_extract_workflow
510
+ input_file = options[:image]
511
+ metadata = MetadataManager.read(input_file)
512
+ frame_numbers = options[:extract].split(',').map(&:to_i)
513
+ columns = options[:columns]
514
+
515
+ Utils::OutputFormatter.header("Frame Extraction")
516
+ Utils::OutputFormatter.indent("Input: #{input_file}")
517
+ Utils::OutputFormatter.indent("Frames to extract: #{frame_numbers.join(', ')}")
518
+ Utils::OutputFormatter.indent("Output columns: #{columns}")
519
+
520
+ # Step 1: Extract all frames to temp directory
521
+ temp_frames_dir = File.join(options[:temp_dir], 'extracted_frames')
522
+ splitter = Utils::SpritesheetSplitter.new
523
+ splitter.split_into_frames(input_file, temp_frames_dir, metadata[:columns], metadata[:rows], metadata[:frames])
524
+
525
+ # Step 2: Keep only requested frames, delete the rest
526
+ spritesheet_basename = File.basename(input_file, '.*')
527
+ all_frame_files = Dir.glob(File.join(temp_frames_dir, "FR*_#{spritesheet_basename}.png")).sort
528
+ requested_frame_files = frame_numbers.map do |frame_num|
529
+ # Frame files are named FR001, FR002, etc. (1-indexed)
530
+ File.join(temp_frames_dir, "FR#{format('%03d', frame_num)}_#{spritesheet_basename}.png")
531
+ end
532
+
533
+ # Delete unwanted frames
534
+ (all_frame_files - requested_frame_files).each { |f| FileUtils.rm_f(f) }
535
+
536
+ Utils::OutputFormatter.indent("Kept #{requested_frame_files.length} frames, deleted #{all_frame_files.length - requested_frame_files.length} frames")
537
+
538
+ # Step 3: Reassemble into new spritesheet
539
+ Utils::OutputFormatter.header("Reassembling Spritesheet")
540
+ reassembled_file = File.join(options[:temp_dir], "reassembled_#{spritesheet_basename}.png")
541
+ reassemble_frames(requested_frame_files, reassembled_file, columns)
542
+
543
+ working_file = reassembled_file
544
+ intermediate_files = []
545
+
546
+ # Step 4: Apply GIMP processing if requested
547
+ if needs_gimp?
548
+ initial_file = working_file
549
+ working_file = process_with_gimp(working_file)
550
+
551
+ if working_file != initial_file
552
+ intermediate_files = collect_intermediate_files(initial_file, working_file)
553
+ end
554
+ end
555
+
556
+ # Step 5: Determine final output filename
557
+ if options[:output]
558
+ final_output = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
559
+ else
560
+ # Auto-generate output filename with _extracted suffix
561
+ base = File.basename(input_file, '.*')
562
+ ext = File.extname(input_file)
563
+ desired_output = File.join(File.dirname(input_file), "#{base}_extracted#{ext}")
564
+ final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
565
+ end
566
+
567
+ # Step 6: Copy to final output
568
+ FileUtils.cp(working_file, final_output)
569
+ working_file = final_output
570
+
571
+ # Step 7: Clean up intermediate files
572
+ cleanup_intermediate_files(intermediate_files)
573
+
574
+ # Step 8: Apply max compression if requested
575
+ if options[:max_compress]
576
+ working_file = apply_max_compression(working_file)
577
+ end
578
+
579
+ # Step 9: Optionally save individual frames
580
+ if options[:save_frames]
581
+ frames_output_dir = File.join(File.dirname(working_file), "#{File.basename(working_file, '.*')}_frames")
582
+ FileUtils.mkdir_p(frames_output_dir)
583
+ requested_frame_files.each_with_index do |frame_file, idx|
584
+ frame_num = frame_numbers[idx]
585
+ dest = File.join(frames_output_dir, "FR#{format('%03d', frame_num)}_#{spritesheet_basename}.png")
586
+ FileUtils.cp(frame_file, dest)
587
+ end
588
+ Utils::OutputFormatter.indent("Saved #{requested_frame_files.length} frames to: #{frames_output_dir}")
589
+ end
590
+
591
+ Utils::OutputFormatter.header("SUCCESS!")
592
+ Utils::OutputFormatter.success("Extracted spritesheet: #{working_file}")
593
+
594
+ {
595
+ mode: :extract,
596
+ input_file: input_file,
597
+ output_file: working_file,
598
+ frames_extracted: frame_numbers.length,
599
+ columns: columns
600
+ }
601
+ end
602
+
603
+ def execute_add_meta_workflow
604
+ input_file = options[:image]
605
+ rows, columns = options[:add_meta].split(':').map(&:to_i)
606
+
607
+ # Determine frame count
608
+ frame_count = if options[:frame_count]
609
+ options[:frame_count]
610
+ else
611
+ rows * columns
612
+ end
613
+
614
+ Utils::OutputFormatter.header("Adding Metadata")
615
+ Utils::OutputFormatter.indent("Input: #{input_file}")
616
+ Utils::OutputFormatter.indent("Grid: #{rows}×#{columns} (#{frame_count} frames)")
617
+
618
+ # Determine output file
619
+ if options[:output]
620
+ # User specified explicit output
621
+ output_file = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
622
+
623
+ # Copy input to output
624
+ FileUtils.cp(input_file, output_file)
625
+ Utils::OutputFormatter.indent("Copied to: #{output_file}")
626
+ else
627
+ # In-place modification
628
+ if options[:overwrite]
629
+ output_file = input_file
630
+ Utils::OutputFormatter.indent("Modifying in-place (--overwrite specified)")
631
+ else
632
+ # Create unique filename
633
+ output_file = Utils::FileHelper.ensure_unique_output(input_file, overwrite: false)
634
+ FileUtils.cp(input_file, output_file)
635
+ Utils::OutputFormatter.indent("Created: #{output_file}")
636
+ end
637
+ end
638
+
639
+ # Embed metadata
640
+ MetadataManager.embed(output_file, columns, rows, frame_count)
641
+ Utils::OutputFormatter.indent("📝 Metadata embedded: #{columns}×#{rows} grid (#{frame_count} frames)")
642
+
643
+ Utils::OutputFormatter.header("SUCCESS!")
644
+ Utils::OutputFormatter.success("Metadata added to: #{output_file}")
645
+
646
+ {
647
+ mode: :add_meta,
648
+ input_file: input_file,
649
+ output_file: output_file,
650
+ columns: columns,
651
+ rows: rows,
652
+ frames: frame_count
653
+ }
654
+ end
655
+
341
656
  def execute_consolidate_workflow
342
657
  consolidator = Consolidator.new(options)
343
658
 
344
- desired_output = options[:output] || generate_consolidated_filename
659
+ # Determine file list: either from command line or from directory
660
+ files_to_consolidate = if options[:dir] && !options[:consolidate]
661
+ # Directory-based consolidation
662
+ consolidator.find_spritesheets_in_directory(options[:dir])
663
+ else
664
+ # File list consolidation
665
+ options[:consolidate]
666
+ end
667
+
668
+ # Determine output filename and directory
669
+ if options[:dir] && !options[:consolidate]
670
+ # Directory mode: output to dir or outputdir
671
+ output_dir = options[:outputdir] || options[:dir]
672
+ desired_output = if options[:output]
673
+ File.join(output_dir, File.basename(options[:output]))
674
+ else
675
+ File.join(output_dir, generate_consolidated_filename)
676
+ end
677
+ else
678
+ # File list mode: use current directory behavior
679
+ if options[:outputdir]
680
+ desired_output = File.join(options[:outputdir], options[:output] || generate_consolidated_filename)
681
+ else
682
+ desired_output = options[:output] || generate_consolidated_filename
683
+ end
684
+ end
685
+
345
686
  final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
346
687
 
347
- result = consolidator.consolidate(options[:consolidate], final_output)
688
+ result = consolidator.consolidate(files_to_consolidate, final_output)
689
+
690
+ # Apply max compression if requested
691
+ if options[:max_compress]
692
+ final_output = apply_max_compression(result[:output_file])
693
+ result[:output_file] = final_output
694
+ end
348
695
 
349
696
  Utils::OutputFormatter.header("SUCCESS!")
350
697
  Utils::OutputFormatter.success("Final output: #{result[:output_file]}")
@@ -352,8 +699,16 @@ module RubySpriter
352
699
  result.merge(mode: :consolidate)
353
700
  end
354
701
 
702
+ def execute_batch_workflow
703
+ batch_processor = BatchProcessor.new(options)
704
+ result = batch_processor.process
705
+
706
+ result.merge(mode: :batch)
707
+ end
708
+
355
709
  def process_with_gimp(input_file)
356
- gimp_processor = GimpProcessor.new(@gimp_path, options)
710
+ gimp_options = options.merge(gimp_version: @gimp_version)
711
+ gimp_processor = GimpProcessor.new(@gimp_path, gimp_options)
357
712
  gimp_processor.process(input_file)
358
713
  end
359
714
 
@@ -371,6 +726,38 @@ module RubySpriter
371
726
  splitter.split_into_frames(spritesheet_file, frames_dir, columns, rows, frames)
372
727
  end
373
728
 
729
+ def reassemble_frames(frame_files, output_file, columns)
730
+ # Calculate rows needed for the specified columns
731
+ total_frames = frame_files.length
732
+ rows = (total_frames.to_f / columns).ceil
733
+
734
+ Utils::OutputFormatter.indent("Layout: #{columns}×#{rows} grid (#{total_frames} frames)")
735
+
736
+ # Use ImageMagick montage to create spritesheet
737
+ # Montage arranges images in a grid
738
+ cmd = [
739
+ 'magick',
740
+ 'montage',
741
+ frame_files.map { |f| Utils::PathHelper.quote_path(f) }.join(' '),
742
+ '-tile', "#{columns}x#{rows}",
743
+ '-geometry', '+0+0', # No spacing between tiles
744
+ '-background', 'none', # Transparent background
745
+ Utils::PathHelper.quote_path(output_file)
746
+ ].join(' ')
747
+
748
+ stdout, stderr, status = Open3.capture3(cmd)
749
+
750
+ unless status.success?
751
+ raise ProcessingError, "Failed to reassemble frames: #{stderr}"
752
+ end
753
+
754
+ # Embed metadata in the reassembled spritesheet
755
+ MetadataManager.embed(output_file, columns, rows, total_frames)
756
+
757
+ Utils::OutputFormatter.indent("✅ Reassembled into #{columns}×#{rows} spritesheet")
758
+ Utils::OutputFormatter.indent("📝 Metadata embedded: #{columns}×#{rows} grid (#{total_frames} frames)")
759
+ end
760
+
374
761
  def collect_intermediate_files(initial_file, final_file)
375
762
  # Find all files that were created during GIMP processing
376
763
  # Pattern: initial_file + suffixes like -nobg-fuzzy, -scaled-40pct, etc.
@@ -477,5 +864,28 @@ module RubySpriter
477
864
  raise ValidationError, "Image height (#{height}) not evenly divisible by #{rows} rows"
478
865
  end
479
866
  end
867
+
868
+ def apply_max_compression(file)
869
+ Utils::OutputFormatter.note("Applying maximum compression...")
870
+
871
+ original_size = File.size(file)
872
+ temp_file = file.gsub('.png', '_compressed_temp.png')
873
+
874
+ CompressionManager.compress_with_metadata(file, temp_file, debug: options[:debug])
875
+
876
+ # Show compression stats
877
+ stats = CompressionManager.compression_stats(file, temp_file)
878
+
879
+ if options[:debug] || stats[:saved_bytes] > 0
880
+ Utils::OutputFormatter.indent("Original: #{Utils::FileHelper.format_size(stats[:original_size])}")
881
+ Utils::OutputFormatter.indent("Compressed: #{Utils::FileHelper.format_size(stats[:compressed_size])}")
882
+ Utils::OutputFormatter.indent("Saved: #{Utils::FileHelper.format_size(stats[:saved_bytes])} (#{stats[:reduction_percent].round(1)}% reduction)")
883
+ end
884
+
885
+ # Replace original with compressed
886
+ FileUtils.mv(temp_file, file)
887
+
888
+ file
889
+ end
480
890
  end
481
891
  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.1'
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'