ruby_spriter 0.6.7 → 0.7.0.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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +3 -3
  3. data/CHANGELOG.md +1035 -405
  4. data/Gemfile +17 -17
  5. data/LICENSE +21 -21
  6. data/README.md +183 -902
  7. data/bin/ruby_spriter +20 -20
  8. data/lib/ruby_spriter/background_sampler.rb +140 -0
  9. data/lib/ruby_spriter/batch_processor.rb +268 -212
  10. data/lib/ruby_spriter/cell_cleanup_config.rb +21 -0
  11. data/lib/ruby_spriter/cell_cleanup_gimp_script.rb +123 -0
  12. data/lib/ruby_spriter/cell_cleanup_processor.rb +230 -0
  13. data/lib/ruby_spriter/cli.rb +676 -612
  14. data/lib/ruby_spriter/compression_manager.rb +101 -101
  15. data/lib/ruby_spriter/consolidator.rb +179 -179
  16. data/lib/ruby_spriter/dependency_checker.rb +224 -174
  17. data/lib/ruby_spriter/ghost_edge_cleaner.rb +164 -0
  18. data/lib/ruby_spriter/gimp_processor.rb +1188 -667
  19. data/lib/ruby_spriter/metadata_manager.rb +117 -116
  20. data/lib/ruby_spriter/platform.rb +137 -82
  21. data/lib/ruby_spriter/processor.rb +1230 -886
  22. data/lib/ruby_spriter/smoke_detector.rb +223 -0
  23. data/lib/ruby_spriter/threshold_stepper.rb +227 -0
  24. data/lib/ruby_spriter/utils/file_helper.rb +82 -82
  25. data/lib/ruby_spriter/utils/image_helper.rb +16 -0
  26. data/lib/ruby_spriter/utils/output_formatter.rb +65 -65
  27. data/lib/ruby_spriter/utils/path_helper.rb +59 -59
  28. data/lib/ruby_spriter/utils/spritesheet_splitter.rb +86 -86
  29. data/lib/ruby_spriter/version.rb +6 -7
  30. data/lib/ruby_spriter/video_processor.rb +357 -139
  31. data/lib/ruby_spriter.rb +38 -34
  32. data/ruby_spriter.gemspec +44 -42
  33. data/spec/fixtures/centered_sprite_with_inner_bg.png +0 -0
  34. data/spec/fixtures/centered_sprite_with_inner_bg_inner_removed.png +0 -0
  35. data/spec/fixtures/centered_sprite_with_inner_bg_threshold_stepped.png +0 -0
  36. data/spec/fixtures/complex_background_sprite.png +0 -0
  37. data/spec/fixtures/death - bloodborne rekconing.mp4 +0 -0
  38. data/spec/fixtures/death - bloodborne rekconing_spritesheet-nobg-global.png +0 -0
  39. data/spec/fixtures/death - bloodborne rekconing_spritesheet.png +0 -0
  40. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431-nobg-global.png +0 -0
  41. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431.png +0 -0
  42. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738-nobg-global.png +0 -0
  43. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738.png +0 -0
  44. data/spec/fixtures/has_inner_bg.png +0 -0
  45. data/spec/fixtures/has_small_inner_bg.png +0 -0
  46. data/spec/fixtures/smoke_effect_sprite.png +0 -0
  47. data/spec/fixtures/spritesheet_with_metadata.png +0 -0
  48. data/spec/fixtures/test_sprite.png +0 -0
  49. data/spec/fixtures/test_sprite_smoke_processed.png +0 -0
  50. data/spec/fixtures/test_video_spritesheet.png +0 -0
  51. data/spec/fixtures/transparent_bg_sprite.png +0 -0
  52. data/spec/fixtures/walk_north_sprite-sheet.png +0 -0
  53. data/spec/integration/no_fuzzy_mode_spec.rb +100 -0
  54. data/spec/ruby_spriter/batch_processor_spec.rb +509 -200
  55. data/spec/ruby_spriter/cli_spec.rb +2026 -1892
  56. data/spec/ruby_spriter/compression_manager_spec.rb +157 -157
  57. data/spec/ruby_spriter/consolidator_spec.rb +538 -538
  58. data/spec/ruby_spriter/gimp_processor_spec.rb +523 -425
  59. data/spec/ruby_spriter/platform_spec.rb +92 -82
  60. data/spec/ruby_spriter/processor_spec.rb +911 -735
  61. data/spec/ruby_spriter/utils/file_helper_spec.rb +150 -150
  62. data/spec/ruby_spriter/utils/path_helper_spec.rb +78 -78
  63. data/spec/ruby_spriter/utils/spritesheet_splitter_spec.rb +104 -104
  64. data/spec/ruby_spriter/video_processor_spec.rb +346 -29
  65. data/spec/spec_helper.rb +41 -41
  66. data/spec/tmp/cli_test_output.png +0 -0
  67. data/spec/tmp/cli_test_output_20251105_114647_395.png +0 -0
  68. data/spec/tmp/combined_test.png +0 -0
  69. data/spec/tmp/compat_test.png +0 -0
  70. data/spec/tmp/compat_test_20251030_174233_361.png +0 -0
  71. data/spec/tmp/final_all_features.png +0 -0
  72. data/spec/tmp/final_test_all_features.png +0 -0
  73. data/spec/tmp/full_pipeline_test.png +0 -0
  74. data/spec/tmp/inner_test.png +0 -0
  75. data/spec/tmp/integration_test.png +0 -0
  76. data/spec/tmp/validation_test.png +0 -0
  77. data/spec/unit/background_sampler_spec.rb +132 -0
  78. data/spec/unit/cell_cleanup_config_spec.rb +32 -0
  79. data/spec/unit/cell_cleanup_gimp_script_spec.rb +59 -0
  80. data/spec/unit/cell_cleanup_processor_spec.rb +261 -0
  81. data/spec/unit/ghost_edge_cleaner_spec.rb +223 -0
  82. data/spec/unit/gimp_processor_single_point_selection_spec.rb +81 -0
  83. data/spec/unit/smoke_detector_spec.rb +246 -0
  84. data/spec/unit/threshold_stepper_spec.rb +195 -0
  85. metadata +56 -10
@@ -1,886 +1,1230 @@
1
- # frozen_string_literal: true
2
-
3
- require 'fileutils'
4
- require 'tmpdir'
5
- require 'open3'
6
-
7
- module RubySpriter
8
- # Main orchestration processor
9
- class Processor
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
24
-
25
- def initialize(options = {})
26
- @options = default_options.merge(options)
27
- @gimp_path = nil
28
- validate_numeric_options!
29
- validate_split_option!
30
- validate_extract_option!
31
- validate_add_meta_option!
32
- end
33
-
34
- # Run the processing workflow
35
- def run
36
- validate_options!
37
- check_dependencies!
38
- setup_temp_directory
39
-
40
- result = execute_workflow
41
-
42
- cleanup unless options[:keep_temp]
43
-
44
- result
45
- end
46
-
47
- private
48
-
49
- def default_options
50
- {
51
- video: nil,
52
- image: nil,
53
- consolidate: nil,
54
- verify: nil,
55
- output: nil,
56
- frame_count: 16,
57
- columns: 4,
58
- max_width: 320,
59
- padding: 0,
60
- bg_color: 'black',
61
- scale_percent: nil,
62
- scale_interpolation: 'nohalo',
63
- sharpen: false,
64
- sharpen_radius: 2.0,
65
- sharpen_gain: 0.5,
66
- sharpen_threshold: 0.03,
67
- remove_bg: false,
68
- bg_threshold: 0.0,
69
- grow_selection: 1,
70
- fuzzy_select: true,
71
- operation_order: :scale_then_remove_bg,
72
- validate_columns: true,
73
- temp_dir: nil,
74
- keep_temp: 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
83
- }
84
- end
85
-
86
- def validate_options!
87
- input_modes = [options[:video], options[:image], options[:consolidate_mode], options[:verify], options[:batch]].compact
88
-
89
- if input_modes.empty?
90
- raise ValidationError, "Must specify --video, --image, --consolidate, --verify, or --batch"
91
- end
92
-
93
- if input_modes.length > 1
94
- raise ValidationError, "Cannot use multiple input modes together. Choose one."
95
- end
96
-
97
- validate_consolidate_options!
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
124
- end
125
-
126
- def validate_input_files!
127
- if options[:video]
128
- Utils::FileHelper.validate_exists!(options[:video])
129
- validate_file_extension!(options[:video], ['.mp4'], '--video')
130
- end
131
-
132
- if options[:image]
133
- Utils::FileHelper.validate_exists!(options[:image])
134
- validate_file_extension!(options[:image], ['.png'], '--image')
135
- end
136
-
137
- if options[:consolidate]
138
- if options[:consolidate].length < 2
139
- raise ValidationError, "--consolidate requires at least 2 files"
140
- end
141
-
142
- options[:consolidate].each do |file|
143
- Utils::FileHelper.validate_exists!(file)
144
- validate_file_extension!(file, ['.png'], '--consolidate')
145
- end
146
- end
147
-
148
- if options[:verify]
149
- Utils::FileHelper.validate_exists!(options[:verify])
150
- validate_file_extension!(options[:verify], ['.png'], '--verify')
151
- end
152
- end
153
-
154
- def validate_file_extension!(file_path, valid_extensions, flag_name)
155
- ext = File.extname(file_path).downcase
156
- unless valid_extensions.include?(ext)
157
- expected = valid_extensions.join(', ')
158
- raise ValidationError, "#{flag_name} expects #{expected} file, got: #{ext || '(no extension)'}"
159
- end
160
- end
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
-
322
- def check_dependencies!
323
- checker = DependencyChecker.new(verbose: options[:debug])
324
- results = checker.check_all
325
-
326
- # Check required tools
327
- missing = []
328
-
329
- [:ffmpeg, :ffprobe, :imagemagick].each do |tool|
330
- missing << tool unless results[tool][:available]
331
- end
332
-
333
- # GIMP only needed for scaling and background removal (not for sharpen-only)
334
- if needs_gimp_specifically? && !results[:gimp][:available]
335
- missing << :gimp
336
- end
337
-
338
- if missing.any?
339
- checker.print_report
340
- raise DependencyError, "Missing required dependencies: #{missing.join(', ')}"
341
- end
342
-
343
- @gimp_path = checker.gimp_path if results[:gimp][:available]
344
-
345
- if options[:debug]
346
- checker.print_report
347
- end
348
- end
349
-
350
- def needs_gimp?
351
- options[:scale_percent] || options[:remove_bg] || options[:sharpen]
352
- end
353
-
354
- def needs_gimp_specifically?
355
- options[:scale_percent] || options[:remove_bg]
356
- end
357
-
358
- def setup_temp_directory
359
- @options[:temp_dir] = Dir.mktmpdir('ruby_spriter_')
360
-
361
- if options[:debug]
362
- Utils::OutputFormatter.indent("Temp directory: #{options[:temp_dir]}")
363
- end
364
- end
365
-
366
- def execute_workflow
367
- Utils::OutputFormatter.header("Ruby Spriter v#{VERSION}")
368
- puts "Platform: #{Platform.current.to_s.capitalize}"
369
- puts "Date: #{VERSION_DATE}\n\n"
370
-
371
- if options[:verify]
372
- MetadataManager.verify(options[:verify])
373
- return { mode: :verify, file: options[:verify] }
374
- end
375
-
376
- if options[:batch]
377
- return execute_batch_workflow
378
- elsif options[:consolidate_mode]
379
- return execute_consolidate_workflow
380
- elsif options[:image]
381
- return execute_image_workflow
382
- else
383
- return execute_video_workflow
384
- end
385
- end
386
-
387
- def execute_video_workflow
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
393
- video_processor = VideoProcessor.new(options)
394
- result = video_processor.create_spritesheet(
395
- options[:video],
396
- final_output
397
- )
398
-
399
- working_file = result[:output_file]
400
- intermediate_files = []
401
-
402
- # Step 3: Apply GIMP processing if requested
403
- if needs_gimp?
404
- initial_file = working_file
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)
427
- end
428
-
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])
432
- end
433
-
434
- Utils::OutputFormatter.header("SUCCESS!")
435
- Utils::OutputFormatter.success("Final output: #{working_file}")
436
-
437
- result.merge(final_output: working_file)
438
- end
439
-
440
- def execute_image_workflow
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
448
-
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)
455
- if needs_gimp?
456
- initial_file = working_file
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)
482
- end
483
-
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)
493
- end
494
-
495
- Utils::OutputFormatter.header("SUCCESS!")
496
- Utils::OutputFormatter.success("Final output: #{working_file}")
497
-
498
- {
499
- mode: :image,
500
- input_file: options[:image],
501
- output_file: working_file
502
- }
503
- end
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
-
652
- def execute_consolidate_workflow
653
- consolidator = Consolidator.new(options)
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
691
-
692
- Utils::OutputFormatter.header("SUCCESS!")
693
- Utils::OutputFormatter.success("Final output: #{result[:output_file]}")
694
-
695
- result.merge(mode: :consolidate)
696
- end
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
-
705
- def process_with_gimp(input_file)
706
- gimp_processor = GimpProcessor.new(@gimp_path, options)
707
- gimp_processor.process(input_file)
708
- end
709
-
710
- def generate_consolidated_filename
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
794
- end
795
-
796
- def cleanup
797
- if options[:temp_dir] && Dir.exist?(options[:temp_dir])
798
- FileUtils.rm_rf(options[:temp_dir])
799
- Utils::OutputFormatter.note("Cleaned up temporary files") if options[:debug]
800
- end
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
885
- end
886
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tmpdir'
5
+ require 'open3'
6
+
7
+ module RubySpriter
8
+ # Main orchestration processor
9
+ class Processor
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
24
+
25
+ def initialize(options = {})
26
+ @options = default_options.merge(options)
27
+ @gimp_path = nil
28
+ @gimp_version = nil
29
+ validate_numeric_options!
30
+ validate_split_option!
31
+ validate_extract_option!
32
+ validate_add_meta_option!
33
+ end
34
+
35
+ # Run the processing workflow
36
+ def run
37
+ validate_options!
38
+ check_dependencies!
39
+ setup_temp_directory
40
+
41
+ result = execute_workflow
42
+
43
+ cleanup unless options[:keep_temp]
44
+
45
+ result
46
+ end
47
+
48
+ private
49
+
50
+ # Check if using frame-by-frame background removal mode
51
+ # @return [Boolean] true if both --by-frame and --remove-bg flags are set
52
+ def using_frame_by_frame_background_removal?
53
+ options[:by_frame] && options[:remove_bg]
54
+ end
55
+
56
+ # Normalize video processing result to standard format
57
+ # @param result [Hash] Result from process_with_background_removal
58
+ # @return [Hash] Normalized result with :output_file, :columns, :rows, :frames
59
+ def normalize_video_result_format(result)
60
+ {
61
+ output_file: result[:output_file],
62
+ columns: result[:columns],
63
+ rows: (result[:frames].to_f / result[:columns]).ceil,
64
+ frames: result[:frames]
65
+ }
66
+ end
67
+
68
+ def default_options
69
+ {
70
+ video: nil,
71
+ image: nil,
72
+ consolidate: nil,
73
+ verify: nil,
74
+ output: nil,
75
+ frame_count: 16,
76
+ columns: 4,
77
+ max_width: 320,
78
+ padding: 0,
79
+ bg_color: 'black',
80
+ scale_percent: nil,
81
+ scale_interpolation: 'nohalo',
82
+ sharpen: false,
83
+ sharpen_radius: 2.0,
84
+ sharpen_gain: 0.5,
85
+ sharpen_threshold: 0.03,
86
+ remove_bg: false,
87
+ bg_threshold: 15.0,
88
+ feather_radius: 0.0,
89
+ grow_selection: 0, # Changed from 1 to 0 - don't grow by default!
90
+ fuzzy_select: true,
91
+ operation_order: :scale_then_remove_bg,
92
+ validate_columns: true,
93
+ temp_dir: nil,
94
+ keep_temp: false,
95
+ debug: false,
96
+ overwrite: false,
97
+ save_frames: false,
98
+ split: nil,
99
+ override_md: false,
100
+ extract: nil,
101
+ add_meta: nil,
102
+ overwrite_meta: false
103
+ }
104
+ end
105
+
106
+ def validate_options!
107
+ input_modes = [options[:video], options[:image], options[:consolidate_mode], options[:verify], options[:batch]].compact
108
+
109
+ if input_modes.empty?
110
+ raise ValidationError, "Must specify --video, --image, --consolidate, --verify, or --batch"
111
+ end
112
+
113
+ if input_modes.length > 1
114
+ raise ValidationError, "Cannot use multiple input modes together. Choose one."
115
+ end
116
+
117
+ validate_consolidate_options!
118
+ validate_input_files!
119
+ validate_numeric_options!
120
+ validate_split_option!
121
+ validate_extract_option!
122
+ validate_add_meta_option!
123
+ end
124
+
125
+ def validate_consolidate_options!
126
+ return unless options[:consolidate_mode]
127
+
128
+ # Check for mutual exclusivity between file list and directory
129
+ if options[:consolidate] && options[:dir]
130
+ raise ValidationError, "Cannot use --dir with comma-separated file list for --consolidate. Choose one method."
131
+ end
132
+
133
+ # Require either file list or directory
134
+ unless options[:consolidate] || options[:dir]
135
+ raise ValidationError, "--consolidate requires either comma-separated files or --dir option"
136
+ end
137
+
138
+ # Validate directory if using directory mode
139
+ if options[:dir] && !options[:consolidate]
140
+ unless File.directory?(options[:dir])
141
+ raise ValidationError, "Directory not found: #{options[:dir]}"
142
+ end
143
+ end
144
+ end
145
+
146
+ def validate_input_files!
147
+ if options[:video]
148
+ Utils::FileHelper.validate_exists!(options[:video])
149
+ validate_file_extension!(options[:video], ['.mp4'], '--video')
150
+ end
151
+
152
+ if options[:image]
153
+ Utils::FileHelper.validate_exists!(options[:image])
154
+ validate_file_extension!(options[:image], ['.png'], '--image')
155
+ end
156
+
157
+ if options[:consolidate]
158
+ if options[:consolidate].length < 2
159
+ raise ValidationError, "--consolidate requires at least 2 files"
160
+ end
161
+
162
+ options[:consolidate].each do |file|
163
+ Utils::FileHelper.validate_exists!(file)
164
+ validate_file_extension!(file, ['.png'], '--consolidate')
165
+ end
166
+ end
167
+
168
+ if options[:verify]
169
+ Utils::FileHelper.validate_exists!(options[:verify])
170
+ validate_file_extension!(options[:verify], ['.png'], '--verify')
171
+ end
172
+ end
173
+
174
+ def validate_file_extension!(file_path, valid_extensions, flag_name)
175
+ ext = File.extname(file_path).downcase
176
+ unless valid_extensions.include?(ext)
177
+ expected = valid_extensions.join(', ')
178
+ raise ValidationError, "#{flag_name} expects #{expected} file, got: #{ext || '(no extension)'}"
179
+ end
180
+ end
181
+
182
+ def validate_numeric_options!
183
+ VALID_RANGES.each do |option_name, range_config|
184
+ value = options[option_name]
185
+
186
+ # Skip validation if option is not set (nil)
187
+ next if value.nil?
188
+
189
+ min = range_config[:min]
190
+ max = range_config[:max]
191
+
192
+ # Validate that value is within range
193
+ if value < min || value > max
194
+ raise ValidationError, "#{option_name} must be between #{min} and #{max}, got: #{value}"
195
+ end
196
+ end
197
+ end
198
+
199
+ def validate_split_option!
200
+ return unless options[:split]
201
+
202
+ # Parse split format: R:C
203
+ unless options[:split] =~ /^\d+:\d+$/
204
+ raise ValidationError, "Invalid --split format. Use R:C (e.g., 4:4)"
205
+ end
206
+
207
+ rows, columns = options[:split].split(':').map(&:to_i)
208
+
209
+ # Validate ranges
210
+ if rows < 1 || rows > 99
211
+ raise ValidationError, "rows must be between 1 and 99, got: #{rows}"
212
+ end
213
+
214
+ if columns < 1 || columns > 99
215
+ raise ValidationError, "columns must be between 1 and 99, got: #{columns}"
216
+ end
217
+
218
+ # Validate total frames < 1000
219
+ total_frames = rows * columns
220
+ if total_frames >= 1000
221
+ raise ValidationError, "Total frames (#{total_frames}) must be less than 1000"
222
+ end
223
+
224
+ # Store parsed values for later use
225
+ @split_rows = rows
226
+ @split_columns = columns
227
+ end
228
+
229
+ def validate_extract_option!
230
+ return unless options[:extract]
231
+
232
+ # Parse extract format: comma-separated integers (allow negatives for better error messages)
233
+ unless options[:extract] =~ /^-?\d+(,-?\d+)*$/
234
+ raise ValidationError, "Invalid --extract format. Use comma-separated frame numbers (e.g., 1,2,4,5,8)"
235
+ end
236
+
237
+ # Parse frame numbers
238
+ frame_numbers = options[:extract].split(',').map(&:to_i)
239
+
240
+ # Validate minimum 2 frames
241
+ if frame_numbers.length < 2
242
+ raise ValidationError, "--extract requires at least 2 frames, got: #{frame_numbers.length}"
243
+ end
244
+
245
+ # Validate frame numbers are 1-indexed (no 0 or negative)
246
+ invalid_frames = frame_numbers.select { |n| n <= 0 }
247
+ if invalid_frames.any?
248
+ raise ValidationError, "Frame numbers must be 1-indexed (positive integers), got invalid: #{invalid_frames.join(', ')}"
249
+ end
250
+
251
+ # Check for metadata (required for extraction)
252
+ return unless options[:image] # Only validate bounds if we have an image path
253
+
254
+ image_file = options[:image]
255
+ metadata = MetadataManager.read(image_file)
256
+
257
+ unless metadata
258
+ raise ValidationError, "Image has no metadata. Cannot extract frames without knowing the grid layout. Use --add-meta first."
259
+ end
260
+
261
+ # Validate frame numbers are within bounds
262
+ total_frames = metadata[:frames]
263
+ out_of_bounds = frame_numbers.select { |n| n > total_frames }
264
+ if out_of_bounds.any?
265
+ first_oob = out_of_bounds.first
266
+ raise ValidationError, "Frame #{first_oob} is out of bounds (image only has #{total_frames} frames)"
267
+ end
268
+
269
+ # Set default columns if not specified
270
+ options[:columns] ||= 4
271
+ end
272
+
273
+ def validate_add_meta_option!
274
+ return unless options[:add_meta]
275
+
276
+ # Parse add-meta format: R:C
277
+ unless options[:add_meta] =~ /^\d+:\d+$/
278
+ raise ValidationError, "Invalid --add-meta format. Use R:C (e.g., 4:4)"
279
+ end
280
+
281
+ rows, columns = options[:add_meta].split(':').map(&:to_i)
282
+
283
+ # Validate ranges
284
+ if rows < 1 || rows > 99
285
+ raise ValidationError, "rows must be between 1 and 99, got: #{rows}"
286
+ end
287
+
288
+ if columns < 1 || columns > 99
289
+ raise ValidationError, "columns must be between 1 and 99, got: #{columns}"
290
+ end
291
+
292
+ # Validate total frames < 1000
293
+ total_frames = rows * columns
294
+ if total_frames >= 1000
295
+ raise ValidationError, "Total frames (#{total_frames}) must be less than 1000"
296
+ end
297
+
298
+ # Check if we need to validate against image file
299
+ return unless options[:image]
300
+
301
+ image_file = options[:image]
302
+ metadata = MetadataManager.read(image_file)
303
+
304
+ # Check for existing metadata
305
+ if metadata && !options[:overwrite_meta]
306
+ raise ValidationError, "Image already has spritesheet metadata. Use --overwrite-meta to replace it."
307
+ end
308
+
309
+ # Validate image dimensions divide evenly by grid
310
+ dimensions = get_image_dimensions(image_file)
311
+ tile_width = dimensions[:width] / columns.to_f
312
+ tile_height = dimensions[:height] / rows.to_f
313
+
314
+ unless tile_width == tile_width.to_i && tile_height == tile_height.to_i
315
+ 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}"
316
+ end
317
+
318
+ # Validate custom frame count doesn't exceed grid size
319
+ if options[:frame_count] && options[:frame_count] > total_frames
320
+ raise ValidationError, "Frame count (#{options[:frame_count]}) exceeds grid size (#{total_frames})"
321
+ end
322
+ end
323
+
324
+ def get_image_dimensions(image_file)
325
+ cmd = [
326
+ 'magick',
327
+ 'identify',
328
+ '-format', '%wx%h',
329
+ Utils::PathHelper.quote_path(image_file)
330
+ ].join(' ')
331
+
332
+ stdout, stderr, status = Open3.capture3(cmd)
333
+
334
+ unless status.success?
335
+ raise ProcessingError, "Could not get image dimensions: #{stderr}"
336
+ end
337
+
338
+ width, height = stdout.strip.split('x').map(&:to_i)
339
+ { width: width, height: height }
340
+ end
341
+
342
+ def check_dependencies!
343
+ checker = DependencyChecker.new(verbose: options[:debug])
344
+ results = checker.check_all
345
+
346
+ # Check required tools
347
+ missing = []
348
+
349
+ [:ffmpeg, :ffprobe, :imagemagick].each do |tool|
350
+ missing << tool unless results[tool][:available]
351
+ end
352
+
353
+ # GIMP only needed for scaling and background removal (not for sharpen-only)
354
+ if needs_gimp_specifically? && !results[:gimp][:available]
355
+ missing << :gimp
356
+ end
357
+
358
+ if missing.any?
359
+ checker.print_report
360
+ raise DependencyError, "Missing required dependencies: #{missing.join(', ')}"
361
+ end
362
+
363
+ if results[:gimp][:available]
364
+ @gimp_path = checker.gimp_path
365
+ @gimp_version = checker.gimp_version
366
+ end
367
+
368
+ if options[:debug]
369
+ checker.print_report
370
+ end
371
+ end
372
+
373
+ def needs_gimp?
374
+ options[:scale_percent] || options[:remove_bg] || options[:sharpen]
375
+ end
376
+
377
+ def needs_gimp_specifically?
378
+ options[:scale_percent] || options[:remove_bg]
379
+ end
380
+
381
+ def setup_temp_directory
382
+ @options[:temp_dir] = Dir.mktmpdir('ruby_spriter_')
383
+
384
+ if options[:debug]
385
+ Utils::OutputFormatter.indent("Temp directory: #{options[:temp_dir]}")
386
+ end
387
+ end
388
+
389
+ def execute_workflow
390
+ Utils::OutputFormatter.header("Ruby Spriter v#{VERSION}")
391
+ puts "Platform: #{Platform.current.to_s.capitalize}"
392
+ puts "Date: #{VERSION_DATE}\n\n"
393
+
394
+ if options[:verify]
395
+ MetadataManager.verify(options[:verify])
396
+ return { mode: :verify, file: options[:verify] }
397
+ end
398
+
399
+ if options[:batch]
400
+ return execute_batch_workflow
401
+ elsif options[:consolidate_mode]
402
+ return execute_consolidate_workflow
403
+ elsif options[:image]
404
+ return execute_image_workflow
405
+ else
406
+ return execute_video_workflow
407
+ end
408
+ end
409
+
410
+ def execute_video_workflow
411
+ # Step 1: Determine output filename
412
+ desired_output = options[:output] || Utils::FileHelper.spritesheet_filename(options[:video])
413
+ final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
414
+
415
+ # Step 2: Convert video to spritesheet
416
+ # Pass gimp_path through options for background removal
417
+ video_options = options.merge(gimp_path: @gimp_path)
418
+ video_processor = VideoProcessor.new(video_options)
419
+
420
+ # Check if we need frame-by-frame background removal
421
+ if using_frame_by_frame_background_removal?
422
+ # Frame-by-frame processing with background removal
423
+ result = video_processor.process_with_background_removal(
424
+ options[:video],
425
+ final_output,
426
+ video_options
427
+ )
428
+
429
+ # Convert result to match expected format
430
+ result = normalize_video_result_format(result)
431
+ else
432
+ # Standard video processing
433
+ result = video_processor.create_spritesheet(
434
+ options[:video],
435
+ final_output
436
+ )
437
+ end
438
+
439
+ working_file = result[:output_file]
440
+ intermediate_files = []
441
+
442
+ # Step 3: Apply GIMP processing if requested
443
+ # Skip GIMP processing if by_frame already handled background removal
444
+ if needs_gimp? && !using_frame_by_frame_background_removal?
445
+ initial_file = working_file
446
+ working_file = process_with_gimp(working_file)
447
+
448
+ # Apply cell cleanup after GIMP background removal
449
+ if options[:cleanup_cells] && options[:remove_bg]
450
+ # Pass frame count and columns to cell cleanup
451
+ cleanup_options = options.merge(
452
+ frames: result[:frames],
453
+ columns: result[:columns]
454
+ )
455
+ working_file = apply_cell_cleanup(working_file, cleanup_options)
456
+ end
457
+
458
+ # Track intermediate files for cleanup (everything except initial and final)
459
+ if working_file != initial_file
460
+ intermediate_files = collect_intermediate_files(initial_file, working_file)
461
+ end
462
+ end
463
+
464
+ # Step 4: Move to final output location if different
465
+ if final_output != working_file
466
+ FileUtils.cp(working_file, final_output)
467
+ # Add the GIMP output to intermediates if it's different from final
468
+ intermediate_files << working_file unless intermediate_files.include?(working_file)
469
+ working_file = final_output
470
+ end
471
+
472
+ # Step 5: Clean up intermediate files
473
+ cleanup_intermediate_files(intermediate_files)
474
+
475
+ # Step 6: Apply max compression if requested
476
+ if options[:max_compress]
477
+ working_file = apply_max_compression(working_file)
478
+ end
479
+
480
+ # Step 7: Extract individual frames if requested
481
+ if options[:save_frames]
482
+ split_frames_from_spritesheet(working_file, result[:columns], result[:rows], result[:frames])
483
+ end
484
+
485
+ Utils::OutputFormatter.header("SUCCESS!")
486
+ Utils::OutputFormatter.success("Final output: #{working_file}")
487
+
488
+ result.merge(final_output: working_file)
489
+ end
490
+
491
+ def execute_image_workflow
492
+ working_file = options[:image]
493
+ intermediate_files = []
494
+ @background_palette = nil
495
+
496
+ # Handle metadata addition workflow first
497
+ if options[:add_meta]
498
+ return execute_add_meta_workflow
499
+ end
500
+
501
+ # Handle frame extraction workflow
502
+ if options[:extract]
503
+ return execute_extract_workflow
504
+ end
505
+
506
+ # STEP 1: Sample edges ONCE if needed for background removal operations
507
+ if options[:remove_bg] && (options[:threshold_stepping] || options[:try_inner])
508
+ @background_palette = sample_edge_colors(working_file)
509
+ end
510
+
511
+ # STEP 2: Apply operations in correct order based on flags
512
+ if options[:threshold_stepping] && options[:remove_bg]
513
+ # Threshold stepping (uses background_palette, skips GIMP fuzzy select)
514
+ working_file = process_threshold_stepping(working_file, intermediate_files, @background_palette)
515
+
516
+ # Inner removal after threshold stepping (if requested)
517
+ if options[:try_inner]
518
+ working_file = process_inner_background_removal(working_file, intermediate_files, @background_palette)
519
+ end
520
+
521
+ elsif options[:try_inner] && options[:remove_bg]
522
+ # CORRECT ORDER: GIMP fuzzy select FIRST, then inner removal
523
+ # GIMP removes outer background quickly, leaving less work for inner removal
524
+ initial_file = working_file
525
+ working_file = process_with_gimp(working_file)
526
+ if working_file != initial_file
527
+ intermediate_files = collect_intermediate_files(initial_file, working_file)
528
+ end
529
+
530
+ # Inner removal processes interior using pre-sampled background_palette
531
+ working_file = process_inner_background_removal(working_file, intermediate_files, @background_palette)
532
+
533
+ elsif needs_gimp?
534
+ # Traditional GIMP processing only (no threshold stepping, no inner removal)
535
+ initial_file = working_file
536
+ working_file = process_with_gimp(working_file)
537
+ if working_file != initial_file
538
+ intermediate_files = collect_intermediate_files(initial_file, working_file)
539
+ end
540
+ end
541
+
542
+ # STEP 3: Ghost edge cleaning (if multi-pass enabled)
543
+ if options[:multi_pass] && options[:remove_bg]
544
+ working_file = process_ghost_edge_cleaning(working_file, intermediate_files)
545
+ end
546
+
547
+ # STEP 4: Smoke detection and removal (only if explicitly enabled)
548
+ if options[:remove_smoke] == true
549
+ working_file = process_smoke_detection(working_file, intermediate_files)
550
+ end
551
+
552
+ # Move to final output location if user specified explicit --output
553
+ if options[:output]
554
+ final_output = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
555
+ if working_file != final_output
556
+ FileUtils.cp(working_file, final_output)
557
+ # Add the GIMP output to intermediates if it's different from final
558
+ intermediate_files << working_file unless intermediate_files.include?(working_file)
559
+ working_file = final_output
560
+ end
561
+ end
562
+
563
+ # Clean up intermediate files
564
+ cleanup_intermediate_files(intermediate_files)
565
+
566
+ # Apply max compression if requested
567
+ if options[:max_compress]
568
+ working_file = apply_max_compression(working_file)
569
+ end
570
+
571
+ # Determine if we should split the image into frames
572
+ should_split = options[:save_frames] || options[:split]
573
+
574
+ if should_split
575
+ # Determine rows, columns, and frames to use
576
+ rows, columns, frames = determine_split_parameters(working_file)
577
+
578
+ # Split the image into frames
579
+ split_frames_from_spritesheet(working_file, columns, rows, frames)
580
+ end
581
+
582
+ Utils::OutputFormatter.header("SUCCESS!")
583
+ Utils::OutputFormatter.success("Final output: #{working_file}")
584
+
585
+ {
586
+ mode: :image,
587
+ input_file: options[:image],
588
+ output_file: working_file
589
+ }
590
+ end
591
+
592
+ def execute_extract_workflow
593
+ input_file = options[:image]
594
+ metadata = MetadataManager.read(input_file)
595
+ frame_numbers = options[:extract].split(',').map(&:to_i)
596
+ columns = options[:columns]
597
+
598
+ Utils::OutputFormatter.header("Frame Extraction")
599
+ Utils::OutputFormatter.indent("Input: #{input_file}")
600
+ Utils::OutputFormatter.indent("Frames to extract: #{frame_numbers.join(', ')}")
601
+ Utils::OutputFormatter.indent("Output columns: #{columns}")
602
+
603
+ # Step 1: Extract all frames to temp directory
604
+ temp_frames_dir = File.join(options[:temp_dir], 'extracted_frames')
605
+ splitter = Utils::SpritesheetSplitter.new
606
+ splitter.split_into_frames(input_file, temp_frames_dir, metadata[:columns], metadata[:rows], metadata[:frames])
607
+
608
+ # Step 2: Keep only requested frames, delete the rest
609
+ spritesheet_basename = File.basename(input_file, '.*')
610
+ all_frame_files = Dir.glob(File.join(temp_frames_dir, "FR*_#{spritesheet_basename}.png")).sort
611
+ requested_frame_files = frame_numbers.map do |frame_num|
612
+ # Frame files are named FR001, FR002, etc. (1-indexed)
613
+ File.join(temp_frames_dir, "FR#{format('%03d', frame_num)}_#{spritesheet_basename}.png")
614
+ end
615
+
616
+ # Delete unwanted frames
617
+ (all_frame_files - requested_frame_files).each { |f| FileUtils.rm_f(f) }
618
+
619
+ Utils::OutputFormatter.indent("Kept #{requested_frame_files.length} frames, deleted #{all_frame_files.length - requested_frame_files.length} frames")
620
+
621
+ # Step 3: Reassemble into new spritesheet
622
+ Utils::OutputFormatter.header("Reassembling Spritesheet")
623
+ reassembled_file = File.join(options[:temp_dir], "reassembled_#{spritesheet_basename}.png")
624
+ reassemble_frames(requested_frame_files, reassembled_file, columns)
625
+
626
+ working_file = reassembled_file
627
+ intermediate_files = []
628
+
629
+ # Step 4: Apply GIMP processing if requested
630
+ if needs_gimp?
631
+ initial_file = working_file
632
+ working_file = process_with_gimp(working_file)
633
+
634
+ if working_file != initial_file
635
+ intermediate_files = collect_intermediate_files(initial_file, working_file)
636
+ end
637
+ end
638
+
639
+ # Step 5: Determine final output filename
640
+ if options[:output]
641
+ final_output = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
642
+ else
643
+ # Auto-generate output filename with _extracted suffix
644
+ base = File.basename(input_file, '.*')
645
+ ext = File.extname(input_file)
646
+ desired_output = File.join(File.dirname(input_file), "#{base}_extracted#{ext}")
647
+ final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
648
+ end
649
+
650
+ # Step 6: Copy to final output
651
+ FileUtils.cp(working_file, final_output)
652
+ working_file = final_output
653
+
654
+ # Step 7: Clean up intermediate files
655
+ cleanup_intermediate_files(intermediate_files)
656
+
657
+ # Step 8: Apply max compression if requested
658
+ if options[:max_compress]
659
+ working_file = apply_max_compression(working_file)
660
+ end
661
+
662
+ # Step 9: Optionally save individual frames
663
+ if options[:save_frames]
664
+ frames_output_dir = File.join(File.dirname(working_file), "#{File.basename(working_file, '.*')}_frames")
665
+ FileUtils.mkdir_p(frames_output_dir)
666
+ requested_frame_files.each_with_index do |frame_file, idx|
667
+ frame_num = frame_numbers[idx]
668
+ dest = File.join(frames_output_dir, "FR#{format('%03d', frame_num)}_#{spritesheet_basename}.png")
669
+ FileUtils.cp(frame_file, dest)
670
+ end
671
+ Utils::OutputFormatter.indent("Saved #{requested_frame_files.length} frames to: #{frames_output_dir}")
672
+ end
673
+
674
+ Utils::OutputFormatter.header("SUCCESS!")
675
+ Utils::OutputFormatter.success("Extracted spritesheet: #{working_file}")
676
+
677
+ {
678
+ mode: :extract,
679
+ input_file: input_file,
680
+ output_file: working_file,
681
+ frames_extracted: frame_numbers.length,
682
+ columns: columns
683
+ }
684
+ end
685
+
686
+ def execute_add_meta_workflow
687
+ input_file = options[:image]
688
+ rows, columns = options[:add_meta].split(':').map(&:to_i)
689
+
690
+ # Determine frame count
691
+ frame_count = if options[:frame_count]
692
+ options[:frame_count]
693
+ else
694
+ rows * columns
695
+ end
696
+
697
+ Utils::OutputFormatter.header("Adding Metadata")
698
+ Utils::OutputFormatter.indent("Input: #{input_file}")
699
+ Utils::OutputFormatter.indent("Grid: #{rows}×#{columns} (#{frame_count} frames)")
700
+
701
+ # Determine output file
702
+ if options[:output]
703
+ # User specified explicit output
704
+ output_file = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
705
+
706
+ # Copy input to output
707
+ FileUtils.cp(input_file, output_file)
708
+ Utils::OutputFormatter.indent("Copied to: #{output_file}")
709
+ else
710
+ # In-place modification
711
+ if options[:overwrite]
712
+ output_file = input_file
713
+ Utils::OutputFormatter.indent("Modifying in-place (--overwrite specified)")
714
+ else
715
+ # Create unique filename
716
+ output_file = Utils::FileHelper.ensure_unique_output(input_file, overwrite: false)
717
+ FileUtils.cp(input_file, output_file)
718
+ Utils::OutputFormatter.indent("Created: #{output_file}")
719
+ end
720
+ end
721
+
722
+ # Embed metadata
723
+ MetadataManager.embed(output_file, columns, rows, frame_count)
724
+ Utils::OutputFormatter.indent("📝 Metadata embedded: #{columns}×#{rows} grid (#{frame_count} frames)")
725
+
726
+ Utils::OutputFormatter.header("SUCCESS!")
727
+ Utils::OutputFormatter.success("Metadata added to: #{output_file}")
728
+
729
+ {
730
+ mode: :add_meta,
731
+ input_file: input_file,
732
+ output_file: output_file,
733
+ columns: columns,
734
+ rows: rows,
735
+ frames: frame_count
736
+ }
737
+ end
738
+
739
+ def execute_consolidate_workflow
740
+ consolidator = Consolidator.new(options)
741
+
742
+ # Determine file list: either from command line or from directory
743
+ files_to_consolidate = if options[:dir] && !options[:consolidate]
744
+ # Directory-based consolidation
745
+ consolidator.find_spritesheets_in_directory(options[:dir])
746
+ else
747
+ # File list consolidation
748
+ options[:consolidate]
749
+ end
750
+
751
+ # Determine output filename and directory
752
+ if options[:dir] && !options[:consolidate]
753
+ # Directory mode: output to dir or outputdir
754
+ output_dir = options[:outputdir] || options[:dir]
755
+ desired_output = if options[:output]
756
+ File.join(output_dir, File.basename(options[:output]))
757
+ else
758
+ File.join(output_dir, generate_consolidated_filename)
759
+ end
760
+ else
761
+ # File list mode: use current directory behavior
762
+ if options[:outputdir]
763
+ desired_output = File.join(options[:outputdir], options[:output] || generate_consolidated_filename)
764
+ else
765
+ desired_output = options[:output] || generate_consolidated_filename
766
+ end
767
+ end
768
+
769
+ final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
770
+
771
+ result = consolidator.consolidate(files_to_consolidate, final_output)
772
+
773
+ # Apply max compression if requested
774
+ if options[:max_compress]
775
+ final_output = apply_max_compression(result[:output_file])
776
+ result[:output_file] = final_output
777
+ end
778
+
779
+ Utils::OutputFormatter.header("SUCCESS!")
780
+ Utils::OutputFormatter.success("Final output: #{result[:output_file]}")
781
+
782
+ result.merge(mode: :consolidate)
783
+ end
784
+
785
+ def execute_batch_workflow
786
+ batch_processor = BatchProcessor.new(options)
787
+ result = batch_processor.process
788
+
789
+ result.merge(mode: :batch)
790
+ end
791
+
792
+ def process_with_gimp(input_file)
793
+ gimp_options = options.merge(gimp_version: @gimp_version)
794
+ gimp_processor = GimpProcessor.new(@gimp_path, gimp_options)
795
+ gimp_processor.process(input_file)
796
+ end
797
+
798
+ def apply_cell_cleanup(working_file, cleanup_options = {})
799
+ Utils::OutputFormatter.header("CELL CLEANUP")
800
+ Utils::OutputFormatter.indent("Analyzing and removing residual background colors from spritesheet cells...")
801
+
802
+ require_relative 'cell_cleanup_processor'
803
+ cell_processor = CellCleanupProcessor.new(cleanup_options.merge(gimp_path: @gimp_path))
804
+
805
+ # Process the spritesheet
806
+ stats = cell_processor.cleanup_cells(working_file, cleanup_options)
807
+
808
+ Utils::OutputFormatter.success("Cell cleanup complete")
809
+ if stats
810
+ Utils::OutputFormatter.indent("Processed: #{stats[:processed]} cells")
811
+ Utils::OutputFormatter.indent("Cleaned: #{stats[:cleaned]} cells")
812
+ Utils::OutputFormatter.indent("Skipped: #{stats[:skipped]} cells")
813
+ Utils::OutputFormatter.indent("Colors removed: #{stats[:colors_removed]}")
814
+ end
815
+
816
+ # Cell cleanup modifies the file in-place, so return the same path
817
+ working_file
818
+ end
819
+
820
+ def sample_edge_colors(input_file)
821
+ Utils::OutputFormatter.header("EDGE SAMPLING")
822
+ Utils::OutputFormatter.indent("Sampling image edges for background colors...")
823
+
824
+ # Create configuration from options
825
+ config = InnerBgConfig.new(
826
+ edge_sample_interval: options[:edge_sample_interval] || 5,
827
+ edge_sample_depth: options[:edge_sample_depth] || 2
828
+ )
829
+
830
+ # Sample edges and build color palette
831
+ sampler = EdgeSampler.new(input_file, config)
832
+ samples = sampler.sample_edges
833
+ background_palette = sampler.build_color_palette(samples)
834
+
835
+ # Report sampling results
836
+ edge_report = sampler.report
837
+ Utils::OutputFormatter.indent("Samples collected: #{edge_report[:samples_collected]}")
838
+ Utils::OutputFormatter.indent("Unique colors: #{edge_report[:unique_colors]}")
839
+
840
+ if background_palette.empty?
841
+ Utils::OutputFormatter.indent("⚠️ No background colors detected, using fallback")
842
+ background_palette = [{ r: 255, g: 255, b: 255 }]
843
+ end
844
+
845
+ Utils::OutputFormatter.success("Edge sampling complete (#{background_palette.length} color(s))")
846
+ background_palette
847
+ end
848
+
849
+ def process_inner_background_removal(input_file, intermediate_files, background_palette = nil)
850
+ Utils::OutputFormatter.header("INNER BACKGROUND REMOVAL")
851
+ Utils::OutputFormatter.indent("Detecting and removing interior background regions...")
852
+
853
+ # Create configuration from options
854
+ config = InnerBgConfig.new(options)
855
+
856
+ # Validate configuration
857
+ unless config.valid?
858
+ warn "⚠️ Invalid inner background removal configuration. Skipping."
859
+ return input_file
860
+ end
861
+
862
+ # Use provided palette or sample edges (backward compatibility)
863
+ if background_palette.nil?
864
+ Utils::OutputFormatter.indent("Sampling edge colors...")
865
+ sampler = EdgeSampler.new(input_file, config)
866
+ background_palette = sampler.sample_edges.then { |samples| sampler.build_color_palette(samples) }
867
+
868
+ if background_palette.empty?
869
+ Utils::OutputFormatter.indent("⚠️ No background colors detected. Skipping inner removal.")
870
+ return input_file
871
+ end
872
+ end
873
+
874
+ Utils::OutputFormatter.indent("Using #{background_palette.length} background color(s)")
875
+
876
+ # Create output file path (unique to avoid conflicts)
877
+ dir = File.dirname(input_file)
878
+ basename = File.basename(input_file, '.*')
879
+ ext = File.extname(input_file)
880
+ output_file = File.join(dir, "#{basename}_inner_removed#{ext}")
881
+
882
+ # Process inner background removal
883
+ processor = InnerBackgroundProcessor.new(input_file, output_file, config, background_palette)
884
+ processor.process
885
+
886
+ # Display processing report
887
+ report = processor.report
888
+ Utils::OutputFormatter.indent("Regions detected: #{report[:regions_detected]}")
889
+ Utils::OutputFormatter.indent("Regions removed: #{report[:regions_removed]}")
890
+ Utils::OutputFormatter.indent("Processing time: #{report[:processing_time]}s")
891
+
892
+ # Track input file for cleanup if it's an intermediate file
893
+ if input_file != options[:image]
894
+ intermediate_files << input_file unless intermediate_files.include?(input_file)
895
+ end
896
+
897
+ Utils::OutputFormatter.success("Inner background removal complete")
898
+ output_file
899
+ end
900
+
901
+ def process_threshold_stepping(input_file, intermediate_files, background_palette = nil)
902
+ Utils::OutputFormatter.header('Threshold Stepping Background Removal')
903
+
904
+ # Use provided palette or sample edges (backward compatibility)
905
+ if background_palette.nil?
906
+ Utils::OutputFormatter.indent('Sampling image edges for background colors...')
907
+ config = InnerBgConfig.new(
908
+ edge_sample_interval: options[:edge_sample_interval] || 5,
909
+ edge_sample_depth: options[:edge_sample_depth] || 2,
910
+ threshold_stepping: true
911
+ )
912
+
913
+ edge_sampler = EdgeSampler.new(input_file, config)
914
+ samples = edge_sampler.sample_edges
915
+ background_palette = edge_sampler.build_color_palette(samples)
916
+
917
+ # Report edge sampling results
918
+ edge_report = edge_sampler.report
919
+ Utils::OutputFormatter.indent(" Samples collected: #{edge_report[:samples_collected]}")
920
+ Utils::OutputFormatter.indent(" Unique colors: #{edge_report[:unique_colors]}")
921
+
922
+ if background_palette.empty?
923
+ Utils::OutputFormatter.indent('WARNING: No background colors detected, using fallback')
924
+ background_palette = [{ r: 255, g: 255, b: 255 }]
925
+ end
926
+ else
927
+ Utils::OutputFormatter.indent("Using #{background_palette.length} background color(s) from edge sampling")
928
+ end
929
+
930
+ # Create GimpProcessor instance
931
+ gimp_path = Platform.find_gimp
932
+ gimp_version = Platform.get_gimp_version(gimp_path)
933
+ gimp_processor = GimpProcessor.new(gimp_path, options.merge(gimp_version: gimp_version))
934
+
935
+ # Step 3: Create output file path
936
+ dir = File.dirname(input_file)
937
+ basename = File.basename(input_file, '.*')
938
+ ext = File.extname(input_file)
939
+ output_file = File.join(dir, "#{basename}_threshold_stepped#{ext}")
940
+
941
+ # Step 4: Apply threshold stepping with GIMP
942
+ Utils::OutputFormatter.indent('Applying threshold-based removal with GIMP...')
943
+ threshold_options = options.merge(
944
+ threshold_values: options[:threshold_values],
945
+ threshold_timeout: options[:threshold_timeout] || 60,
946
+ total_threshold_timeout: options[:total_threshold_timeout] || 300
947
+ )
948
+
949
+ stepper = ThresholdStepper.new(
950
+ input_file,
951
+ output_file,
952
+ background_palette,
953
+ gimp_processor,
954
+ threshold_options
955
+ )
956
+
957
+ stepper.process
958
+
959
+ # Report results
960
+ report = stepper.report
961
+ Utils::OutputFormatter.indent(" Thresholds processed: #{report[:thresholds_processed]}")
962
+ Utils::OutputFormatter.indent(" Skipped thresholds: #{report[:skipped_thresholds]}") if report[:skipped_thresholds] > 0
963
+ Utils::OutputFormatter.indent(" Processing time: #{report[:total_time]}s")
964
+
965
+ # Track input file for cleanup if it's an intermediate file
966
+ if input_file != options[:image]
967
+ intermediate_files << input_file unless intermediate_files.include?(input_file)
968
+ end
969
+
970
+ Utils::OutputFormatter.success('Threshold stepping complete')
971
+ output_file
972
+ end
973
+
974
+ def process_ghost_edge_cleaning(input_file, intermediate_files)
975
+ Utils::OutputFormatter.header("GHOST EDGE CLEANING")
976
+ Utils::OutputFormatter.indent("Removing semi-transparent ghost pixels...")
977
+
978
+ # Create configuration from options
979
+ config = InnerBgConfig.new(options)
980
+
981
+ # Create output file path
982
+ dir = File.dirname(input_file)
983
+ basename = File.basename(input_file, '.*')
984
+ ext = File.extname(input_file)
985
+ output_file = File.join(dir, "#{basename}_ghost_cleaned#{ext}")
986
+
987
+ # Process ghost edge cleaning
988
+ cleaner = GhostEdgeCleaner.new(input_file, output_file, config)
989
+ cleaner.process
990
+
991
+ # Display processing report
992
+ report = cleaner.report
993
+ Utils::OutputFormatter.indent("Ghost pixels detected: #{report[:ghost_pixels_detected]}")
994
+ Utils::OutputFormatter.indent("Passes performed: #{report[:passes_performed]}")
995
+ Utils::OutputFormatter.indent("Processing time: #{report[:processing_time]}s")
996
+
997
+ # Track input file for cleanup if it's an intermediate file
998
+ if input_file != options[:image]
999
+ intermediate_files << input_file unless intermediate_files.include?(input_file)
1000
+ end
1001
+
1002
+ Utils::OutputFormatter.success("Ghost edge cleaning complete")
1003
+ output_file
1004
+ end
1005
+
1006
+ def process_smoke_detection(input_file, intermediate_files)
1007
+ Utils::OutputFormatter.header("SMOKE DETECTION")
1008
+ Utils::OutputFormatter.indent("Detecting transparency gradients (smoke effects)...")
1009
+
1010
+ # Validate input file exists
1011
+ unless File.exist?(input_file)
1012
+ Utils::OutputFormatter.indent("⚠️ Input file not found. Skipping smoke detection.")
1013
+ return input_file
1014
+ end
1015
+
1016
+ # Create configuration from options
1017
+ config = InnerBgConfig.new(options)
1018
+
1019
+ # Create output file path
1020
+ dir = File.dirname(input_file)
1021
+ basename = File.basename(input_file, '.*')
1022
+ ext = File.extname(input_file)
1023
+ output_file = File.join(dir, "#{basename}_smoke_processed#{ext}")
1024
+
1025
+ # Process smoke detection/removal
1026
+ detector = SmokeDetector.new(input_file, output_file, config)
1027
+ result = detector.process
1028
+
1029
+ # If processing failed, return input file unchanged
1030
+ unless result
1031
+ Utils::OutputFormatter.indent("⚠️ Smoke detection failed. Continuing with previous output.")
1032
+ return input_file
1033
+ end
1034
+
1035
+ # Display processing report
1036
+ report = detector.report
1037
+ Utils::OutputFormatter.indent("Smoke regions detected: #{report[:smoke_detected]}")
1038
+ if report[:smoke_removed]
1039
+ Utils::OutputFormatter.indent("Smoke removal: ENABLED")
1040
+ else
1041
+ Utils::OutputFormatter.indent("Smoke removal: disabled (detection only)")
1042
+ end
1043
+ Utils::OutputFormatter.indent("Processing time: #{report[:processing_time]}s")
1044
+
1045
+ # Track input file for cleanup if it's an intermediate file
1046
+ if input_file != options[:image]
1047
+ intermediate_files << input_file unless intermediate_files.include?(input_file)
1048
+ end
1049
+
1050
+ Utils::OutputFormatter.success("Smoke detection complete")
1051
+ output_file
1052
+ end
1053
+
1054
+ def generate_consolidated_filename
1055
+ "consolidated_spritesheet.png"
1056
+ end
1057
+
1058
+ def split_frames_from_spritesheet(spritesheet_file, columns, rows, frames)
1059
+ # Determine frames directory based on spritesheet filename
1060
+ spritesheet_basename = File.basename(spritesheet_file, '.*')
1061
+ frames_dir = File.join(File.dirname(spritesheet_file), "#{spritesheet_basename}_frames")
1062
+
1063
+ # Split the spritesheet into individual frames
1064
+ splitter = Utils::SpritesheetSplitter.new
1065
+ splitter.split_into_frames(spritesheet_file, frames_dir, columns, rows, frames)
1066
+ end
1067
+
1068
+ def reassemble_frames(frame_files, output_file, columns)
1069
+ # Calculate rows needed for the specified columns
1070
+ total_frames = frame_files.length
1071
+ rows = (total_frames.to_f / columns).ceil
1072
+
1073
+ Utils::OutputFormatter.indent("Layout: #{columns}×#{rows} grid (#{total_frames} frames)")
1074
+
1075
+ # Use ImageMagick montage to create spritesheet
1076
+ # Montage arranges images in a grid
1077
+ cmd = [
1078
+ 'magick',
1079
+ 'montage',
1080
+ frame_files.map { |f| Utils::PathHelper.quote_path(f) }.join(' '),
1081
+ '-tile', "#{columns}x#{rows}",
1082
+ '-geometry', '+0+0', # No spacing between tiles
1083
+ '-background', 'none', # Transparent background
1084
+ Utils::PathHelper.quote_path(output_file)
1085
+ ].join(' ')
1086
+
1087
+ stdout, stderr, status = Open3.capture3(cmd)
1088
+
1089
+ unless status.success?
1090
+ raise ProcessingError, "Failed to reassemble frames: #{stderr}"
1091
+ end
1092
+
1093
+ # Embed metadata in the reassembled spritesheet
1094
+ MetadataManager.embed(output_file, columns, rows, total_frames)
1095
+
1096
+ Utils::OutputFormatter.indent("✅ Reassembled into #{columns}×#{rows} spritesheet")
1097
+ Utils::OutputFormatter.indent("📝 Metadata embedded: #{columns}×#{rows} grid (#{total_frames} frames)")
1098
+ end
1099
+
1100
+ def collect_intermediate_files(initial_file, final_file)
1101
+ # Find all files that were created during GIMP processing
1102
+ # Pattern: initial_file + suffixes like -nobg-fuzzy, -scaled-40pct, etc.
1103
+ # Note: output_filename uses DASH separator, not underscore
1104
+ dir = File.dirname(initial_file)
1105
+ basename = File.basename(initial_file, '.*')
1106
+ ext = File.extname(initial_file)
1107
+
1108
+ # Get all PNG files in the directory that start with the basename and have a dash
1109
+ pattern = File.join(dir, "#{basename}-*#{ext}")
1110
+ intermediate_files = Dir.glob(pattern)
1111
+
1112
+ # Normalize paths for comparison (Windows compatibility)
1113
+ initial_normalized = File.expand_path(initial_file)
1114
+ final_normalized = File.expand_path(final_file)
1115
+
1116
+ # Exclude the initial and final files
1117
+ intermediate_files.reject do |f|
1118
+ f_normalized = File.expand_path(f)
1119
+ f_normalized == initial_normalized || f_normalized == final_normalized
1120
+ end
1121
+ end
1122
+
1123
+ def cleanup_intermediate_files(files)
1124
+ return if files.empty?
1125
+
1126
+ if options[:debug]
1127
+ Utils::OutputFormatter.note("Cleaning up #{files.length} intermediate file(s):")
1128
+ end
1129
+
1130
+ files.each do |file|
1131
+ if File.exist?(file)
1132
+ File.delete(file)
1133
+ if options[:debug]
1134
+ Utils::OutputFormatter.indent("Deleted: #{File.basename(file)}")
1135
+ end
1136
+ end
1137
+ end
1138
+ end
1139
+
1140
+ def cleanup
1141
+ if options[:temp_dir] && Dir.exist?(options[:temp_dir])
1142
+ FileUtils.rm_rf(options[:temp_dir])
1143
+ Utils::OutputFormatter.note("Cleaned up temporary files") if options[:debug]
1144
+ end
1145
+ end
1146
+
1147
+ def determine_split_parameters(image_file)
1148
+ metadata = MetadataManager.read(image_file)
1149
+
1150
+ # Check if we have metadata
1151
+ if metadata && metadata[:columns] && metadata[:rows] && metadata[:frames]
1152
+ # Metadata exists
1153
+ if options[:split] && !options[:override_md]
1154
+ # Warn user that split values will be ignored
1155
+ Utils::OutputFormatter.note("Image has metadata (#{metadata[:rows]}×#{metadata[:columns]}). Your --split values will be ignored. Use --override-md to override.")
1156
+ return [metadata[:rows], metadata[:columns], metadata[:frames]]
1157
+ elsif options[:split] && options[:override_md]
1158
+ # Use user's split values
1159
+ frames = @split_rows * @split_columns
1160
+ validate_image_dimensions(image_file, @split_rows, @split_columns)
1161
+ return [@split_rows, @split_columns, frames]
1162
+ else
1163
+ # Use metadata
1164
+ return [metadata[:rows], metadata[:columns], metadata[:frames]]
1165
+ end
1166
+ else
1167
+ # No metadata
1168
+ if options[:split]
1169
+ # Use user's split values
1170
+ frames = @split_rows * @split_columns
1171
+ validate_image_dimensions(image_file, @split_rows, @split_columns)
1172
+ return [@split_rows, @split_columns, frames]
1173
+ else
1174
+ # Error: no metadata and no split option
1175
+ raise ValidationError, "Image has no metadata. Please provide --split R:C"
1176
+ end
1177
+ end
1178
+ end
1179
+
1180
+ def validate_image_dimensions(image_file, rows, columns)
1181
+ # Get image dimensions using ImageMagick
1182
+ cmd = [
1183
+ 'magick',
1184
+ 'identify',
1185
+ '-format', '%wx%h',
1186
+ Utils::PathHelper.quote_path(image_file)
1187
+ ].join(' ')
1188
+
1189
+ stdout, stderr, status = Open3.capture3(cmd)
1190
+
1191
+ unless status.success?
1192
+ raise ProcessingError, "Could not get image dimensions: #{stderr}"
1193
+ end
1194
+
1195
+ width, height = stdout.strip.split('x').map(&:to_i)
1196
+
1197
+ # Check if dimensions divide evenly
1198
+ unless width % columns == 0
1199
+ raise ValidationError, "Image width (#{width}) not evenly divisible by #{columns} columns"
1200
+ end
1201
+
1202
+ unless height % rows == 0
1203
+ raise ValidationError, "Image height (#{height}) not evenly divisible by #{rows} rows"
1204
+ end
1205
+ end
1206
+
1207
+ def apply_max_compression(file)
1208
+ Utils::OutputFormatter.note("Applying maximum compression...")
1209
+
1210
+ original_size = File.size(file)
1211
+ temp_file = file.gsub('.png', '_compressed_temp.png')
1212
+
1213
+ CompressionManager.compress_with_metadata(file, temp_file, debug: options[:debug])
1214
+
1215
+ # Show compression stats
1216
+ stats = CompressionManager.compression_stats(file, temp_file)
1217
+
1218
+ if options[:debug] || stats[:saved_bytes] > 0
1219
+ Utils::OutputFormatter.indent("Original: #{Utils::FileHelper.format_size(stats[:original_size])}")
1220
+ Utils::OutputFormatter.indent("Compressed: #{Utils::FileHelper.format_size(stats[:compressed_size])}")
1221
+ Utils::OutputFormatter.indent("Saved: #{Utils::FileHelper.format_size(stats[:saved_bytes])} (#{stats[:reduction_percent].round(1)}% reduction)")
1222
+ end
1223
+
1224
+ # Replace original with compressed
1225
+ FileUtils.mv(temp_file, file)
1226
+
1227
+ file
1228
+ end
1229
+ end
1230
+ end