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,200 +1,509 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
-
5
- RSpec.describe RubySpriter::BatchProcessor do
6
- let(:test_dir) { 'E:/test/videos' }
7
- let(:output_dir) { 'E:/test/output' }
8
- let(:video1) { File.join(test_dir, 'video1.mp4') }
9
- let(:video2) { File.join(test_dir, 'video2.mp4') }
10
- let(:video3) { File.join(test_dir, 'video3.mp4') }
11
-
12
- let(:options) do
13
- {
14
- batch: true,
15
- dir: test_dir,
16
- columns: 4,
17
- frame_count: 16,
18
- max_width: 320,
19
- overwrite: false,
20
- debug: false
21
- }
22
- end
23
-
24
- before do
25
- # Mock file system
26
- allow(Dir).to receive(:exist?).and_return(true)
27
- allow(File).to receive(:exist?).and_return(true)
28
- allow(File).to receive(:directory?).with(test_dir).and_return(true)
29
- allow(Dir).to receive(:glob).with(File.join(test_dir, '*.mp4')).and_return([video1, video2, video3])
30
- end
31
-
32
- describe '#initialize' do
33
- it 'raises error if directory does not exist' do
34
- allow(File).to receive(:directory?).with(test_dir).and_return(false)
35
-
36
- expect {
37
- described_class.new(options)
38
- }.to raise_error(RubySpriter::ValidationError, /Directory not found/)
39
- end
40
-
41
- it 'initializes with valid options' do
42
- processor = described_class.new(options)
43
- expect(processor.options).to eq(options)
44
- end
45
- end
46
-
47
- describe '#find_videos' do
48
- it 'finds all MP4 files in directory' do
49
- processor = described_class.new(options)
50
- videos = processor.find_videos
51
-
52
- expect(videos).to eq([video1, video2, video3])
53
- end
54
-
55
- it 'raises error if no videos found' do
56
- allow(Dir).to receive(:glob).with(File.join(test_dir, '*.mp4')).and_return([])
57
-
58
- processor = described_class.new(options)
59
-
60
- expect {
61
- processor.find_videos
62
- }.to raise_error(RubySpriter::ValidationError, /No MP4 files found/)
63
- end
64
- end
65
-
66
- describe '#process' do
67
- let(:video_processor_mock) { instance_double(RubySpriter::VideoProcessor) }
68
-
69
- before do
70
- allow(RubySpriter::VideoProcessor).to receive(:new).and_return(video_processor_mock)
71
- allow(video_processor_mock).to receive(:create_spritesheet).and_return(
72
- { output_file: 'output.png', columns: 4, rows: 4, frames: 16 }
73
- )
74
- allow(File).to receive(:exist?).and_return(false) # No existing files
75
- allow(RubySpriter::Utils::FileHelper).to receive(:spritesheet_filename) do |video|
76
- video.gsub('.mp4', '_spritesheet.png')
77
- end
78
- end
79
-
80
- it 'processes all videos in directory' do
81
- processor = described_class.new(options)
82
-
83
- expect(video_processor_mock).to receive(:create_spritesheet).exactly(3).times
84
-
85
- results = processor.process
86
-
87
- expect(results[:processed_count]).to eq(3)
88
- expect(results[:outputs].length).to eq(3)
89
- end
90
-
91
- it 'outputs to same directory by default' do
92
- processor = described_class.new(options)
93
-
94
- expected_output1 = File.join(test_dir, 'video1_spritesheet.png')
95
- expect(video_processor_mock).to receive(:create_spritesheet).with(video1, expected_output1)
96
-
97
- processor.process
98
- end
99
-
100
- it 'outputs to specified outputdir when provided' do
101
- options_with_output = options.merge(outputdir: output_dir)
102
- processor = described_class.new(options_with_output)
103
-
104
- allow(File).to receive(:directory?).with(output_dir).and_return(true)
105
-
106
- expected_output1 = File.join(output_dir, 'video1_spritesheet.png')
107
- expect(video_processor_mock).to receive(:create_spritesheet).with(video1, expected_output1)
108
-
109
- processor.process
110
- end
111
-
112
- it 'creates output directory if it does not exist' do
113
- options_with_output = options.merge(outputdir: output_dir)
114
- processor = described_class.new(options_with_output)
115
-
116
- allow(File).to receive(:directory?).with(output_dir).and_return(false)
117
- expect(FileUtils).to receive(:mkdir_p).with(output_dir)
118
-
119
- processor.process
120
- end
121
-
122
- it 'enforces unique filenames unless overwrite is true' do
123
- allow(File).to receive(:exist?).with(/video1_spritesheet\.png/).and_return(true)
124
- allow(RubySpriter::Utils::FileHelper).to receive(:ensure_unique_output).and_call_original
125
- allow(RubySpriter::Utils::FileHelper).to receive(:unique_filename).and_return('video1_spritesheet_20251024_123456_789.png')
126
-
127
- processor = described_class.new(options)
128
-
129
- expect(RubySpriter::Utils::FileHelper).to receive(:ensure_unique_output).at_least(:once)
130
-
131
- processor.process
132
- end
133
-
134
- it 'continues processing after error in one video' do
135
- processor = described_class.new(options)
136
-
137
- allow(video_processor_mock).to receive(:create_spritesheet).and_raise(RubySpriter::ProcessingError, 'Test error')
138
-
139
- results = processor.process
140
-
141
- expect(results[:processed_count]).to eq(0)
142
- expect(results[:errors].length).to eq(3)
143
- end
144
- end
145
-
146
- describe '#consolidate_results' do
147
- let(:spritesheet1) { File.join(test_dir, 'video1_spritesheet.png') }
148
- let(:spritesheet2) { File.join(test_dir, 'video2_spritesheet.png') }
149
- let(:spritesheet3) { File.join(test_dir, 'video3_spritesheet.png') }
150
- let(:consolidator_mock) { instance_double(RubySpriter::Consolidator) }
151
-
152
- before do
153
- allow(RubySpriter::Consolidator).to receive(:new).and_return(consolidator_mock)
154
- allow(consolidator_mock).to receive(:consolidate).and_return(
155
- { output_file: 'consolidated.png', columns: 4, rows: 12, frames: 48 }
156
- )
157
- end
158
-
159
- it 'consolidates all spritesheets when batch_consolidate is true' do
160
- options_with_consolidate = options.merge(batch_consolidate: true)
161
- processor = described_class.new(options_with_consolidate)
162
-
163
- outputs = [spritesheet1, spritesheet2, spritesheet3]
164
-
165
- expect(consolidator_mock).to receive(:consolidate) do |files, output|
166
- expect(files).to eq(outputs)
167
- expect(output).to include('batch_consolidated_spritesheet')
168
- expect(output).to end_with('.png')
169
- end
170
-
171
- processor.consolidate_results(outputs)
172
- end
173
-
174
- it 'uses outputdir for consolidated file if specified' do
175
- options_with_consolidate = options.merge(batch_consolidate: true, outputdir: output_dir)
176
- processor = described_class.new(options_with_consolidate)
177
-
178
- allow(File).to receive(:directory?).with(output_dir).and_return(true)
179
-
180
- outputs = [spritesheet1, spritesheet2, spritesheet3]
181
-
182
- expect(consolidator_mock).to receive(:consolidate) do |files, output|
183
- expect(output).to start_with(output_dir)
184
- end
185
-
186
- processor.consolidate_results(outputs)
187
- end
188
-
189
- it 'does not consolidate when batch_consolidate is false' do
190
- processor = described_class.new(options)
191
-
192
- outputs = [spritesheet1, spritesheet2, spritesheet3]
193
-
194
- expect(consolidator_mock).not_to receive(:consolidate)
195
-
196
- result = processor.consolidate_results(outputs)
197
- expect(result).to be_nil
198
- end
199
- end
200
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubySpriter::BatchProcessor do
6
+ let(:test_dir) { 'E:/test/videos' }
7
+ let(:output_dir) { 'E:/test/output' }
8
+ let(:video1) { File.join(test_dir, 'video1.mp4') }
9
+ let(:video2) { File.join(test_dir, 'video2.mp4') }
10
+ let(:video3) { File.join(test_dir, 'video3.mp4') }
11
+
12
+ let(:options) do
13
+ {
14
+ batch: true,
15
+ dir: test_dir,
16
+ columns: 4,
17
+ frame_count: 16,
18
+ max_width: 320,
19
+ overwrite: false,
20
+ debug: false
21
+ }
22
+ end
23
+
24
+ before do
25
+ # Mock file system
26
+ allow(Dir).to receive(:exist?).and_return(true)
27
+ allow(File).to receive(:exist?).and_return(true)
28
+ allow(File).to receive(:directory?).with(test_dir).and_return(true)
29
+ allow(Dir).to receive(:glob).with(File.join(test_dir, '*.mp4')).and_return([video1, video2, video3])
30
+ end
31
+
32
+ describe '#initialize' do
33
+ it 'raises error if directory does not exist' do
34
+ allow(File).to receive(:directory?).with(test_dir).and_return(false)
35
+
36
+ expect {
37
+ described_class.new(options)
38
+ }.to raise_error(RubySpriter::ValidationError, /Directory not found/)
39
+ end
40
+
41
+ it 'initializes with valid options' do
42
+ processor = described_class.new(options)
43
+ expect(processor.options).to eq(options)
44
+ end
45
+ end
46
+
47
+ describe '#find_videos' do
48
+ it 'finds all MP4 files in directory' do
49
+ processor = described_class.new(options)
50
+ videos = processor.find_videos
51
+
52
+ expect(videos).to eq([video1, video2, video3])
53
+ end
54
+
55
+ it 'raises error if no videos found' do
56
+ allow(Dir).to receive(:glob).with(File.join(test_dir, '*.mp4')).and_return([])
57
+
58
+ processor = described_class.new(options)
59
+
60
+ expect {
61
+ processor.find_videos
62
+ }.to raise_error(RubySpriter::ValidationError, /No MP4 files found/)
63
+ end
64
+ end
65
+
66
+ describe '#process' do
67
+ let(:video_processor_mock) { instance_double(RubySpriter::VideoProcessor) }
68
+
69
+ before do
70
+ allow(RubySpriter::VideoProcessor).to receive(:new).and_return(video_processor_mock)
71
+ allow(video_processor_mock).to receive(:create_spritesheet).and_return(
72
+ { output_file: 'output.png', columns: 4, rows: 4, frames: 16 }
73
+ )
74
+ allow(File).to receive(:exist?).and_return(false) # No existing files
75
+ allow(RubySpriter::Utils::FileHelper).to receive(:spritesheet_filename) do |video|
76
+ video.gsub('.mp4', '_spritesheet.png')
77
+ end
78
+ end
79
+
80
+ it 'processes all videos in directory' do
81
+ processor = described_class.new(options)
82
+
83
+ expect(video_processor_mock).to receive(:create_spritesheet).exactly(3).times
84
+
85
+ results = processor.process
86
+
87
+ expect(results[:processed_count]).to eq(3)
88
+ expect(results[:outputs].length).to eq(3)
89
+ end
90
+
91
+ it 'outputs to same directory by default' do
92
+ processor = described_class.new(options)
93
+
94
+ expected_output1 = File.join(test_dir, 'video1_spritesheet.png')
95
+ expect(video_processor_mock).to receive(:create_spritesheet).with(video1, expected_output1)
96
+
97
+ processor.process
98
+ end
99
+
100
+ it 'outputs to specified outputdir when provided' do
101
+ options_with_output = options.merge(outputdir: output_dir)
102
+ processor = described_class.new(options_with_output)
103
+
104
+ allow(File).to receive(:directory?).with(output_dir).and_return(true)
105
+
106
+ expected_output1 = File.join(output_dir, 'video1_spritesheet.png')
107
+ expect(video_processor_mock).to receive(:create_spritesheet).with(video1, expected_output1)
108
+
109
+ processor.process
110
+ end
111
+
112
+ it 'creates output directory if it does not exist' do
113
+ options_with_output = options.merge(outputdir: output_dir)
114
+ processor = described_class.new(options_with_output)
115
+
116
+ allow(File).to receive(:directory?).with(output_dir).and_return(false)
117
+ expect(FileUtils).to receive(:mkdir_p).with(output_dir)
118
+
119
+ processor.process
120
+ end
121
+
122
+ it 'enforces unique filenames unless overwrite is true' do
123
+ allow(File).to receive(:exist?).with(/video1_spritesheet\.png/).and_return(true)
124
+ allow(RubySpriter::Utils::FileHelper).to receive(:ensure_unique_output).and_call_original
125
+ allow(RubySpriter::Utils::FileHelper).to receive(:unique_filename).and_return('video1_spritesheet_20251024_123456_789.png')
126
+
127
+ processor = described_class.new(options)
128
+
129
+ expect(RubySpriter::Utils::FileHelper).to receive(:ensure_unique_output).at_least(:once)
130
+
131
+ processor.process
132
+ end
133
+
134
+ it 'continues processing after error in one video' do
135
+ processor = described_class.new(options)
136
+
137
+ allow(video_processor_mock).to receive(:create_spritesheet).and_raise(RubySpriter::ProcessingError, 'Test error')
138
+
139
+ results = processor.process
140
+
141
+ expect(results[:processed_count]).to eq(0)
142
+ expect(results[:errors].length).to eq(3)
143
+ end
144
+ end
145
+
146
+ describe 'batch processing with --by-frame flag' do
147
+ let(:options) do
148
+ {
149
+ batch: true,
150
+ dir: test_dir,
151
+ remove_bg: true,
152
+ by_frame: true,
153
+ frame_count: 4,
154
+ columns: 2
155
+ }
156
+ end
157
+
158
+ let(:batch_processor) { described_class.new(options) }
159
+
160
+ before do
161
+ # Mock directory with video files
162
+ allow(Dir).to receive(:glob).with(File.join(test_dir, '*.mp4')).and_return(['test_videos/video1.mp4', 'test_videos/video2.mp4'])
163
+ allow(File).to receive(:directory?).and_return(true)
164
+
165
+ # Mock file validation
166
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
167
+
168
+ # Mock DependencyChecker for GIMP path
169
+ dependency_checker = instance_double(RubySpriter::DependencyChecker)
170
+ allow(RubySpriter::DependencyChecker).to receive(:new).and_return(dependency_checker)
171
+ allow(dependency_checker).to receive(:check_all).and_return({
172
+ ffmpeg: { available: true },
173
+ ffprobe: { available: true },
174
+ imagemagick: { available: true },
175
+ gimp: { available: true }
176
+ })
177
+ allow(dependency_checker).to receive(:gimp_path).and_return('/usr/bin/gimp')
178
+ allow(dependency_checker).to receive(:gimp_version).and_return({ major: 3, minor: 0 })
179
+ end
180
+
181
+ it 'passes by_frame flag to each video processor' do
182
+ video_processor1 = instance_double(RubySpriter::VideoProcessor)
183
+ video_processor2 = instance_double(RubySpriter::VideoProcessor)
184
+
185
+ # VideoProcessor.new is called 2 times total (1 per video)
186
+ # After refactoring: uses cached @gimp_path, so only one instantiation per video
187
+ call_count = 0
188
+ expect(RubySpriter::VideoProcessor).to receive(:new).exactly(2).times do |passed_options|
189
+ call_count += 1
190
+
191
+ # Every call should have by_frame, remove_bg, and gimp_path (cached)
192
+ expect(passed_options[:by_frame]).to be true
193
+ expect(passed_options[:remove_bg]).to be true
194
+ expect(passed_options[:gimp_path]).to eq('/usr/bin/gimp')
195
+
196
+ call_count == 1 ? video_processor1 : video_processor2
197
+ end
198
+
199
+ # Mock process_with_background_removal to return proper result
200
+ allow(video_processor1).to receive(:process_with_background_removal).and_return({
201
+ output_file: 'output1.png',
202
+ columns: 2,
203
+ frames: 4,
204
+ processing_mode: 'by-frame'
205
+ })
206
+
207
+ allow(video_processor2).to receive(:process_with_background_removal).and_return({
208
+ output_file: 'output2.png',
209
+ columns: 2,
210
+ frames: 4,
211
+ processing_mode: 'by-frame'
212
+ })
213
+
214
+ batch_processor.process
215
+ end
216
+
217
+ it 'reports frame-by-frame processing mode in batch summary' do
218
+ video_processor = instance_double(RubySpriter::VideoProcessor)
219
+ allow(RubySpriter::VideoProcessor).to receive(:new).and_return(video_processor)
220
+
221
+ # Mock process_with_background_removal
222
+ allow(video_processor).to receive(:process_with_background_removal).and_return({
223
+ output_file: 'output.png',
224
+ columns: 2,
225
+ frames: 4,
226
+ processing_mode: 'by-frame'
227
+ })
228
+
229
+ result = batch_processor.process
230
+
231
+ expect(result[:processed_count]).to eq(2)
232
+ expect(result[:errors].length).to eq(0)
233
+ end
234
+ end
235
+
236
+ describe '#consolidate_results' do
237
+ let(:spritesheet1) { File.join(test_dir, 'video1_spritesheet.png') }
238
+ let(:spritesheet2) { File.join(test_dir, 'video2_spritesheet.png') }
239
+ let(:spritesheet3) { File.join(test_dir, 'video3_spritesheet.png') }
240
+ let(:consolidator_mock) { instance_double(RubySpriter::Consolidator) }
241
+
242
+ before do
243
+ allow(RubySpriter::Consolidator).to receive(:new).and_return(consolidator_mock)
244
+ allow(consolidator_mock).to receive(:consolidate).and_return(
245
+ { output_file: 'consolidated.png', columns: 4, rows: 12, frames: 48 }
246
+ )
247
+ end
248
+
249
+ it 'consolidates all spritesheets when batch_consolidate is true' do
250
+ options_with_consolidate = options.merge(batch_consolidate: true)
251
+ processor = described_class.new(options_with_consolidate)
252
+
253
+ outputs = [spritesheet1, spritesheet2, spritesheet3]
254
+
255
+ expect(consolidator_mock).to receive(:consolidate) do |files, output|
256
+ expect(files).to eq(outputs)
257
+ expect(output).to include('batch_consolidated_spritesheet')
258
+ expect(output).to end_with('.png')
259
+ end
260
+
261
+ processor.consolidate_results(outputs)
262
+ end
263
+
264
+ it 'uses outputdir for consolidated file if specified' do
265
+ options_with_consolidate = options.merge(batch_consolidate: true, outputdir: output_dir)
266
+ processor = described_class.new(options_with_consolidate)
267
+
268
+ allow(File).to receive(:directory?).with(output_dir).and_return(true)
269
+
270
+ outputs = [spritesheet1, spritesheet2, spritesheet3]
271
+
272
+ expect(consolidator_mock).to receive(:consolidate) do |files, output|
273
+ expect(output).to start_with(output_dir)
274
+ end
275
+
276
+ processor.consolidate_results(outputs)
277
+ end
278
+
279
+ it 'does not consolidate when batch_consolidate is false' do
280
+ processor = described_class.new(options)
281
+
282
+ outputs = [spritesheet1, spritesheet2, spritesheet3]
283
+
284
+ expect(consolidator_mock).not_to receive(:consolidate)
285
+
286
+ result = processor.consolidate_results(outputs)
287
+ expect(result).to be_nil
288
+ end
289
+ end
290
+
291
+ describe '#using_frame_by_frame_background_removal?' do
292
+ context 'when both by_frame and remove_bg are true' do
293
+ it 'returns true' do
294
+ options_with_flags = options.merge(by_frame: true, remove_bg: true)
295
+ processor = described_class.new(options_with_flags)
296
+
297
+ expect(processor.send(:using_frame_by_frame_background_removal?)).to be true
298
+ end
299
+ end
300
+
301
+ context 'when only by_frame is true' do
302
+ it 'returns false' do
303
+ options_with_by_frame = options.merge(by_frame: true, remove_bg: false)
304
+ processor = described_class.new(options_with_by_frame)
305
+
306
+ expect(processor.send(:using_frame_by_frame_background_removal?)).to be false
307
+ end
308
+ end
309
+
310
+ context 'when only remove_bg is true' do
311
+ it 'returns false' do
312
+ options_with_remove_bg = options.merge(by_frame: false, remove_bg: true)
313
+ processor = described_class.new(options_with_remove_bg)
314
+
315
+ expect(processor.send(:using_frame_by_frame_background_removal?)).to be false
316
+ end
317
+ end
318
+
319
+ context 'when neither flag is set' do
320
+ it 'returns false' do
321
+ options_without_flags = options.merge(by_frame: false, remove_bg: false)
322
+ processor = described_class.new(options_without_flags)
323
+
324
+ expect(processor.send(:using_frame_by_frame_background_removal?)).to be false
325
+ end
326
+ end
327
+ end
328
+
329
+ describe '#normalize_video_result_format' do
330
+ it 'normalizes result hash to standard format' do
331
+ input_result = {
332
+ output_file: 'test_output.png',
333
+ columns: 4,
334
+ frames: 16,
335
+ processing_mode: 'by-frame',
336
+ extra_data: 'should be removed'
337
+ }
338
+
339
+ expected_result = {
340
+ output_file: 'test_output.png',
341
+ columns: 4,
342
+ rows: 4,
343
+ frames: 16
344
+ }
345
+
346
+ processor = described_class.new(options)
347
+ normalized = processor.send(:normalize_video_result_format, input_result)
348
+
349
+ expect(normalized).to eq(expected_result)
350
+ end
351
+
352
+ it 'calculates rows with ceiling division' do
353
+ input_result = {
354
+ output_file: 'test.png',
355
+ columns: 4,
356
+ frames: 15 # 15 / 4 = 3.75, should ceil to 4
357
+ }
358
+
359
+ processor = described_class.new(options)
360
+ normalized = processor.send(:normalize_video_result_format, input_result)
361
+
362
+ expect(normalized[:rows]).to eq(4)
363
+ end
364
+ end
365
+
366
+ describe 'dependency checking' do
367
+ let(:dependency_checker) { instance_double(RubySpriter::DependencyChecker) }
368
+
369
+ before do
370
+ allow(RubySpriter::DependencyChecker).to receive(:new).and_return(dependency_checker)
371
+ allow(dependency_checker).to receive(:check_all).and_return({
372
+ ffmpeg: { available: true },
373
+ ffprobe: { available: true },
374
+ imagemagick: { available: true },
375
+ gimp: { available: true }
376
+ })
377
+ allow(dependency_checker).to receive(:gimp_path).and_return('/usr/bin/gimp')
378
+ allow(dependency_checker).to receive(:gimp_version).and_return({ major: 3, minor: 0 })
379
+ end
380
+
381
+ context 'when GIMP is needed for processing' do
382
+ let(:options_needing_gimp) { options.merge(by_frame: true, remove_bg: true) }
383
+
384
+ it 'checks dependencies during initialization' do
385
+ expect(RubySpriter::DependencyChecker).to receive(:new).with(verbose: false).once
386
+ expect(dependency_checker).to receive(:check_all).once
387
+
388
+ described_class.new(options_needing_gimp)
389
+ end
390
+
391
+ it 'stores gimp_path as instance variable' do
392
+ processor = described_class.new(options_needing_gimp)
393
+
394
+ expect(processor.instance_variable_get(:@gimp_path)).to eq('/usr/bin/gimp')
395
+ end
396
+
397
+ it 'stores gimp_version as instance variable' do
398
+ processor = described_class.new(options_needing_gimp)
399
+
400
+ expect(processor.instance_variable_get(:@gimp_version)).to eq({ major: 3, minor: 0 })
401
+ end
402
+ end
403
+
404
+ context 'when GIMP is not needed' do
405
+ let(:options_without_gimp) { options.merge(by_frame: false, remove_bg: false) }
406
+
407
+ it 'does not check dependencies during initialization' do
408
+ expect(RubySpriter::DependencyChecker).not_to receive(:new)
409
+
410
+ described_class.new(options_without_gimp)
411
+ end
412
+ end
413
+ end
414
+
415
+ describe '#process_video with cached dependencies' do
416
+ let(:options_with_by_frame) { options.merge(by_frame: true, remove_bg: true) }
417
+ let(:video_file) { File.join(test_dir, 'test.mp4') }
418
+ let(:output_file) { File.join(test_dir, 'test_spritesheet.png') }
419
+ let(:video_processor) { instance_double(RubySpriter::VideoProcessor) }
420
+ let(:dependency_checker) { instance_double(RubySpriter::DependencyChecker) }
421
+
422
+ before do
423
+ # Mock DependencyChecker for initialization only
424
+ allow(RubySpriter::DependencyChecker).to receive(:new).and_return(dependency_checker)
425
+ allow(dependency_checker).to receive(:check_all).and_return({
426
+ ffmpeg: { available: true },
427
+ gimp: { available: true }
428
+ })
429
+ allow(dependency_checker).to receive(:gimp_path).and_return('/usr/bin/gimp')
430
+ allow(dependency_checker).to receive(:gimp_version).and_return({ major: 3, minor: 0 })
431
+
432
+ # Mock VideoProcessor
433
+ allow(RubySpriter::VideoProcessor).to receive(:new).and_return(video_processor)
434
+ allow(video_processor).to receive(:process_with_background_removal).and_return({
435
+ output_file: output_file,
436
+ columns: 4,
437
+ frames: 16
438
+ })
439
+ end
440
+
441
+ it 'does not create additional DependencyChecker instances during processing' do
442
+ processor = described_class.new(options_with_by_frame)
443
+
444
+ # DependencyChecker should only be called once during initialization
445
+ # Not again during process_video
446
+ expect(RubySpriter::DependencyChecker).not_to receive(:new)
447
+
448
+ processor.send(:process_video, video_file, output_file)
449
+ end
450
+
451
+ it 'passes cached gimp_path to VideoProcessor via options' do
452
+ processor = described_class.new(options_with_by_frame)
453
+
454
+ expect(RubySpriter::VideoProcessor).to receive(:new) do |passed_options|
455
+ expect(passed_options[:gimp_path]).to eq('/usr/bin/gimp')
456
+ video_processor
457
+ end
458
+
459
+ processor.send(:process_video, video_file, output_file)
460
+ end
461
+ end
462
+
463
+ describe '#process_with_gimp with cached dependencies' do
464
+ let(:options_with_gimp) { options.merge(scale_percent: 50) }
465
+ let(:input_file) { File.join(test_dir, 'input.png') }
466
+ let(:output_file) { File.join(test_dir, 'output.png') }
467
+ let(:gimp_processor) { instance_double(RubySpriter::GimpProcessor) }
468
+ let(:dependency_checker) { instance_double(RubySpriter::DependencyChecker) }
469
+
470
+ before do
471
+ # Mock DependencyChecker for initialization only
472
+ allow(RubySpriter::DependencyChecker).to receive(:new).and_return(dependency_checker)
473
+ allow(dependency_checker).to receive(:check_all).and_return({
474
+ ffmpeg: { available: true },
475
+ gimp: { available: true }
476
+ })
477
+ allow(dependency_checker).to receive(:gimp_path).and_return('/usr/bin/gimp')
478
+ allow(dependency_checker).to receive(:gimp_version).and_return({ major: 3, minor: 0 })
479
+
480
+ # Mock GimpProcessor
481
+ allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_processor)
482
+ allow(gimp_processor).to receive(:process).and_return(output_file)
483
+
484
+ # Mock file operations
485
+ allow(File).to receive(:exist?).and_return(true)
486
+ allow(File).to receive(:delete)
487
+ end
488
+
489
+ it 'does not create additional DependencyChecker instances' do
490
+ processor = described_class.new(options_with_gimp)
491
+
492
+ # DependencyChecker should only be called once during initialization
493
+ expect(RubySpriter::DependencyChecker).not_to receive(:new)
494
+
495
+ processor.send(:process_with_gimp, input_file, { columns: 4, rows: 4 })
496
+ end
497
+
498
+ it 'uses cached gimp_path when creating GimpProcessor' do
499
+ processor = described_class.new(options_with_gimp)
500
+
501
+ expect(RubySpriter::GimpProcessor).to receive(:new).with(
502
+ '/usr/bin/gimp',
503
+ hash_including(gimp_version: { major: 3, minor: 0 })
504
+ ).and_return(gimp_processor)
505
+
506
+ processor.send(:process_with_gimp, input_file, { columns: 4, rows: 4 })
507
+ end
508
+ end
509
+ end