ruby_spriter 0.6.7.1 → 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 -524
  4. data/Gemfile +17 -17
  5. data/LICENSE +21 -21
  6. data/README.md +183 -950
  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 -214
  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 -224
  17. data/lib/ruby_spriter/ghost_edge_cleaner.rb +164 -0
  18. data/lib/ruby_spriter/gimp_processor.rb +1188 -1058
  19. data/lib/ruby_spriter/metadata_manager.rb +117 -116
  20. data/lib/ruby_spriter/platform.rb +137 -137
  21. data/lib/ruby_spriter/processor.rb +1230 -891
  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 -92
  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,891 +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
- @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
- def default_options
51
- {
52
- video: nil,
53
- image: nil,
54
- consolidate: nil,
55
- verify: nil,
56
- output: nil,
57
- frame_count: 16,
58
- columns: 4,
59
- max_width: 320,
60
- padding: 0,
61
- bg_color: 'black',
62
- scale_percent: nil,
63
- scale_interpolation: 'nohalo',
64
- sharpen: false,
65
- sharpen_radius: 2.0,
66
- sharpen_gain: 0.5,
67
- sharpen_threshold: 0.03,
68
- remove_bg: false,
69
- bg_threshold: 0.0,
70
- grow_selection: 1,
71
- fuzzy_select: true,
72
- operation_order: :scale_then_remove_bg,
73
- validate_columns: true,
74
- temp_dir: nil,
75
- keep_temp: false,
76
- debug: false,
77
- overwrite: false,
78
- save_frames: false,
79
- split: nil,
80
- override_md: false,
81
- extract: nil,
82
- add_meta: nil,
83
- overwrite_meta: false
84
- }
85
- end
86
-
87
- def validate_options!
88
- input_modes = [options[:video], options[:image], options[:consolidate_mode], options[:verify], options[:batch]].compact
89
-
90
- if input_modes.empty?
91
- raise ValidationError, "Must specify --video, --image, --consolidate, --verify, or --batch"
92
- end
93
-
94
- if input_modes.length > 1
95
- raise ValidationError, "Cannot use multiple input modes together. Choose one."
96
- end
97
-
98
- validate_consolidate_options!
99
- validate_input_files!
100
- validate_numeric_options!
101
- validate_split_option!
102
- validate_extract_option!
103
- validate_add_meta_option!
104
- end
105
-
106
- def validate_consolidate_options!
107
- return unless options[:consolidate_mode]
108
-
109
- # Check for mutual exclusivity between file list and directory
110
- if options[:consolidate] && options[:dir]
111
- raise ValidationError, "Cannot use --dir with comma-separated file list for --consolidate. Choose one method."
112
- end
113
-
114
- # Require either file list or directory
115
- unless options[:consolidate] || options[:dir]
116
- raise ValidationError, "--consolidate requires either comma-separated files or --dir option"
117
- end
118
-
119
- # Validate directory if using directory mode
120
- if options[:dir] && !options[:consolidate]
121
- unless File.directory?(options[:dir])
122
- raise ValidationError, "Directory not found: #{options[:dir]}"
123
- end
124
- end
125
- end
126
-
127
- def validate_input_files!
128
- if options[:video]
129
- Utils::FileHelper.validate_exists!(options[:video])
130
- validate_file_extension!(options[:video], ['.mp4'], '--video')
131
- end
132
-
133
- if options[:image]
134
- Utils::FileHelper.validate_exists!(options[:image])
135
- validate_file_extension!(options[:image], ['.png'], '--image')
136
- end
137
-
138
- if options[:consolidate]
139
- if options[:consolidate].length < 2
140
- raise ValidationError, "--consolidate requires at least 2 files"
141
- end
142
-
143
- options[:consolidate].each do |file|
144
- Utils::FileHelper.validate_exists!(file)
145
- validate_file_extension!(file, ['.png'], '--consolidate')
146
- end
147
- end
148
-
149
- if options[:verify]
150
- Utils::FileHelper.validate_exists!(options[:verify])
151
- validate_file_extension!(options[:verify], ['.png'], '--verify')
152
- end
153
- end
154
-
155
- def validate_file_extension!(file_path, valid_extensions, flag_name)
156
- ext = File.extname(file_path).downcase
157
- unless valid_extensions.include?(ext)
158
- expected = valid_extensions.join(', ')
159
- raise ValidationError, "#{flag_name} expects #{expected} file, got: #{ext || '(no extension)'}"
160
- end
161
- end
162
-
163
- def validate_numeric_options!
164
- VALID_RANGES.each do |option_name, range_config|
165
- value = options[option_name]
166
-
167
- # Skip validation if option is not set (nil)
168
- next if value.nil?
169
-
170
- min = range_config[:min]
171
- max = range_config[:max]
172
-
173
- # Validate that value is within range
174
- if value < min || value > max
175
- raise ValidationError, "#{option_name} must be between #{min} and #{max}, got: #{value}"
176
- end
177
- end
178
- end
179
-
180
- def validate_split_option!
181
- return unless options[:split]
182
-
183
- # Parse split format: R:C
184
- unless options[:split] =~ /^\d+:\d+$/
185
- raise ValidationError, "Invalid --split format. Use R:C (e.g., 4:4)"
186
- end
187
-
188
- rows, columns = options[:split].split(':').map(&:to_i)
189
-
190
- # Validate ranges
191
- if rows < 1 || rows > 99
192
- raise ValidationError, "rows must be between 1 and 99, got: #{rows}"
193
- end
194
-
195
- if columns < 1 || columns > 99
196
- raise ValidationError, "columns must be between 1 and 99, got: #{columns}"
197
- end
198
-
199
- # Validate total frames < 1000
200
- total_frames = rows * columns
201
- if total_frames >= 1000
202
- raise ValidationError, "Total frames (#{total_frames}) must be less than 1000"
203
- end
204
-
205
- # Store parsed values for later use
206
- @split_rows = rows
207
- @split_columns = columns
208
- end
209
-
210
- def validate_extract_option!
211
- return unless options[:extract]
212
-
213
- # Parse extract format: comma-separated integers (allow negatives for better error messages)
214
- unless options[:extract] =~ /^-?\d+(,-?\d+)*$/
215
- raise ValidationError, "Invalid --extract format. Use comma-separated frame numbers (e.g., 1,2,4,5,8)"
216
- end
217
-
218
- # Parse frame numbers
219
- frame_numbers = options[:extract].split(',').map(&:to_i)
220
-
221
- # Validate minimum 2 frames
222
- if frame_numbers.length < 2
223
- raise ValidationError, "--extract requires at least 2 frames, got: #{frame_numbers.length}"
224
- end
225
-
226
- # Validate frame numbers are 1-indexed (no 0 or negative)
227
- invalid_frames = frame_numbers.select { |n| n <= 0 }
228
- if invalid_frames.any?
229
- raise ValidationError, "Frame numbers must be 1-indexed (positive integers), got invalid: #{invalid_frames.join(', ')}"
230
- end
231
-
232
- # Check for metadata (required for extraction)
233
- return unless options[:image] # Only validate bounds if we have an image path
234
-
235
- image_file = options[:image]
236
- metadata = MetadataManager.read(image_file)
237
-
238
- unless metadata
239
- raise ValidationError, "Image has no metadata. Cannot extract frames without knowing the grid layout. Use --add-meta first."
240
- end
241
-
242
- # Validate frame numbers are within bounds
243
- total_frames = metadata[:frames]
244
- out_of_bounds = frame_numbers.select { |n| n > total_frames }
245
- if out_of_bounds.any?
246
- first_oob = out_of_bounds.first
247
- raise ValidationError, "Frame #{first_oob} is out of bounds (image only has #{total_frames} frames)"
248
- end
249
-
250
- # Set default columns if not specified
251
- options[:columns] ||= 4
252
- end
253
-
254
- def validate_add_meta_option!
255
- return unless options[:add_meta]
256
-
257
- # Parse add-meta format: R:C
258
- unless options[:add_meta] =~ /^\d+:\d+$/
259
- raise ValidationError, "Invalid --add-meta format. Use R:C (e.g., 4:4)"
260
- end
261
-
262
- rows, columns = options[:add_meta].split(':').map(&:to_i)
263
-
264
- # Validate ranges
265
- if rows < 1 || rows > 99
266
- raise ValidationError, "rows must be between 1 and 99, got: #{rows}"
267
- end
268
-
269
- if columns < 1 || columns > 99
270
- raise ValidationError, "columns must be between 1 and 99, got: #{columns}"
271
- end
272
-
273
- # Validate total frames < 1000
274
- total_frames = rows * columns
275
- if total_frames >= 1000
276
- raise ValidationError, "Total frames (#{total_frames}) must be less than 1000"
277
- end
278
-
279
- # Check if we need to validate against image file
280
- return unless options[:image]
281
-
282
- image_file = options[:image]
283
- metadata = MetadataManager.read(image_file)
284
-
285
- # Check for existing metadata
286
- if metadata && !options[:overwrite_meta]
287
- raise ValidationError, "Image already has spritesheet metadata. Use --overwrite-meta to replace it."
288
- end
289
-
290
- # Validate image dimensions divide evenly by grid
291
- dimensions = get_image_dimensions(image_file)
292
- tile_width = dimensions[:width] / columns.to_f
293
- tile_height = dimensions[:height] / rows.to_f
294
-
295
- unless tile_width == tile_width.to_i && tile_height == tile_height.to_i
296
- raise ValidationError, "Image dimensions (#{dimensions[:width]}x#{dimensions[:height]}) must divide evenly by grid (#{rows}x#{columns}). Expected frame size: #{tile_width}x#{tile_height}"
297
- end
298
-
299
- # Validate custom frame count doesn't exceed grid size
300
- if options[:frame_count] && options[:frame_count] > total_frames
301
- raise ValidationError, "Frame count (#{options[:frame_count]}) exceeds grid size (#{total_frames})"
302
- end
303
- end
304
-
305
- def get_image_dimensions(image_file)
306
- cmd = [
307
- 'magick',
308
- 'identify',
309
- '-format', '%wx%h',
310
- Utils::PathHelper.quote_path(image_file)
311
- ].join(' ')
312
-
313
- stdout, stderr, status = Open3.capture3(cmd)
314
-
315
- unless status.success?
316
- raise ProcessingError, "Could not get image dimensions: #{stderr}"
317
- end
318
-
319
- width, height = stdout.strip.split('x').map(&:to_i)
320
- { width: width, height: height }
321
- end
322
-
323
- def check_dependencies!
324
- checker = DependencyChecker.new(verbose: options[:debug])
325
- results = checker.check_all
326
-
327
- # Check required tools
328
- missing = []
329
-
330
- [:ffmpeg, :ffprobe, :imagemagick].each do |tool|
331
- missing << tool unless results[tool][:available]
332
- end
333
-
334
- # GIMP only needed for scaling and background removal (not for sharpen-only)
335
- if needs_gimp_specifically? && !results[:gimp][:available]
336
- missing << :gimp
337
- end
338
-
339
- if missing.any?
340
- checker.print_report
341
- raise DependencyError, "Missing required dependencies: #{missing.join(', ')}"
342
- end
343
-
344
- if results[:gimp][:available]
345
- @gimp_path = checker.gimp_path
346
- @gimp_version = checker.gimp_version
347
- end
348
-
349
- if options[:debug]
350
- checker.print_report
351
- end
352
- end
353
-
354
- def needs_gimp?
355
- options[:scale_percent] || options[:remove_bg] || options[:sharpen]
356
- end
357
-
358
- def needs_gimp_specifically?
359
- options[:scale_percent] || options[:remove_bg]
360
- end
361
-
362
- def setup_temp_directory
363
- @options[:temp_dir] = Dir.mktmpdir('ruby_spriter_')
364
-
365
- if options[:debug]
366
- Utils::OutputFormatter.indent("Temp directory: #{options[:temp_dir]}")
367
- end
368
- end
369
-
370
- def execute_workflow
371
- Utils::OutputFormatter.header("Ruby Spriter v#{VERSION}")
372
- puts "Platform: #{Platform.current.to_s.capitalize}"
373
- puts "Date: #{VERSION_DATE}\n\n"
374
-
375
- if options[:verify]
376
- MetadataManager.verify(options[:verify])
377
- return { mode: :verify, file: options[:verify] }
378
- end
379
-
380
- if options[:batch]
381
- return execute_batch_workflow
382
- elsif options[:consolidate_mode]
383
- return execute_consolidate_workflow
384
- elsif options[:image]
385
- return execute_image_workflow
386
- else
387
- return execute_video_workflow
388
- end
389
- end
390
-
391
- def execute_video_workflow
392
- # Step 1: Determine output filename
393
- desired_output = options[:output] || Utils::FileHelper.spritesheet_filename(options[:video])
394
- final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
395
-
396
- # Step 2: Convert video to spritesheet
397
- video_processor = VideoProcessor.new(options)
398
- result = video_processor.create_spritesheet(
399
- options[:video],
400
- final_output
401
- )
402
-
403
- working_file = result[:output_file]
404
- intermediate_files = []
405
-
406
- # Step 3: Apply GIMP processing if requested
407
- if needs_gimp?
408
- initial_file = working_file
409
- working_file = process_with_gimp(working_file)
410
-
411
- # Track intermediate files for cleanup (everything except initial and final)
412
- if working_file != initial_file
413
- intermediate_files = collect_intermediate_files(initial_file, working_file)
414
- end
415
- end
416
-
417
- # Step 4: Move to final output location if different
418
- if final_output != working_file
419
- FileUtils.cp(working_file, final_output)
420
- # Add the GIMP output to intermediates if it's different from final
421
- intermediate_files << working_file unless intermediate_files.include?(working_file)
422
- working_file = final_output
423
- end
424
-
425
- # Step 5: Clean up intermediate files
426
- cleanup_intermediate_files(intermediate_files)
427
-
428
- # Step 6: Apply max compression if requested
429
- if options[:max_compress]
430
- working_file = apply_max_compression(working_file)
431
- end
432
-
433
- # Step 7: Extract individual frames if requested
434
- if options[:save_frames]
435
- split_frames_from_spritesheet(working_file, result[:columns], result[:rows], result[:frames])
436
- end
437
-
438
- Utils::OutputFormatter.header("SUCCESS!")
439
- Utils::OutputFormatter.success("Final output: #{working_file}")
440
-
441
- result.merge(final_output: working_file)
442
- end
443
-
444
- def execute_image_workflow
445
- working_file = options[:image]
446
- intermediate_files = []
447
-
448
- # Handle metadata addition workflow first
449
- if options[:add_meta]
450
- return execute_add_meta_workflow
451
- end
452
-
453
- # Handle frame extraction workflow
454
- if options[:extract]
455
- return execute_extract_workflow
456
- end
457
-
458
- # Apply GIMP processing if requested (GimpProcessor handles uniqueness)
459
- if needs_gimp?
460
- initial_file = working_file
461
- working_file = process_with_gimp(working_file)
462
-
463
- # Track intermediate files for cleanup (everything except initial and final)
464
- if working_file != initial_file
465
- intermediate_files = collect_intermediate_files(initial_file, working_file)
466
- end
467
- end
468
-
469
- # Move to final output location if user specified explicit --output
470
- if options[:output]
471
- final_output = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
472
- if working_file != final_output
473
- FileUtils.cp(working_file, final_output)
474
- # Add the GIMP output to intermediates if it's different from final
475
- intermediate_files << working_file unless intermediate_files.include?(working_file)
476
- working_file = final_output
477
- end
478
- end
479
-
480
- # Clean up intermediate files
481
- cleanup_intermediate_files(intermediate_files)
482
-
483
- # Apply max compression if requested
484
- if options[:max_compress]
485
- working_file = apply_max_compression(working_file)
486
- end
487
-
488
- # Determine if we should split the image into frames
489
- should_split = options[:save_frames] || options[:split]
490
-
491
- if should_split
492
- # Determine rows, columns, and frames to use
493
- rows, columns, frames = determine_split_parameters(working_file)
494
-
495
- # Split the image into frames
496
- split_frames_from_spritesheet(working_file, columns, rows, frames)
497
- end
498
-
499
- Utils::OutputFormatter.header("SUCCESS!")
500
- Utils::OutputFormatter.success("Final output: #{working_file}")
501
-
502
- {
503
- mode: :image,
504
- input_file: options[:image],
505
- output_file: working_file
506
- }
507
- end
508
-
509
- def execute_extract_workflow
510
- input_file = options[:image]
511
- metadata = MetadataManager.read(input_file)
512
- frame_numbers = options[:extract].split(',').map(&:to_i)
513
- columns = options[:columns]
514
-
515
- Utils::OutputFormatter.header("Frame Extraction")
516
- Utils::OutputFormatter.indent("Input: #{input_file}")
517
- Utils::OutputFormatter.indent("Frames to extract: #{frame_numbers.join(', ')}")
518
- Utils::OutputFormatter.indent("Output columns: #{columns}")
519
-
520
- # Step 1: Extract all frames to temp directory
521
- temp_frames_dir = File.join(options[:temp_dir], 'extracted_frames')
522
- splitter = Utils::SpritesheetSplitter.new
523
- splitter.split_into_frames(input_file, temp_frames_dir, metadata[:columns], metadata[:rows], metadata[:frames])
524
-
525
- # Step 2: Keep only requested frames, delete the rest
526
- spritesheet_basename = File.basename(input_file, '.*')
527
- all_frame_files = Dir.glob(File.join(temp_frames_dir, "FR*_#{spritesheet_basename}.png")).sort
528
- requested_frame_files = frame_numbers.map do |frame_num|
529
- # Frame files are named FR001, FR002, etc. (1-indexed)
530
- File.join(temp_frames_dir, "FR#{format('%03d', frame_num)}_#{spritesheet_basename}.png")
531
- end
532
-
533
- # Delete unwanted frames
534
- (all_frame_files - requested_frame_files).each { |f| FileUtils.rm_f(f) }
535
-
536
- Utils::OutputFormatter.indent("Kept #{requested_frame_files.length} frames, deleted #{all_frame_files.length - requested_frame_files.length} frames")
537
-
538
- # Step 3: Reassemble into new spritesheet
539
- Utils::OutputFormatter.header("Reassembling Spritesheet")
540
- reassembled_file = File.join(options[:temp_dir], "reassembled_#{spritesheet_basename}.png")
541
- reassemble_frames(requested_frame_files, reassembled_file, columns)
542
-
543
- working_file = reassembled_file
544
- intermediate_files = []
545
-
546
- # Step 4: Apply GIMP processing if requested
547
- if needs_gimp?
548
- initial_file = working_file
549
- working_file = process_with_gimp(working_file)
550
-
551
- if working_file != initial_file
552
- intermediate_files = collect_intermediate_files(initial_file, working_file)
553
- end
554
- end
555
-
556
- # Step 5: Determine final output filename
557
- if options[:output]
558
- final_output = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
559
- else
560
- # Auto-generate output filename with _extracted suffix
561
- base = File.basename(input_file, '.*')
562
- ext = File.extname(input_file)
563
- desired_output = File.join(File.dirname(input_file), "#{base}_extracted#{ext}")
564
- final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
565
- end
566
-
567
- # Step 6: Copy to final output
568
- FileUtils.cp(working_file, final_output)
569
- working_file = final_output
570
-
571
- # Step 7: Clean up intermediate files
572
- cleanup_intermediate_files(intermediate_files)
573
-
574
- # Step 8: Apply max compression if requested
575
- if options[:max_compress]
576
- working_file = apply_max_compression(working_file)
577
- end
578
-
579
- # Step 9: Optionally save individual frames
580
- if options[:save_frames]
581
- frames_output_dir = File.join(File.dirname(working_file), "#{File.basename(working_file, '.*')}_frames")
582
- FileUtils.mkdir_p(frames_output_dir)
583
- requested_frame_files.each_with_index do |frame_file, idx|
584
- frame_num = frame_numbers[idx]
585
- dest = File.join(frames_output_dir, "FR#{format('%03d', frame_num)}_#{spritesheet_basename}.png")
586
- FileUtils.cp(frame_file, dest)
587
- end
588
- Utils::OutputFormatter.indent("Saved #{requested_frame_files.length} frames to: #{frames_output_dir}")
589
- end
590
-
591
- Utils::OutputFormatter.header("SUCCESS!")
592
- Utils::OutputFormatter.success("Extracted spritesheet: #{working_file}")
593
-
594
- {
595
- mode: :extract,
596
- input_file: input_file,
597
- output_file: working_file,
598
- frames_extracted: frame_numbers.length,
599
- columns: columns
600
- }
601
- end
602
-
603
- def execute_add_meta_workflow
604
- input_file = options[:image]
605
- rows, columns = options[:add_meta].split(':').map(&:to_i)
606
-
607
- # Determine frame count
608
- frame_count = if options[:frame_count]
609
- options[:frame_count]
610
- else
611
- rows * columns
612
- end
613
-
614
- Utils::OutputFormatter.header("Adding Metadata")
615
- Utils::OutputFormatter.indent("Input: #{input_file}")
616
- Utils::OutputFormatter.indent("Grid: #{rows}×#{columns} (#{frame_count} frames)")
617
-
618
- # Determine output file
619
- if options[:output]
620
- # User specified explicit output
621
- output_file = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
622
-
623
- # Copy input to output
624
- FileUtils.cp(input_file, output_file)
625
- Utils::OutputFormatter.indent("Copied to: #{output_file}")
626
- else
627
- # In-place modification
628
- if options[:overwrite]
629
- output_file = input_file
630
- Utils::OutputFormatter.indent("Modifying in-place (--overwrite specified)")
631
- else
632
- # Create unique filename
633
- output_file = Utils::FileHelper.ensure_unique_output(input_file, overwrite: false)
634
- FileUtils.cp(input_file, output_file)
635
- Utils::OutputFormatter.indent("Created: #{output_file}")
636
- end
637
- end
638
-
639
- # Embed metadata
640
- MetadataManager.embed(output_file, columns, rows, frame_count)
641
- Utils::OutputFormatter.indent("📝 Metadata embedded: #{columns}×#{rows} grid (#{frame_count} frames)")
642
-
643
- Utils::OutputFormatter.header("SUCCESS!")
644
- Utils::OutputFormatter.success("Metadata added to: #{output_file}")
645
-
646
- {
647
- mode: :add_meta,
648
- input_file: input_file,
649
- output_file: output_file,
650
- columns: columns,
651
- rows: rows,
652
- frames: frame_count
653
- }
654
- end
655
-
656
- def execute_consolidate_workflow
657
- consolidator = Consolidator.new(options)
658
-
659
- # Determine file list: either from command line or from directory
660
- files_to_consolidate = if options[:dir] && !options[:consolidate]
661
- # Directory-based consolidation
662
- consolidator.find_spritesheets_in_directory(options[:dir])
663
- else
664
- # File list consolidation
665
- options[:consolidate]
666
- end
667
-
668
- # Determine output filename and directory
669
- if options[:dir] && !options[:consolidate]
670
- # Directory mode: output to dir or outputdir
671
- output_dir = options[:outputdir] || options[:dir]
672
- desired_output = if options[:output]
673
- File.join(output_dir, File.basename(options[:output]))
674
- else
675
- File.join(output_dir, generate_consolidated_filename)
676
- end
677
- else
678
- # File list mode: use current directory behavior
679
- if options[:outputdir]
680
- desired_output = File.join(options[:outputdir], options[:output] || generate_consolidated_filename)
681
- else
682
- desired_output = options[:output] || generate_consolidated_filename
683
- end
684
- end
685
-
686
- final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
687
-
688
- result = consolidator.consolidate(files_to_consolidate, final_output)
689
-
690
- # Apply max compression if requested
691
- if options[:max_compress]
692
- final_output = apply_max_compression(result[:output_file])
693
- result[:output_file] = final_output
694
- end
695
-
696
- Utils::OutputFormatter.header("SUCCESS!")
697
- Utils::OutputFormatter.success("Final output: #{result[:output_file]}")
698
-
699
- result.merge(mode: :consolidate)
700
- end
701
-
702
- def execute_batch_workflow
703
- batch_processor = BatchProcessor.new(options)
704
- result = batch_processor.process
705
-
706
- result.merge(mode: :batch)
707
- end
708
-
709
- def process_with_gimp(input_file)
710
- gimp_options = options.merge(gimp_version: @gimp_version)
711
- gimp_processor = GimpProcessor.new(@gimp_path, gimp_options)
712
- gimp_processor.process(input_file)
713
- end
714
-
715
- def generate_consolidated_filename
716
- "consolidated_spritesheet.png"
717
- end
718
-
719
- def split_frames_from_spritesheet(spritesheet_file, columns, rows, frames)
720
- # Determine frames directory based on spritesheet filename
721
- spritesheet_basename = File.basename(spritesheet_file, '.*')
722
- frames_dir = File.join(File.dirname(spritesheet_file), "#{spritesheet_basename}_frames")
723
-
724
- # Split the spritesheet into individual frames
725
- splitter = Utils::SpritesheetSplitter.new
726
- splitter.split_into_frames(spritesheet_file, frames_dir, columns, rows, frames)
727
- end
728
-
729
- def reassemble_frames(frame_files, output_file, columns)
730
- # Calculate rows needed for the specified columns
731
- total_frames = frame_files.length
732
- rows = (total_frames.to_f / columns).ceil
733
-
734
- Utils::OutputFormatter.indent("Layout: #{columns}×#{rows} grid (#{total_frames} frames)")
735
-
736
- # Use ImageMagick montage to create spritesheet
737
- # Montage arranges images in a grid
738
- cmd = [
739
- 'magick',
740
- 'montage',
741
- frame_files.map { |f| Utils::PathHelper.quote_path(f) }.join(' '),
742
- '-tile', "#{columns}x#{rows}",
743
- '-geometry', '+0+0', # No spacing between tiles
744
- '-background', 'none', # Transparent background
745
- Utils::PathHelper.quote_path(output_file)
746
- ].join(' ')
747
-
748
- stdout, stderr, status = Open3.capture3(cmd)
749
-
750
- unless status.success?
751
- raise ProcessingError, "Failed to reassemble frames: #{stderr}"
752
- end
753
-
754
- # Embed metadata in the reassembled spritesheet
755
- MetadataManager.embed(output_file, columns, rows, total_frames)
756
-
757
- Utils::OutputFormatter.indent("✅ Reassembled into #{columns}×#{rows} spritesheet")
758
- Utils::OutputFormatter.indent("📝 Metadata embedded: #{columns}×#{rows} grid (#{total_frames} frames)")
759
- end
760
-
761
- def collect_intermediate_files(initial_file, final_file)
762
- # Find all files that were created during GIMP processing
763
- # Pattern: initial_file + suffixes like -nobg-fuzzy, -scaled-40pct, etc.
764
- # Note: output_filename uses DASH separator, not underscore
765
- dir = File.dirname(initial_file)
766
- basename = File.basename(initial_file, '.*')
767
- ext = File.extname(initial_file)
768
-
769
- # Get all PNG files in the directory that start with the basename and have a dash
770
- pattern = File.join(dir, "#{basename}-*#{ext}")
771
- intermediate_files = Dir.glob(pattern)
772
-
773
- # Normalize paths for comparison (Windows compatibility)
774
- initial_normalized = File.expand_path(initial_file)
775
- final_normalized = File.expand_path(final_file)
776
-
777
- # Exclude the initial and final files
778
- intermediate_files.reject do |f|
779
- f_normalized = File.expand_path(f)
780
- f_normalized == initial_normalized || f_normalized == final_normalized
781
- end
782
- end
783
-
784
- def cleanup_intermediate_files(files)
785
- return if files.empty?
786
-
787
- if options[:debug]
788
- Utils::OutputFormatter.note("Cleaning up #{files.length} intermediate file(s):")
789
- end
790
-
791
- files.each do |file|
792
- if File.exist?(file)
793
- File.delete(file)
794
- if options[:debug]
795
- Utils::OutputFormatter.indent("Deleted: #{File.basename(file)}")
796
- end
797
- end
798
- end
799
- end
800
-
801
- def cleanup
802
- if options[:temp_dir] && Dir.exist?(options[:temp_dir])
803
- FileUtils.rm_rf(options[:temp_dir])
804
- Utils::OutputFormatter.note("Cleaned up temporary files") if options[:debug]
805
- end
806
- end
807
-
808
- def determine_split_parameters(image_file)
809
- metadata = MetadataManager.read(image_file)
810
-
811
- # Check if we have metadata
812
- if metadata && metadata[:columns] && metadata[:rows] && metadata[:frames]
813
- # Metadata exists
814
- if options[:split] && !options[:override_md]
815
- # Warn user that split values will be ignored
816
- Utils::OutputFormatter.note("Image has metadata (#{metadata[:rows]}×#{metadata[:columns]}). Your --split values will be ignored. Use --override-md to override.")
817
- return [metadata[:rows], metadata[:columns], metadata[:frames]]
818
- elsif options[:split] && options[:override_md]
819
- # Use user's split values
820
- frames = @split_rows * @split_columns
821
- validate_image_dimensions(image_file, @split_rows, @split_columns)
822
- return [@split_rows, @split_columns, frames]
823
- else
824
- # Use metadata
825
- return [metadata[:rows], metadata[:columns], metadata[:frames]]
826
- end
827
- else
828
- # No metadata
829
- if options[:split]
830
- # Use user's split values
831
- frames = @split_rows * @split_columns
832
- validate_image_dimensions(image_file, @split_rows, @split_columns)
833
- return [@split_rows, @split_columns, frames]
834
- else
835
- # Error: no metadata and no split option
836
- raise ValidationError, "Image has no metadata. Please provide --split R:C"
837
- end
838
- end
839
- end
840
-
841
- def validate_image_dimensions(image_file, rows, columns)
842
- # Get image dimensions using ImageMagick
843
- cmd = [
844
- 'magick',
845
- 'identify',
846
- '-format', '%wx%h',
847
- Utils::PathHelper.quote_path(image_file)
848
- ].join(' ')
849
-
850
- stdout, stderr, status = Open3.capture3(cmd)
851
-
852
- unless status.success?
853
- raise ProcessingError, "Could not get image dimensions: #{stderr}"
854
- end
855
-
856
- width, height = stdout.strip.split('x').map(&:to_i)
857
-
858
- # Check if dimensions divide evenly
859
- unless width % columns == 0
860
- raise ValidationError, "Image width (#{width}) not evenly divisible by #{columns} columns"
861
- end
862
-
863
- unless height % rows == 0
864
- raise ValidationError, "Image height (#{height}) not evenly divisible by #{rows} rows"
865
- end
866
- end
867
-
868
- def apply_max_compression(file)
869
- Utils::OutputFormatter.note("Applying maximum compression...")
870
-
871
- original_size = File.size(file)
872
- temp_file = file.gsub('.png', '_compressed_temp.png')
873
-
874
- CompressionManager.compress_with_metadata(file, temp_file, debug: options[:debug])
875
-
876
- # Show compression stats
877
- stats = CompressionManager.compression_stats(file, temp_file)
878
-
879
- if options[:debug] || stats[:saved_bytes] > 0
880
- Utils::OutputFormatter.indent("Original: #{Utils::FileHelper.format_size(stats[:original_size])}")
881
- Utils::OutputFormatter.indent("Compressed: #{Utils::FileHelper.format_size(stats[:compressed_size])}")
882
- Utils::OutputFormatter.indent("Saved: #{Utils::FileHelper.format_size(stats[:saved_bytes])} (#{stats[:reduction_percent].round(1)}% reduction)")
883
- end
884
-
885
- # Replace original with compressed
886
- FileUtils.mv(temp_file, file)
887
-
888
- file
889
- end
890
- end
891
- 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