ruby_spriter 0.6.5 → 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.
@@ -2,15 +2,33 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'tmpdir'
5
+ require 'open3'
5
6
 
6
7
  module RubySpriter
7
8
  # Main orchestration processor
8
9
  class Processor
9
- attr_reader :options, :gimp_path
10
+ attr_reader :options, :gimp_path, :split_rows, :split_columns
11
+
12
+ # Valid ranges for numeric options
13
+ VALID_RANGES = {
14
+ frame_count: { min: 1, max: 10000, type: Integer },
15
+ columns: { min: 1, max: 100, type: Integer },
16
+ max_width: { min: 1, max: 1920, type: Integer },
17
+ scale_percent: { min: 1, max: 500, type: Integer },
18
+ grow_selection: { min: 0, max: 100, type: Integer },
19
+ sharpen_radius: { min: 0.1, max: 100.0, type: Float },
20
+ sharpen_gain: { min: 0.0, max: 10.0, type: Float },
21
+ sharpen_threshold: { min: 0.0, max: 1.0, type: Float },
22
+ bg_threshold: { min: 0.0, max: 100.0, type: Float }
23
+ }.freeze
10
24
 
11
25
  def initialize(options = {})
12
26
  @options = default_options.merge(options)
13
27
  @gimp_path = nil
28
+ validate_numeric_options!
29
+ validate_split_option!
30
+ validate_extract_option!
31
+ validate_add_meta_option!
14
32
  end
15
33
 
16
34
  # Run the processing workflow
@@ -54,22 +72,55 @@ module RubySpriter
54
72
  validate_columns: true,
55
73
  temp_dir: nil,
56
74
  keep_temp: false,
57
- debug: false
75
+ debug: false,
76
+ overwrite: false,
77
+ save_frames: false,
78
+ split: nil,
79
+ override_md: false,
80
+ extract: nil,
81
+ add_meta: nil,
82
+ overwrite_meta: false
58
83
  }
59
84
  end
60
85
 
61
86
  def validate_options!
62
- input_modes = [options[:video], options[:image], options[:consolidate], options[:verify]].compact
63
-
87
+ input_modes = [options[:video], options[:image], options[:consolidate_mode], options[:verify], options[:batch]].compact
88
+
64
89
  if input_modes.empty?
65
- raise ValidationError, "Must specify --video, --image, --consolidate, or --verify"
90
+ raise ValidationError, "Must specify --video, --image, --consolidate, --verify, or --batch"
66
91
  end
67
92
 
68
93
  if input_modes.length > 1
69
94
  raise ValidationError, "Cannot use multiple input modes together. Choose one."
70
95
  end
71
96
 
97
+ validate_consolidate_options!
72
98
  validate_input_files!
99
+ validate_numeric_options!
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
73
124
  end
74
125
 
75
126
  def validate_input_files!
@@ -108,6 +159,166 @@ module RubySpriter
108
159
  end
109
160
  end
110
161
 
162
+ def validate_numeric_options!
163
+ VALID_RANGES.each do |option_name, range_config|
164
+ value = options[option_name]
165
+
166
+ # Skip validation if option is not set (nil)
167
+ next if value.nil?
168
+
169
+ min = range_config[:min]
170
+ max = range_config[:max]
171
+
172
+ # Validate that value is within range
173
+ if value < min || value > max
174
+ raise ValidationError, "#{option_name} must be between #{min} and #{max}, got: #{value}"
175
+ end
176
+ end
177
+ end
178
+
179
+ def validate_split_option!
180
+ return unless options[:split]
181
+
182
+ # Parse split format: R:C
183
+ unless options[:split] =~ /^\d+:\d+$/
184
+ raise ValidationError, "Invalid --split format. Use R:C (e.g., 4:4)"
185
+ end
186
+
187
+ rows, columns = options[:split].split(':').map(&:to_i)
188
+
189
+ # Validate ranges
190
+ if rows < 1 || rows > 99
191
+ raise ValidationError, "rows must be between 1 and 99, got: #{rows}"
192
+ end
193
+
194
+ if columns < 1 || columns > 99
195
+ raise ValidationError, "columns must be between 1 and 99, got: #{columns}"
196
+ end
197
+
198
+ # Validate total frames < 1000
199
+ total_frames = rows * columns
200
+ if total_frames >= 1000
201
+ raise ValidationError, "Total frames (#{total_frames}) must be less than 1000"
202
+ end
203
+
204
+ # Store parsed values for later use
205
+ @split_rows = rows
206
+ @split_columns = columns
207
+ end
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
+
111
322
  def check_dependencies!
112
323
  checker = DependencyChecker.new(verbose: options[:debug])
113
324
  results = checker.check_all
@@ -119,8 +330,8 @@ module RubySpriter
119
330
  missing << tool unless results[tool][:available]
120
331
  end
121
332
 
122
- # GIMP only needed for image processing
123
- if needs_gimp? && !results[:gimp][:available]
333
+ # GIMP only needed for scaling and background removal (not for sharpen-only)
334
+ if needs_gimp_specifically? && !results[:gimp][:available]
124
335
  missing << :gimp
125
336
  end
126
337
 
@@ -137,6 +348,10 @@ module RubySpriter
137
348
  end
138
349
 
139
350
  def needs_gimp?
351
+ options[:scale_percent] || options[:remove_bg] || options[:sharpen]
352
+ end
353
+
354
+ def needs_gimp_specifically?
140
355
  options[:scale_percent] || options[:remove_bg]
141
356
  end
142
357
 
@@ -158,7 +373,9 @@ module RubySpriter
158
373
  return { mode: :verify, file: options[:verify] }
159
374
  end
160
375
 
161
- if options[:consolidate]
376
+ if options[:batch]
377
+ return execute_batch_workflow
378
+ elsif options[:consolidate_mode]
162
379
  return execute_consolidate_workflow
163
380
  elsif options[:image]
164
381
  return execute_image_workflow
@@ -168,24 +385,50 @@ module RubySpriter
168
385
  end
169
386
 
170
387
  def execute_video_workflow
171
- # Step 1: Convert video to spritesheet
388
+ # Step 1: Determine output filename
389
+ desired_output = options[:output] || Utils::FileHelper.spritesheet_filename(options[:video])
390
+ final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
391
+
392
+ # Step 2: Convert video to spritesheet
172
393
  video_processor = VideoProcessor.new(options)
173
394
  result = video_processor.create_spritesheet(
174
395
  options[:video],
175
- options[:output] || Utils::FileHelper.spritesheet_filename(options[:video])
396
+ final_output
176
397
  )
177
398
 
178
399
  working_file = result[:output_file]
400
+ intermediate_files = []
179
401
 
180
- # Step 2: Apply GIMP processing if requested
402
+ # Step 3: Apply GIMP processing if requested
181
403
  if needs_gimp?
404
+ initial_file = working_file
182
405
  working_file = process_with_gimp(working_file)
406
+
407
+ # Track intermediate files for cleanup (everything except initial and final)
408
+ if working_file != initial_file
409
+ intermediate_files = collect_intermediate_files(initial_file, working_file)
410
+ end
411
+ end
412
+
413
+ # Step 4: Move to final output location if different
414
+ if final_output != working_file
415
+ FileUtils.cp(working_file, final_output)
416
+ # Add the GIMP output to intermediates if it's different from final
417
+ intermediate_files << working_file unless intermediate_files.include?(working_file)
418
+ working_file = final_output
419
+ end
420
+
421
+ # Step 5: Clean up intermediate files
422
+ cleanup_intermediate_files(intermediate_files)
423
+
424
+ # Step 6: Apply max compression if requested
425
+ if options[:max_compress]
426
+ working_file = apply_max_compression(working_file)
183
427
  end
184
428
 
185
- # Step 3: Move to final output location if different
186
- if options[:output] && working_file != options[:output]
187
- FileUtils.cp(working_file, options[:output])
188
- working_file = options[:output]
429
+ # Step 7: Extract individual frames if requested
430
+ if options[:save_frames]
431
+ split_frames_from_spritesheet(working_file, result[:columns], result[:rows], result[:frames])
189
432
  end
190
433
 
191
434
  Utils::OutputFormatter.header("SUCCESS!")
@@ -196,16 +439,57 @@ module RubySpriter
196
439
 
197
440
  def execute_image_workflow
198
441
  working_file = options[:image]
442
+ intermediate_files = []
443
+
444
+ # Handle metadata addition workflow first
445
+ if options[:add_meta]
446
+ return execute_add_meta_workflow
447
+ end
199
448
 
200
- # Apply GIMP processing if requested
449
+ # Handle frame extraction workflow
450
+ if options[:extract]
451
+ return execute_extract_workflow
452
+ end
453
+
454
+ # Apply GIMP processing if requested (GimpProcessor handles uniqueness)
201
455
  if needs_gimp?
456
+ initial_file = working_file
202
457
  working_file = process_with_gimp(working_file)
458
+
459
+ # Track intermediate files for cleanup (everything except initial and final)
460
+ if working_file != initial_file
461
+ intermediate_files = collect_intermediate_files(initial_file, working_file)
462
+ end
463
+ end
464
+
465
+ # Move to final output location if user specified explicit --output
466
+ if options[:output]
467
+ final_output = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
468
+ if working_file != final_output
469
+ FileUtils.cp(working_file, final_output)
470
+ # Add the GIMP output to intermediates if it's different from final
471
+ intermediate_files << working_file unless intermediate_files.include?(working_file)
472
+ working_file = final_output
473
+ end
474
+ end
475
+
476
+ # Clean up intermediate files
477
+ cleanup_intermediate_files(intermediate_files)
478
+
479
+ # Apply max compression if requested
480
+ if options[:max_compress]
481
+ working_file = apply_max_compression(working_file)
203
482
  end
204
483
 
205
- # Move to final output location if specified
206
- if options[:output] && working_file != options[:output]
207
- FileUtils.cp(working_file, options[:output])
208
- working_file = options[:output]
484
+ # Determine if we should split the image into frames
485
+ should_split = options[:save_frames] || options[:split]
486
+
487
+ if should_split
488
+ # Determine rows, columns, and frames to use
489
+ rows, columns, frames = determine_split_parameters(working_file)
490
+
491
+ # Split the image into frames
492
+ split_frames_from_spritesheet(working_file, columns, rows, frames)
209
493
  end
210
494
 
211
495
  Utils::OutputFormatter.header("SUCCESS!")
@@ -218,12 +502,192 @@ module RubySpriter
218
502
  }
219
503
  end
220
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
+
221
652
  def execute_consolidate_workflow
222
653
  consolidator = Consolidator.new(options)
223
-
224
- output_file = options[:output] || generate_consolidated_filename
225
-
226
- result = consolidator.consolidate(options[:consolidate], output_file)
654
+
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
+
682
+ final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
683
+
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
227
691
 
228
692
  Utils::OutputFormatter.header("SUCCESS!")
229
693
  Utils::OutputFormatter.success("Final output: #{result[:output_file]}")
@@ -231,14 +695,102 @@ module RubySpriter
231
695
  result.merge(mode: :consolidate)
232
696
  end
233
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
+
234
705
  def process_with_gimp(input_file)
235
706
  gimp_processor = GimpProcessor.new(@gimp_path, options)
236
707
  gimp_processor.process(input_file)
237
708
  end
238
709
 
239
710
  def generate_consolidated_filename
240
- timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
241
- "consolidated_spritesheet_#{timestamp}.png"
711
+ "consolidated_spritesheet.png"
712
+ end
713
+
714
+ def split_frames_from_spritesheet(spritesheet_file, columns, rows, frames)
715
+ # Determine frames directory based on spritesheet filename
716
+ spritesheet_basename = File.basename(spritesheet_file, '.*')
717
+ frames_dir = File.join(File.dirname(spritesheet_file), "#{spritesheet_basename}_frames")
718
+
719
+ # Split the spritesheet into individual frames
720
+ splitter = Utils::SpritesheetSplitter.new
721
+ splitter.split_into_frames(spritesheet_file, frames_dir, columns, rows, frames)
722
+ end
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
+
756
+ def collect_intermediate_files(initial_file, final_file)
757
+ # Find all files that were created during GIMP processing
758
+ # Pattern: initial_file + suffixes like -nobg-fuzzy, -scaled-40pct, etc.
759
+ # Note: output_filename uses DASH separator, not underscore
760
+ dir = File.dirname(initial_file)
761
+ basename = File.basename(initial_file, '.*')
762
+ ext = File.extname(initial_file)
763
+
764
+ # Get all PNG files in the directory that start with the basename and have a dash
765
+ pattern = File.join(dir, "#{basename}-*#{ext}")
766
+ intermediate_files = Dir.glob(pattern)
767
+
768
+ # Normalize paths for comparison (Windows compatibility)
769
+ initial_normalized = File.expand_path(initial_file)
770
+ final_normalized = File.expand_path(final_file)
771
+
772
+ # Exclude the initial and final files
773
+ intermediate_files.reject do |f|
774
+ f_normalized = File.expand_path(f)
775
+ f_normalized == initial_normalized || f_normalized == final_normalized
776
+ end
777
+ end
778
+
779
+ def cleanup_intermediate_files(files)
780
+ return if files.empty?
781
+
782
+ if options[:debug]
783
+ Utils::OutputFormatter.note("Cleaning up #{files.length} intermediate file(s):")
784
+ end
785
+
786
+ files.each do |file|
787
+ if File.exist?(file)
788
+ File.delete(file)
789
+ if options[:debug]
790
+ Utils::OutputFormatter.indent("Deleted: #{File.basename(file)}")
791
+ end
792
+ end
793
+ end
242
794
  end
243
795
 
244
796
  def cleanup
@@ -247,5 +799,88 @@ module RubySpriter
247
799
  Utils::OutputFormatter.note("Cleaned up temporary files") if options[:debug]
248
800
  end
249
801
  end
802
+
803
+ def determine_split_parameters(image_file)
804
+ metadata = MetadataManager.read(image_file)
805
+
806
+ # Check if we have metadata
807
+ if metadata && metadata[:columns] && metadata[:rows] && metadata[:frames]
808
+ # Metadata exists
809
+ if options[:split] && !options[:override_md]
810
+ # Warn user that split values will be ignored
811
+ Utils::OutputFormatter.note("Image has metadata (#{metadata[:rows]}×#{metadata[:columns]}). Your --split values will be ignored. Use --override-md to override.")
812
+ return [metadata[:rows], metadata[:columns], metadata[:frames]]
813
+ elsif options[:split] && options[:override_md]
814
+ # Use user's split values
815
+ frames = @split_rows * @split_columns
816
+ validate_image_dimensions(image_file, @split_rows, @split_columns)
817
+ return [@split_rows, @split_columns, frames]
818
+ else
819
+ # Use metadata
820
+ return [metadata[:rows], metadata[:columns], metadata[:frames]]
821
+ end
822
+ else
823
+ # No metadata
824
+ if options[:split]
825
+ # Use user's split values
826
+ frames = @split_rows * @split_columns
827
+ validate_image_dimensions(image_file, @split_rows, @split_columns)
828
+ return [@split_rows, @split_columns, frames]
829
+ else
830
+ # Error: no metadata and no split option
831
+ raise ValidationError, "Image has no metadata. Please provide --split R:C"
832
+ end
833
+ end
834
+ end
835
+
836
+ def validate_image_dimensions(image_file, rows, columns)
837
+ # Get image dimensions using ImageMagick
838
+ cmd = [
839
+ 'magick',
840
+ 'identify',
841
+ '-format', '%wx%h',
842
+ Utils::PathHelper.quote_path(image_file)
843
+ ].join(' ')
844
+
845
+ stdout, stderr, status = Open3.capture3(cmd)
846
+
847
+ unless status.success?
848
+ raise ProcessingError, "Could not get image dimensions: #{stderr}"
849
+ end
850
+
851
+ width, height = stdout.strip.split('x').map(&:to_i)
852
+
853
+ # Check if dimensions divide evenly
854
+ unless width % columns == 0
855
+ raise ValidationError, "Image width (#{width}) not evenly divisible by #{columns} columns"
856
+ end
857
+
858
+ unless height % rows == 0
859
+ raise ValidationError, "Image height (#{height}) not evenly divisible by #{rows} rows"
860
+ end
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
250
885
  end
251
886
  end