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
data/bin/ruby_spriter CHANGED
@@ -1,20 +1,20 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- # Add lib directory to load path
5
- lib = File.expand_path('../lib', __dir__)
6
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
7
-
8
- require 'ruby_spriter'
9
-
10
- # Run the CLI
11
- begin
12
- RubySpriter::CLI.start(ARGV)
13
- rescue Interrupt
14
- puts "\n\n⚠️ Process interrupted by user"
15
- exit 130
16
- rescue StandardError => e
17
- puts "\n❌ ERROR: #{e.message}"
18
- puts e.backtrace.join("\n") if ENV['DEBUG']
19
- exit 1
20
- end
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Add lib directory to load path
5
+ lib = File.expand_path('../lib', __dir__)
6
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
7
+
8
+ require 'ruby_spriter'
9
+
10
+ # Run the CLI
11
+ begin
12
+ RubySpriter::CLI.start(ARGV)
13
+ rescue Interrupt
14
+ puts "\n\n⚠️ Process interrupted by user"
15
+ exit 130
16
+ rescue StandardError => e
17
+ puts "\n❌ ERROR: #{e.message}"
18
+ puts e.backtrace.join("\n") if ENV['DEBUG']
19
+ exit 1
20
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module RubySpriter
6
+ # BackgroundSampler collects unique background colors from interior image regions
7
+ #
8
+ # Sampling Strategy:
9
+ # - Starts at (sample_offset, sample_offset) to avoid edge compression artifacts
10
+ # - Samples horizontally across the image with calculated intervals
11
+ # - Moves to next row if not enough unique colors found
12
+ # - Uses pixel cache for fast lookups (loads all pixels once)
13
+ class BackgroundSampler
14
+ attr_reader :image_path, :sample_offset, :sample_count, :max_rows
15
+
16
+ def initialize(image_path, sample_offset = 5, sample_count = 10, max_rows = 20)
17
+ @image_path = image_path
18
+ @sample_offset = sample_offset
19
+ @sample_count = sample_count
20
+ @max_rows = max_rows
21
+ @image_width = nil
22
+ @image_height = nil
23
+ @pixel_cache = nil
24
+ end
25
+
26
+ # Collect unique background colors by sampling interior regions
27
+ def collect_unique_colors
28
+ load_image_dimensions
29
+ load_pixel_cache
30
+
31
+ # Input validation
32
+ if @sample_count < 2
33
+ raise ValidationError, "sample_count must be at least 2"
34
+ end
35
+
36
+ usable_width = @image_width - (2 * @sample_offset)
37
+ if usable_width <= 0
38
+ raise ValidationError, "sample_offset (#{@sample_offset}) too large for image width (#{@image_width})"
39
+ end
40
+
41
+ unique_colors = []
42
+ y = @sample_offset
43
+ rows_sampled = 0
44
+
45
+ while unique_colors.length < @sample_count && rows_sampled < @max_rows
46
+ # Calculate interval across usable width (excluding offset margins on both sides)
47
+ interval = (@image_width - 2 * @sample_offset).to_f / (@sample_count - 1)
48
+
49
+ # Sample across the width at current y position
50
+ @sample_count.times do |i|
51
+ x = @sample_offset + (i * interval).round
52
+
53
+ # Ensure x is within bounds
54
+ x = x.clamp(@sample_offset, @image_width - @sample_offset - 1)
55
+
56
+ color = sample_pixel(x, y)
57
+
58
+ if color && !color_exists?(unique_colors, color)
59
+ unique_colors << color
60
+ break if unique_colors.length >= @sample_count
61
+ end
62
+ end
63
+
64
+ # Move to next row
65
+ y += 1
66
+ rows_sampled += 1
67
+ end
68
+
69
+ unique_colors
70
+ end
71
+
72
+ private
73
+
74
+ def load_image_dimensions
75
+ identify_cmd = Platform.imagemagick_identify_cmd
76
+ cmd = "#{identify_cmd} -format \"%w %h\" #{Utils::PathHelper.quote_path(@image_path)}"
77
+ stdout, stderr, status = Open3.capture3(cmd)
78
+
79
+ unless status.success?
80
+ raise ProcessingError, "Failed to get image dimensions: #{stderr}"
81
+ end
82
+
83
+ @image_width, @image_height = stdout.strip.split.map(&:to_i)
84
+ end
85
+
86
+ def load_pixel_cache
87
+ return if @pixel_cache
88
+
89
+ convert_cmd = Platform.imagemagick_convert_cmd
90
+ cmd = "#{convert_cmd} #{Utils::PathHelper.quote_path(@image_path)} txt:-"
91
+ stdout, stderr, status = Open3.capture3(cmd)
92
+
93
+ unless status.success?
94
+ raise ProcessingError, "Failed to load pixel cache: #{stderr}"
95
+ end
96
+
97
+ @pixel_cache = {}
98
+
99
+ stdout.each_line do |line|
100
+ next if line.start_with?('#')
101
+
102
+ if line =~ /^(\d+),(\d+):\s+\((\d+),(\d+),(\d+)/
103
+ x = $1.to_i
104
+ y = $2.to_i
105
+ r = $3.to_i
106
+ g = $4.to_i
107
+ b = $5.to_i
108
+
109
+ @pixel_cache[[x, y]] = { r: r, g: g, b: b }
110
+ end
111
+ end
112
+ end
113
+
114
+ def sample_pixel(x, y)
115
+ # Fallback to direct ImageMagick call if cache not loaded (for testing)
116
+ return @pixel_cache[[x, y]] if @pixel_cache
117
+
118
+ convert_cmd = Platform.imagemagick_convert_cmd
119
+ cmd = "#{convert_cmd} #{Utils::PathHelper.quote_path(@image_path)} -format \"%[pixel:p{#{x},#{y}}]\" info:"
120
+ stdout, stderr, status = Open3.capture3(cmd)
121
+
122
+ return nil unless status.success?
123
+
124
+ # Parse output like "srgb(255,255,255)", "srgba(255,255,255,1.0)", or "gray(255)"
125
+ if stdout =~ /srgba?\((\d+),(\d+),(\d+)/
126
+ { r: $1.to_i, g: $2.to_i, b: $3.to_i }
127
+ elsif stdout =~ /gray\((\d+)\)/
128
+ # Convert grayscale to RGB
129
+ gray_value = $1.to_i
130
+ { r: gray_value, g: gray_value, b: gray_value }
131
+ else
132
+ nil
133
+ end
134
+ end
135
+
136
+ def color_exists?(colors, new_color)
137
+ colors.any? { |c| c[:r] == new_color[:r] && c[:g] == new_color[:g] && c[:b] == new_color[:b] }
138
+ end
139
+ end
140
+ end
@@ -1,214 +1,268 @@
1
- # frozen_string_literal: true
2
-
3
- require 'fileutils'
4
-
5
- module RubySpriter
6
- # Processes multiple videos in batch mode
7
- class BatchProcessor
8
- attr_reader :options
9
-
10
- def initialize(options = {})
11
- @options = options
12
- validate_directory!
13
- end
14
-
15
- # Find all MP4 files in the directory
16
- # @return [Array<String>] List of MP4 file paths
17
- def find_videos
18
- pattern = File.join(options[:dir], '*.mp4')
19
- videos = Dir.glob(pattern)
20
-
21
- if videos.empty?
22
- raise ValidationError, "No MP4 files found in directory: #{options[:dir]}"
23
- end
24
-
25
- if options[:debug]
26
- Utils::OutputFormatter.note("Found #{videos.length} video(s) to process")
27
- videos.each { |v| Utils::OutputFormatter.indent(File.basename(v)) }
28
- end
29
-
30
- videos
31
- end
32
-
33
- # Process all videos in the directory
34
- # @return [Hash] Processing results with outputs and errors
35
- def process
36
- videos = find_videos
37
- output_dir = determine_output_directory
38
- ensure_output_directory_exists(output_dir)
39
-
40
- results = {
41
- processed_count: 0,
42
- outputs: [],
43
- errors: []
44
- }
45
-
46
- Utils::OutputFormatter.header("Batch Processing: #{videos.length} video(s)")
47
-
48
- videos.each_with_index do |video, index|
49
- begin
50
- puts "\nProcessing [#{index + 1}/#{videos.length}]: #{File.basename(video)}"
51
-
52
- output_file = determine_output_file(video, output_dir)
53
- output_file = Utils::FileHelper.ensure_unique_output(output_file, overwrite: options[:overwrite])
54
-
55
- # Process video
56
- video_result = process_video(video, output_file)
57
-
58
- # Apply max compression if requested
59
- if options[:max_compress] && video_result[:output_file]
60
- video_result[:output_file] = apply_compression(video_result[:output_file])
61
- end
62
-
63
- results[:outputs] << video_result[:output_file]
64
- results[:processed_count] += 1
65
-
66
- Utils::OutputFormatter.success("Output: #{File.basename(video_result[:output_file])}")
67
- rescue StandardError => e
68
- error_msg = "#{File.basename(video)}: #{e.message}"
69
- results[:errors] << error_msg
70
- Utils::OutputFormatter.error(error_msg)
71
- end
72
- end
73
-
74
- # Consolidate if requested
75
- if options[:batch_consolidate] && results[:outputs].any?
76
- consolidated = consolidate_results(results[:outputs])
77
- results[:consolidated] = consolidated if consolidated
78
- end
79
-
80
- display_summary(results)
81
-
82
- results
83
- end
84
-
85
- # Consolidate all resulting spritesheets
86
- # @param outputs [Array<String>] List of spritesheet file paths
87
- # @return [Hash, nil] Consolidation result or nil if not requested
88
- def consolidate_results(outputs)
89
- return nil unless options[:batch_consolidate]
90
- return nil if outputs.empty?
91
-
92
- Utils::OutputFormatter.header("Consolidating #{outputs.length} spritesheets")
93
-
94
- output_dir = options[:outputdir] || options[:dir]
95
- consolidated_file = File.join(output_dir, 'batch_consolidated_spritesheet.png')
96
- consolidated_file = Utils::FileHelper.ensure_unique_output(consolidated_file, overwrite: options[:overwrite])
97
-
98
- consolidator = Consolidator.new(options)
99
- result = consolidator.consolidate(outputs, consolidated_file)
100
-
101
- Utils::OutputFormatter.success("Consolidated output: #{File.basename(consolidated_file)}")
102
-
103
- result
104
- end
105
-
106
- private
107
-
108
- def validate_directory!
109
- dir = options[:dir]
110
-
111
- raise ValidationError, "Must specify --dir for batch processing" unless dir
112
- raise ValidationError, "Directory not found: #{dir}" unless File.directory?(dir)
113
- end
114
-
115
- def determine_output_directory
116
- options[:outputdir] || options[:dir]
117
- end
118
-
119
- def ensure_output_directory_exists(dir)
120
- return if File.directory?(dir)
121
-
122
- if options[:debug]
123
- Utils::OutputFormatter.note("Creating output directory: #{dir}")
124
- end
125
-
126
- FileUtils.mkdir_p(dir)
127
- end
128
-
129
- def determine_output_file(video_file, output_dir)
130
- basename = File.basename(video_file, '.*')
131
- File.join(output_dir, "#{basename}_spritesheet.png")
132
- end
133
-
134
- def process_video(video_file, output_file)
135
- video_processor = VideoProcessor.new(options)
136
- result = video_processor.create_spritesheet(video_file, output_file)
137
-
138
- working_file = result[:output_file]
139
-
140
- # Apply GIMP processing if requested
141
- if needs_gimp_processing?
142
- working_file = process_with_gimp(working_file, result)
143
- end
144
-
145
- # Update result with final file
146
- result[:output_file] = working_file
147
- result
148
- end
149
-
150
- def needs_gimp_processing?
151
- options[:scale_percent] || options[:remove_bg] || options[:sharpen]
152
- end
153
-
154
- def process_with_gimp(input_file, video_result)
155
- # Get GIMP path and version from dependency checker
156
- checker = DependencyChecker.new(verbose: false)
157
- results = checker.check_all
158
- gimp_path = checker.gimp_path
159
- gimp_version = checker.gimp_version
160
-
161
- unless gimp_path
162
- raise DependencyError, "GIMP not found but required for processing"
163
- end
164
-
165
- gimp_options = options.merge(gimp_version: gimp_version)
166
- gimp_processor = GimpProcessor.new(gimp_path, gimp_options)
167
- output_file = gimp_processor.process(input_file)
168
-
169
- # Clean up intermediate file if different
170
- if output_file != input_file && File.exist?(input_file)
171
- File.delete(input_file) unless options[:keep_temp]
172
- end
173
-
174
- output_file
175
- end
176
-
177
- def apply_compression(file)
178
- Utils::OutputFormatter.indent("Applying maximum compression...")
179
-
180
- temp_file = file.gsub('.png', '_temp.png')
181
- CompressionManager.compress_with_metadata(file, temp_file)
182
-
183
- # Show compression stats
184
- if options[:debug]
185
- stats = CompressionManager.compression_stats(file, temp_file)
186
- Utils::OutputFormatter.indent("Saved #{Utils::FileHelper.format_size(stats[:saved_bytes])} (#{stats[:reduction_percent].round(1)}% reduction)")
187
- end
188
-
189
- # Replace original with compressed
190
- FileUtils.mv(temp_file, file)
191
-
192
- file
193
- end
194
-
195
- def display_summary(results)
196
- Utils::OutputFormatter.header("Batch Processing Summary")
197
-
198
- puts "Total videos: #{results[:processed_count] + results[:errors].length}"
199
- Utils::OutputFormatter.success("Successfully processed: #{results[:processed_count]}")
200
-
201
- if results[:errors].any?
202
- Utils::OutputFormatter.error("Failed: #{results[:errors].length}")
203
- results[:errors].each do |error|
204
- Utils::OutputFormatter.indent("- #{error}")
205
- end
206
- end
207
-
208
- if results[:consolidated]
209
- puts "\nConsolidated spritesheet:"
210
- Utils::OutputFormatter.indent(results[:consolidated][:output_file])
211
- end
212
- end
213
- end
214
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module RubySpriter
6
+ # Processes multiple videos in batch mode
7
+ class BatchProcessor
8
+ attr_reader :options
9
+
10
+ def initialize(options = {})
11
+ @options = options
12
+ @gimp_path = nil
13
+ @gimp_version = nil
14
+ validate_directory!
15
+ setup_dependencies if needs_dependency_setup?
16
+ end
17
+
18
+ # Find all MP4 files in the directory
19
+ # @return [Array<String>] List of MP4 file paths
20
+ def find_videos
21
+ pattern = File.join(options[:dir], '*.mp4')
22
+ videos = Dir.glob(pattern)
23
+
24
+ if videos.empty?
25
+ raise ValidationError, "No MP4 files found in directory: #{options[:dir]}"
26
+ end
27
+
28
+ if options[:debug]
29
+ Utils::OutputFormatter.note("Found #{videos.length} video(s) to process")
30
+ videos.each { |v| Utils::OutputFormatter.indent(File.basename(v)) }
31
+ end
32
+
33
+ videos
34
+ end
35
+
36
+ # Process all videos in the directory
37
+ # @return [Hash] Processing results with outputs and errors
38
+ def process
39
+ videos = find_videos
40
+ output_dir = determine_output_directory
41
+ ensure_output_directory_exists(output_dir)
42
+
43
+ results = {
44
+ processed_count: 0,
45
+ outputs: [],
46
+ errors: []
47
+ }
48
+
49
+ Utils::OutputFormatter.header("Batch Processing: #{videos.length} video(s)")
50
+
51
+ videos.each_with_index do |video, index|
52
+ begin
53
+ puts "\nProcessing [#{index + 1}/#{videos.length}]: #{File.basename(video)}"
54
+
55
+ output_file = determine_output_file(video, output_dir)
56
+ output_file = Utils::FileHelper.ensure_unique_output(output_file, overwrite: options[:overwrite])
57
+
58
+ # Process video
59
+ video_result = process_video(video, output_file)
60
+
61
+ # Apply max compression if requested
62
+ if options[:max_compress] && video_result[:output_file]
63
+ video_result[:output_file] = apply_compression(video_result[:output_file])
64
+ end
65
+
66
+ results[:outputs] << video_result[:output_file]
67
+ results[:processed_count] += 1
68
+
69
+ Utils::OutputFormatter.success("Output: #{File.basename(video_result[:output_file])}")
70
+ rescue StandardError => e
71
+ error_msg = "#{File.basename(video)}: #{e.message}"
72
+ results[:errors] << error_msg
73
+ Utils::OutputFormatter.error(error_msg)
74
+ end
75
+ end
76
+
77
+ # Consolidate if requested
78
+ if options[:batch_consolidate] && results[:outputs].any?
79
+ consolidated = consolidate_results(results[:outputs])
80
+ results[:consolidated] = consolidated if consolidated
81
+ end
82
+
83
+ display_summary(results)
84
+
85
+ results
86
+ end
87
+
88
+ # Consolidate all resulting spritesheets
89
+ # @param outputs [Array<String>] List of spritesheet file paths
90
+ # @return [Hash, nil] Consolidation result or nil if not requested
91
+ def consolidate_results(outputs)
92
+ return nil unless options[:batch_consolidate]
93
+ return nil if outputs.empty?
94
+
95
+ Utils::OutputFormatter.header("Consolidating #{outputs.length} spritesheets")
96
+
97
+ output_dir = options[:outputdir] || options[:dir]
98
+ consolidated_file = File.join(output_dir, 'batch_consolidated_spritesheet.png')
99
+ consolidated_file = Utils::FileHelper.ensure_unique_output(consolidated_file, overwrite: options[:overwrite])
100
+
101
+ consolidator = Consolidator.new(options)
102
+ result = consolidator.consolidate(outputs, consolidated_file)
103
+
104
+ Utils::OutputFormatter.success("Consolidated output: #{File.basename(consolidated_file)}")
105
+
106
+ result
107
+ end
108
+
109
+ private
110
+
111
+ def validate_directory!
112
+ dir = options[:dir]
113
+
114
+ raise ValidationError, "Must specify --dir for batch processing" unless dir
115
+ raise ValidationError, "Directory not found: #{dir}" unless File.directory?(dir)
116
+ end
117
+
118
+ # Check if using frame-by-frame background removal mode
119
+ # @return [Boolean] true if both --by-frame and --remove-bg flags are set
120
+ def using_frame_by_frame_background_removal?
121
+ options[:by_frame] && options[:remove_bg]
122
+ end
123
+
124
+ # Normalize video processing result to standard format
125
+ # @param result [Hash] Result from process_with_background_removal
126
+ # @return [Hash] Normalized result with :output_file, :columns, :rows, :frames
127
+ def normalize_video_result_format(result)
128
+ {
129
+ output_file: result[:output_file],
130
+ columns: result[:columns],
131
+ rows: (result[:frames].to_f / result[:columns]).ceil,
132
+ frames: result[:frames]
133
+ }
134
+ end
135
+
136
+ # Check if we need to setup dependencies (GIMP required)
137
+ # @return [Boolean] true if any operation requires GIMP
138
+ def needs_dependency_setup?
139
+ options[:by_frame] || options[:scale_percent] || options[:remove_bg] || options[:sharpen]
140
+ end
141
+
142
+ # Setup dependencies by checking for required tools
143
+ # Caches GIMP path and version as instance variables
144
+ def setup_dependencies
145
+ checker = DependencyChecker.new(verbose: false)
146
+ results = checker.check_all
147
+ @gimp_path = checker.gimp_path
148
+ @gimp_version = checker.gimp_version
149
+ end
150
+
151
+ def determine_output_directory
152
+ options[:outputdir] || options[:dir]
153
+ end
154
+
155
+ def ensure_output_directory_exists(dir)
156
+ return if File.directory?(dir)
157
+
158
+ if options[:debug]
159
+ Utils::OutputFormatter.note("Creating output directory: #{dir}")
160
+ end
161
+
162
+ FileUtils.mkdir_p(dir)
163
+ end
164
+
165
+ def determine_output_file(video_file, output_dir)
166
+ basename = File.basename(video_file, '.*')
167
+ File.join(output_dir, "#{basename}_spritesheet.png")
168
+ end
169
+
170
+ def process_video(video_file, output_file)
171
+ # Check if we need frame-by-frame background removal
172
+ if using_frame_by_frame_background_removal?
173
+ # Frame-by-frame processing with background removal
174
+ unless @gimp_path
175
+ raise DependencyError, "GIMP not found but required for --by-frame processing"
176
+ end
177
+
178
+ # Pass gimp_path through options
179
+ video_options = options.merge(gimp_path: @gimp_path)
180
+ video_processor = VideoProcessor.new(video_options)
181
+
182
+ result = video_processor.process_with_background_removal(
183
+ video_file,
184
+ output_file,
185
+ video_options
186
+ )
187
+
188
+ # Normalize result format to match create_spritesheet
189
+ result = normalize_video_result_format(result)
190
+
191
+ working_file = result[:output_file]
192
+ else
193
+ # Standard video processing
194
+ video_processor = VideoProcessor.new(options)
195
+ result = video_processor.create_spritesheet(video_file, output_file)
196
+ working_file = result[:output_file]
197
+
198
+ # Apply GIMP processing if requested (only for non-by-frame mode)
199
+ if needs_gimp_processing?
200
+ working_file = process_with_gimp(working_file, result)
201
+ end
202
+ end
203
+
204
+ # Update result with final file
205
+ result[:output_file] = working_file
206
+ result
207
+ end
208
+
209
+ def needs_gimp_processing?
210
+ options[:scale_percent] || options[:remove_bg] || options[:sharpen]
211
+ end
212
+
213
+ def process_with_gimp(input_file, video_result)
214
+ # Use cached GIMP path and version from initialization
215
+ unless @gimp_path
216
+ raise DependencyError, "GIMP not found but required for processing"
217
+ end
218
+
219
+ gimp_options = options.merge(gimp_version: @gimp_version)
220
+ gimp_processor = GimpProcessor.new(@gimp_path, gimp_options)
221
+ output_file = gimp_processor.process(input_file)
222
+
223
+ # Clean up intermediate file if different
224
+ if output_file != input_file && File.exist?(input_file)
225
+ File.delete(input_file) unless options[:keep_temp]
226
+ end
227
+
228
+ output_file
229
+ end
230
+
231
+ def apply_compression(file)
232
+ Utils::OutputFormatter.indent("Applying maximum compression...")
233
+
234
+ temp_file = file.gsub('.png', '_temp.png')
235
+ CompressionManager.compress_with_metadata(file, temp_file)
236
+
237
+ # Show compression stats
238
+ if options[:debug]
239
+ stats = CompressionManager.compression_stats(file, temp_file)
240
+ Utils::OutputFormatter.indent("Saved #{Utils::FileHelper.format_size(stats[:saved_bytes])} (#{stats[:reduction_percent].round(1)}% reduction)")
241
+ end
242
+
243
+ # Replace original with compressed
244
+ FileUtils.mv(temp_file, file)
245
+
246
+ file
247
+ end
248
+
249
+ def display_summary(results)
250
+ Utils::OutputFormatter.header("Batch Processing Summary")
251
+
252
+ puts "Total videos: #{results[:processed_count] + results[:errors].length}"
253
+ Utils::OutputFormatter.success("Successfully processed: #{results[:processed_count]}")
254
+
255
+ if results[:errors].any?
256
+ Utils::OutputFormatter.error("Failed: #{results[:errors].length}")
257
+ results[:errors].each do |error|
258
+ Utils::OutputFormatter.indent("- #{error}")
259
+ end
260
+ end
261
+
262
+ if results[:consolidated]
263
+ puts "\nConsolidated spritesheet:"
264
+ Utils::OutputFormatter.indent(results[:consolidated][:output_file])
265
+ end
266
+ end
267
+ end
268
+ end