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.
- checksums.yaml +4 -4
- data/.rspec +3 -3
- data/CHANGELOG.md +1035 -524
- data/Gemfile +17 -17
- data/LICENSE +21 -21
- data/README.md +183 -950
- data/bin/ruby_spriter +20 -20
- data/lib/ruby_spriter/background_sampler.rb +140 -0
- data/lib/ruby_spriter/batch_processor.rb +268 -214
- data/lib/ruby_spriter/cell_cleanup_config.rb +21 -0
- data/lib/ruby_spriter/cell_cleanup_gimp_script.rb +123 -0
- data/lib/ruby_spriter/cell_cleanup_processor.rb +230 -0
- data/lib/ruby_spriter/cli.rb +676 -612
- data/lib/ruby_spriter/compression_manager.rb +101 -101
- data/lib/ruby_spriter/consolidator.rb +179 -179
- data/lib/ruby_spriter/dependency_checker.rb +224 -224
- data/lib/ruby_spriter/ghost_edge_cleaner.rb +164 -0
- data/lib/ruby_spriter/gimp_processor.rb +1188 -1058
- data/lib/ruby_spriter/metadata_manager.rb +117 -116
- data/lib/ruby_spriter/platform.rb +137 -137
- data/lib/ruby_spriter/processor.rb +1230 -891
- data/lib/ruby_spriter/smoke_detector.rb +223 -0
- data/lib/ruby_spriter/threshold_stepper.rb +227 -0
- data/lib/ruby_spriter/utils/file_helper.rb +82 -82
- data/lib/ruby_spriter/utils/image_helper.rb +16 -0
- data/lib/ruby_spriter/utils/output_formatter.rb +65 -65
- data/lib/ruby_spriter/utils/path_helper.rb +59 -59
- data/lib/ruby_spriter/utils/spritesheet_splitter.rb +86 -86
- data/lib/ruby_spriter/version.rb +6 -7
- data/lib/ruby_spriter/video_processor.rb +357 -139
- data/lib/ruby_spriter.rb +38 -34
- data/ruby_spriter.gemspec +44 -42
- data/spec/fixtures/centered_sprite_with_inner_bg.png +0 -0
- data/spec/fixtures/centered_sprite_with_inner_bg_inner_removed.png +0 -0
- data/spec/fixtures/centered_sprite_with_inner_bg_threshold_stepped.png +0 -0
- data/spec/fixtures/complex_background_sprite.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing.mp4 +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet-nobg-global.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431-nobg-global.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738-nobg-global.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738.png +0 -0
- data/spec/fixtures/has_inner_bg.png +0 -0
- data/spec/fixtures/has_small_inner_bg.png +0 -0
- data/spec/fixtures/smoke_effect_sprite.png +0 -0
- data/spec/fixtures/spritesheet_with_metadata.png +0 -0
- data/spec/fixtures/test_sprite.png +0 -0
- data/spec/fixtures/test_sprite_smoke_processed.png +0 -0
- data/spec/fixtures/test_video_spritesheet.png +0 -0
- data/spec/fixtures/transparent_bg_sprite.png +0 -0
- data/spec/fixtures/walk_north_sprite-sheet.png +0 -0
- data/spec/integration/no_fuzzy_mode_spec.rb +100 -0
- data/spec/ruby_spriter/batch_processor_spec.rb +509 -200
- data/spec/ruby_spriter/cli_spec.rb +2026 -1892
- data/spec/ruby_spriter/compression_manager_spec.rb +157 -157
- data/spec/ruby_spriter/consolidator_spec.rb +538 -538
- data/spec/ruby_spriter/gimp_processor_spec.rb +523 -425
- data/spec/ruby_spriter/platform_spec.rb +92 -92
- data/spec/ruby_spriter/processor_spec.rb +911 -735
- data/spec/ruby_spriter/utils/file_helper_spec.rb +150 -150
- data/spec/ruby_spriter/utils/path_helper_spec.rb +78 -78
- data/spec/ruby_spriter/utils/spritesheet_splitter_spec.rb +104 -104
- data/spec/ruby_spriter/video_processor_spec.rb +346 -29
- data/spec/spec_helper.rb +41 -41
- data/spec/tmp/cli_test_output.png +0 -0
- data/spec/tmp/cli_test_output_20251105_114647_395.png +0 -0
- data/spec/tmp/combined_test.png +0 -0
- data/spec/tmp/compat_test.png +0 -0
- data/spec/tmp/compat_test_20251030_174233_361.png +0 -0
- data/spec/tmp/final_all_features.png +0 -0
- data/spec/tmp/final_test_all_features.png +0 -0
- data/spec/tmp/full_pipeline_test.png +0 -0
- data/spec/tmp/inner_test.png +0 -0
- data/spec/tmp/integration_test.png +0 -0
- data/spec/tmp/validation_test.png +0 -0
- data/spec/unit/background_sampler_spec.rb +132 -0
- data/spec/unit/cell_cleanup_config_spec.rb +32 -0
- data/spec/unit/cell_cleanup_gimp_script_spec.rb +59 -0
- data/spec/unit/cell_cleanup_processor_spec.rb +261 -0
- data/spec/unit/ghost_edge_cleaner_spec.rb +223 -0
- data/spec/unit/gimp_processor_single_point_selection_spec.rb +81 -0
- data/spec/unit/smoke_detector_spec.rb +246 -0
- data/spec/unit/threshold_stepper_spec.rb +195 -0
- metadata +56 -10
|
@@ -1,29 +1,346 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'spec_helper'
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
let(:
|
|
9
|
-
let(:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
allow(
|
|
14
|
-
allow(File).to receive(:
|
|
15
|
-
allow(File).to receive(:
|
|
16
|
-
allow(
|
|
17
|
-
allow(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
expect(result[:
|
|
25
|
-
expect(result[:
|
|
26
|
-
expect(result[:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require_relative '../../lib/ruby_spriter/cell_cleanup_processor'
|
|
5
|
+
|
|
6
|
+
RSpec.describe RubySpriter::VideoProcessor do
|
|
7
|
+
describe '#create_spritesheet' do
|
|
8
|
+
let(:video_file) { 'test_video.mp4' }
|
|
9
|
+
let(:output_file) { 'spritesheet.png' }
|
|
10
|
+
let(:processor) { described_class.new(frame_count: 16, columns: 4) }
|
|
11
|
+
|
|
12
|
+
before do
|
|
13
|
+
allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
|
|
14
|
+
allow(File).to receive(:size).and_return(1000)
|
|
15
|
+
allow(File).to receive(:exist?).and_return(true)
|
|
16
|
+
allow(File).to receive(:delete)
|
|
17
|
+
allow(RubySpriter::MetadataManager).to receive(:embed)
|
|
18
|
+
allow(Open3).to receive(:capture3).and_return(['2.0', '', instance_double(Process::Status, success?: true)])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'creates spritesheet from video file' do
|
|
22
|
+
result = processor.create_spritesheet(video_file, output_file)
|
|
23
|
+
|
|
24
|
+
expect(result[:output_file]).to eq(output_file)
|
|
25
|
+
expect(result[:columns]).to eq(4)
|
|
26
|
+
expect(result[:rows]).to eq(4)
|
|
27
|
+
expect(result[:frames]).to eq(16)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe '#process_with_background_removal' do
|
|
33
|
+
let(:video_processor) { described_class.new }
|
|
34
|
+
let(:temp_dir) { 'temp_test_dir' }
|
|
35
|
+
let(:video_path) { 'test_video.mp4' }
|
|
36
|
+
let(:output_path) { 'output_spritesheet.png' }
|
|
37
|
+
|
|
38
|
+
before do
|
|
39
|
+
allow(Dir).to receive(:mktmpdir).and_return(temp_dir)
|
|
40
|
+
allow(FileUtils).to receive(:rm_rf)
|
|
41
|
+
|
|
42
|
+
# Mock file operations for metadata embedding
|
|
43
|
+
allow(FileUtils).to receive(:mv)
|
|
44
|
+
allow(File).to receive(:delete)
|
|
45
|
+
allow(File).to receive(:exist?).and_return(true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
context 'when by_frame option is true' do
|
|
49
|
+
let(:options) do
|
|
50
|
+
{
|
|
51
|
+
by_frame: true,
|
|
52
|
+
remove_bg: true,
|
|
53
|
+
frames: 4,
|
|
54
|
+
columns: 2,
|
|
55
|
+
gimp_path: '/usr/bin/gimp'
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'processes each frame individually with background removal' do
|
|
60
|
+
# Mock frame extraction
|
|
61
|
+
frame_files = ['frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png']
|
|
62
|
+
allow(video_processor).to receive(:extract_frames).and_return(frame_files)
|
|
63
|
+
|
|
64
|
+
# Mock background removal for each frame
|
|
65
|
+
gimp_processor = instance_double(RubySpriter::GimpProcessor)
|
|
66
|
+
allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_processor)
|
|
67
|
+
|
|
68
|
+
# Expect .process() to be called for EACH frame (4 times)
|
|
69
|
+
# process() returns the output path (same as input for simplicity)
|
|
70
|
+
allow(gimp_processor).to receive(:process) do |input_path|
|
|
71
|
+
# Return the _nobg version of the input path
|
|
72
|
+
input_path.sub('.png', '_nobg.png')
|
|
73
|
+
end
|
|
74
|
+
expect(gimp_processor).to receive(:process).exactly(4).times
|
|
75
|
+
|
|
76
|
+
# Mock spritesheet assembly
|
|
77
|
+
allow(video_processor).to receive(:assemble_spritesheet_from_frames)
|
|
78
|
+
|
|
79
|
+
# Mock metadata
|
|
80
|
+
allow(RubySpriter::MetadataManager).to receive(:embed)
|
|
81
|
+
|
|
82
|
+
video_processor.process_with_background_removal(video_path, output_path, options)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'displays progress indicator for each frame' do
|
|
86
|
+
frame_files = ['frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png']
|
|
87
|
+
allow(video_processor).to receive(:extract_frames).and_return(frame_files)
|
|
88
|
+
|
|
89
|
+
gimp_processor = instance_double(RubySpriter::GimpProcessor)
|
|
90
|
+
allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_processor)
|
|
91
|
+
|
|
92
|
+
# Mock .process() to return output path
|
|
93
|
+
allow(gimp_processor).to receive(:process) do |input_path|
|
|
94
|
+
input_path.sub('.png', '_nobg.png')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
allow(video_processor).to receive(:assemble_spritesheet_from_frames)
|
|
98
|
+
allow(RubySpriter::MetadataManager).to receive(:embed)
|
|
99
|
+
|
|
100
|
+
# Verify progress messages are displayed (use regex that matches any frame number)
|
|
101
|
+
expect { video_processor.process_with_background_removal(video_path, output_path, options) }
|
|
102
|
+
.to output(/Processing frame \d+\/4.*Processing frame \d+\/4.*Processing frame \d+\/4.*Processing frame \d+\/4/m).to_stdout
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'passes temp_dir in options to assemble_spritesheet_from_frames' do
|
|
106
|
+
allow(video_processor).to receive(:extract_frames).and_return(['frame_001.png'])
|
|
107
|
+
allow(video_processor).to receive(:process_frames_individually)
|
|
108
|
+
allow(RubySpriter::MetadataManager).to receive(:embed)
|
|
109
|
+
|
|
110
|
+
expect(video_processor).to receive(:assemble_spritesheet_from_frames) do |frames, output, opts|
|
|
111
|
+
expect(opts[:temp_dir]).to be_a(String)
|
|
112
|
+
expect(opts[:temp_dir]).to eq('temp_test_dir')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
video_processor.process_with_background_removal(video_path, output_path, options)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
context 'when by_frame option is false' do
|
|
120
|
+
let(:options) do
|
|
121
|
+
{
|
|
122
|
+
by_frame: false,
|
|
123
|
+
remove_bg: true,
|
|
124
|
+
frames: 4,
|
|
125
|
+
columns: 2,
|
|
126
|
+
gimp_path: '/usr/bin/gimp'
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it 'processes the entire spritesheet at once (existing behavior)' do
|
|
131
|
+
# Mock frame extraction
|
|
132
|
+
frame_files = ['frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png']
|
|
133
|
+
allow(video_processor).to receive(:extract_frames).and_return(frame_files)
|
|
134
|
+
|
|
135
|
+
# Mock spritesheet assembly
|
|
136
|
+
allow(video_processor).to receive(:assemble_spritesheet_from_frames)
|
|
137
|
+
|
|
138
|
+
# Mock background removal for spritesheet (called ONCE, not per frame)
|
|
139
|
+
gimp_processor = instance_double(RubySpriter::GimpProcessor)
|
|
140
|
+
allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_processor)
|
|
141
|
+
|
|
142
|
+
# Mock .process() to return the same path (no change)
|
|
143
|
+
allow(gimp_processor).to receive(:process).with(output_path).and_return(output_path)
|
|
144
|
+
expect(gimp_processor).to receive(:process).once
|
|
145
|
+
|
|
146
|
+
# Mock metadata
|
|
147
|
+
allow(RubySpriter::MetadataManager).to receive(:embed)
|
|
148
|
+
|
|
149
|
+
video_processor.process_with_background_removal(video_path, output_path, options)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
context 'with cell cleanup enabled' do
|
|
153
|
+
let(:cleanup_options) do
|
|
154
|
+
{
|
|
155
|
+
by_frame: false,
|
|
156
|
+
remove_bg: true,
|
|
157
|
+
cleanup_cells: true,
|
|
158
|
+
frames: 4,
|
|
159
|
+
columns: 2,
|
|
160
|
+
gimp_path: '/usr/bin/gimp',
|
|
161
|
+
cell_cleanup_threshold: 15.0
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'applies cell cleanup after background removal' do
|
|
166
|
+
# Mock frame extraction
|
|
167
|
+
frame_files = ['frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png']
|
|
168
|
+
allow(video_processor).to receive(:extract_frames).and_return(frame_files)
|
|
169
|
+
|
|
170
|
+
# Mock spritesheet assembly
|
|
171
|
+
allow(video_processor).to receive(:assemble_spritesheet_from_frames)
|
|
172
|
+
|
|
173
|
+
# Mock GIMP processing
|
|
174
|
+
gimp_processor = instance_double(RubySpriter::GimpProcessor)
|
|
175
|
+
allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_processor)
|
|
176
|
+
allow(gimp_processor).to receive(:process).and_return(output_path)
|
|
177
|
+
|
|
178
|
+
# Mock cell cleanup
|
|
179
|
+
mock_cell_processor = instance_double(RubySpriter::CellCleanupProcessor)
|
|
180
|
+
expect(RubySpriter::CellCleanupProcessor).to receive(:new)
|
|
181
|
+
.with(cleanup_options)
|
|
182
|
+
.and_return(mock_cell_processor)
|
|
183
|
+
expect(mock_cell_processor).to receive(:cleanup_cells)
|
|
184
|
+
.with(output_path, cleanup_options)
|
|
185
|
+
.and_return({ processed: 4, cleaned: 2, skipped: 2 })
|
|
186
|
+
|
|
187
|
+
# Mock metadata
|
|
188
|
+
allow(RubySpriter::MetadataManager).to receive(:embed)
|
|
189
|
+
|
|
190
|
+
video_processor.process_with_background_removal(video_path, output_path, cleanup_options)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it 'skips cell cleanup when flag is false' do
|
|
194
|
+
# Mock frame extraction
|
|
195
|
+
frame_files = ['frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png']
|
|
196
|
+
allow(video_processor).to receive(:extract_frames).and_return(frame_files)
|
|
197
|
+
|
|
198
|
+
# Mock spritesheet assembly
|
|
199
|
+
allow(video_processor).to receive(:assemble_spritesheet_from_frames)
|
|
200
|
+
|
|
201
|
+
# Mock GIMP processing
|
|
202
|
+
gimp_processor = instance_double(RubySpriter::GimpProcessor)
|
|
203
|
+
allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_processor)
|
|
204
|
+
allow(gimp_processor).to receive(:process).and_return(output_path)
|
|
205
|
+
|
|
206
|
+
# Should NOT instantiate CellCleanupProcessor
|
|
207
|
+
expect(RubySpriter::CellCleanupProcessor).not_to receive(:new)
|
|
208
|
+
|
|
209
|
+
# Mock metadata
|
|
210
|
+
allow(RubySpriter::MetadataManager).to receive(:embed)
|
|
211
|
+
|
|
212
|
+
no_cleanup_options = cleanup_options.merge(cleanup_cells: false)
|
|
213
|
+
video_processor.process_with_background_removal(video_path, output_path, no_cleanup_options)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
describe '#extract_frames' do
|
|
219
|
+
let(:video_processor) { described_class.new }
|
|
220
|
+
let(:video_file) { 'test_video.mp4' }
|
|
221
|
+
let(:temp_dir) { 'temp_frames' }
|
|
222
|
+
let(:options) { { frames: 16, max_width: 320, debug: false } }
|
|
223
|
+
|
|
224
|
+
before do
|
|
225
|
+
# Mock get_duration to return 2.0 seconds
|
|
226
|
+
allow(video_processor).to receive(:get_duration).and_return(2.0)
|
|
227
|
+
|
|
228
|
+
# Mock Open3.capture3 for FFmpeg execution
|
|
229
|
+
allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
it 'extracts frames from video using FFmpeg' do
|
|
233
|
+
expect(Open3).to receive(:capture3) do |cmd|
|
|
234
|
+
expect(cmd).to include('ffmpeg')
|
|
235
|
+
expect(cmd).to include('-i')
|
|
236
|
+
expect(cmd).to include(video_file)
|
|
237
|
+
expect(cmd).to include('fps=8.0') # 16 frames / 2.0 seconds
|
|
238
|
+
expect(cmd).to include('scale=320:-1')
|
|
239
|
+
expect(cmd).to include('-frames:v 16')
|
|
240
|
+
['', '', double(success?: true)]
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
result = video_processor.send(:extract_frames, video_file, temp_dir, options)
|
|
244
|
+
|
|
245
|
+
expect(result).to be_an(Array)
|
|
246
|
+
expect(result.length).to eq(16)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it 'returns array of frame filenames' do
|
|
250
|
+
result = video_processor.send(:extract_frames, video_file, temp_dir, options)
|
|
251
|
+
|
|
252
|
+
expect(result).to eq([
|
|
253
|
+
'frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png',
|
|
254
|
+
'frame_005.png', 'frame_006.png', 'frame_007.png', 'frame_008.png',
|
|
255
|
+
'frame_009.png', 'frame_010.png', 'frame_011.png', 'frame_012.png',
|
|
256
|
+
'frame_013.png', 'frame_014.png', 'frame_015.png', 'frame_016.png'
|
|
257
|
+
])
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
it 'raises ProcessingError if FFmpeg fails' do
|
|
261
|
+
allow(Open3).to receive(:capture3).and_return(['', 'FFmpeg error', double(success?: false)])
|
|
262
|
+
|
|
263
|
+
expect {
|
|
264
|
+
video_processor.send(:extract_frames, video_file, temp_dir, options)
|
|
265
|
+
}.to raise_error(RubySpriter::ProcessingError, /Failed to extract frames/)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
describe '#assemble_spritesheet_from_frames' do
|
|
269
|
+
let(:video_processor) { described_class.new }
|
|
270
|
+
let(:frame_files) { ['frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png'] }
|
|
271
|
+
let(:output_path) { 'output_spritesheet.png' }
|
|
272
|
+
let(:temp_dir) { 'temp_frames' }
|
|
273
|
+
let(:options) { { columns: 2, temp_dir: temp_dir, debug: false } }
|
|
274
|
+
|
|
275
|
+
before do
|
|
276
|
+
# Mock Open3.capture3 for FFmpeg execution
|
|
277
|
+
allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
|
|
278
|
+
|
|
279
|
+
# Mock file validation
|
|
280
|
+
allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
it 'assembles frames into spritesheet using FFmpeg tile filter' do
|
|
284
|
+
expect(Open3).to receive(:capture3) do |cmd|
|
|
285
|
+
expect(cmd).to include('ffmpeg')
|
|
286
|
+
expect(cmd).to include('-i')
|
|
287
|
+
expect(cmd).to include('frame_%03d.png')
|
|
288
|
+
expect(cmd).to include('tile=2x2') # 4 frames / 2 columns = 2 rows
|
|
289
|
+
expect(cmd).to include('-frames:v 1')
|
|
290
|
+
expect(cmd).to include(output_path)
|
|
291
|
+
['', '', double(success?: true)]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
video_processor.send(:assemble_spritesheet_from_frames, frame_files, output_path, options)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
it 'calculates rows correctly from frame count and columns' do
|
|
298
|
+
# 4 frames / 2 columns = 2 rows
|
|
299
|
+
expect(Open3).to receive(:capture3) do |cmd|
|
|
300
|
+
expect(cmd).to include('tile=2x2')
|
|
301
|
+
['', '', double(success?: true)]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
video_processor.send(:assemble_spritesheet_from_frames, frame_files, output_path, options)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
it 'handles non-evenly divisible frame counts with ceiling' do
|
|
308
|
+
# 5 frames / 2 columns = 2.5 → 3 rows (ceiling)
|
|
309
|
+
frame_files_odd = ['frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png', 'frame_005.png']
|
|
310
|
+
|
|
311
|
+
expect(Open3).to receive(:capture3) do |cmd|
|
|
312
|
+
expect(cmd).to include('tile=2x3')
|
|
313
|
+
['', '', double(success?: true)]
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
video_processor.send(:assemble_spritesheet_from_frames, frame_files_odd, output_path, options)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
it 'raises ProcessingError if FFmpeg fails' do
|
|
320
|
+
allow(Open3).to receive(:capture3).and_return(['', 'FFmpeg error', double(success?: false)])
|
|
321
|
+
|
|
322
|
+
expect {
|
|
323
|
+
video_processor.send(:assemble_spritesheet_from_frames, frame_files, output_path, options)
|
|
324
|
+
}.to raise_error(RubySpriter::ProcessingError, /Failed to assemble spritesheet/)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
it 'validates output file exists after assembly' do
|
|
328
|
+
expect(RubySpriter::Utils::FileHelper).to receive(:validate_exists!).with(output_path)
|
|
329
|
+
|
|
330
|
+
video_processor.send(:assemble_spritesheet_from_frames, frame_files, output_path, options)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
it 'handles frame files with _nobg suffix' do
|
|
334
|
+
frame_files_nobg = ['frame_001_nobg.png', 'frame_002_nobg.png', 'frame_003_nobg.png', 'frame_004_nobg.png']
|
|
335
|
+
|
|
336
|
+
expect(Open3).to receive(:capture3) do |cmd|
|
|
337
|
+
expect(cmd).to include('frame_%03d_nobg.png')
|
|
338
|
+
expect(cmd).to include('tile=2x2')
|
|
339
|
+
['', '', double(success?: true)]
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
video_processor.send(:assemble_spritesheet_from_frames, frame_files_nobg, output_path, options)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'simplecov'
|
|
4
|
-
SimpleCov.start do
|
|
5
|
-
add_filter '/spec/'
|
|
6
|
-
add_filter '/vendor/'
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
require 'ruby_spriter'
|
|
10
|
-
|
|
11
|
-
RSpec.configure do |config|
|
|
12
|
-
# Enable flags like --only-failures and --next-failure
|
|
13
|
-
config.example_status_persistence_file_path = '.rspec_status'
|
|
14
|
-
|
|
15
|
-
# Disable RSpec exposing methods globally on `Module` and `main`
|
|
16
|
-
config.disable_monkey_patching!
|
|
17
|
-
|
|
18
|
-
config.expect_with :rspec do |c|
|
|
19
|
-
c.syntax = :expect
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Helpers for creating temp files in tests
|
|
23
|
-
config.before(:suite) do
|
|
24
|
-
$test_temp_dir = File.join(Dir.tmpdir, 'ruby_spriter_test')
|
|
25
|
-
FileUtils.mkdir_p($test_temp_dir)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
config.after(:suite) do
|
|
29
|
-
FileUtils.rm_rf($test_temp_dir) if $test_temp_dir && File.exist?($test_temp_dir)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Make test temp directory available to all specs
|
|
33
|
-
config.before(:each) do
|
|
34
|
-
@test_dir = File.join($test_temp_dir, "test_#{Time.now.to_i}_#{rand(10000)}")
|
|
35
|
-
FileUtils.mkdir_p(@test_dir)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
config.after(:each) do
|
|
39
|
-
FileUtils.rm_rf(@test_dir) if @test_dir && File.exist?(@test_dir)
|
|
40
|
-
end
|
|
41
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'simplecov'
|
|
4
|
+
SimpleCov.start do
|
|
5
|
+
add_filter '/spec/'
|
|
6
|
+
add_filter '/vendor/'
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require 'ruby_spriter'
|
|
10
|
+
|
|
11
|
+
RSpec.configure do |config|
|
|
12
|
+
# Enable flags like --only-failures and --next-failure
|
|
13
|
+
config.example_status_persistence_file_path = '.rspec_status'
|
|
14
|
+
|
|
15
|
+
# Disable RSpec exposing methods globally on `Module` and `main`
|
|
16
|
+
config.disable_monkey_patching!
|
|
17
|
+
|
|
18
|
+
config.expect_with :rspec do |c|
|
|
19
|
+
c.syntax = :expect
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Helpers for creating temp files in tests
|
|
23
|
+
config.before(:suite) do
|
|
24
|
+
$test_temp_dir = File.join(Dir.tmpdir, 'ruby_spriter_test')
|
|
25
|
+
FileUtils.mkdir_p($test_temp_dir)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
config.after(:suite) do
|
|
29
|
+
FileUtils.rm_rf($test_temp_dir) if $test_temp_dir && File.exist?($test_temp_dir)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Make test temp directory available to all specs
|
|
33
|
+
config.before(:each) do
|
|
34
|
+
@test_dir = File.join($test_temp_dir, "test_#{Time.now.to_i}_#{rand(10000)}")
|
|
35
|
+
FileUtils.mkdir_p(@test_dir)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
config.after(:each) do
|
|
39
|
+
FileUtils.rm_rf(@test_dir) if @test_dir && File.exist?(@test_dir)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'ruby_spriter/background_sampler'
|
|
3
|
+
|
|
4
|
+
RSpec.describe RubySpriter::BackgroundSampler do
|
|
5
|
+
let(:image_path) { 'spec/fixtures/walk_north_sprite-sheet.png' }
|
|
6
|
+
let(:sample_offset) { 5 }
|
|
7
|
+
let(:sample_count) { 10 }
|
|
8
|
+
let(:max_rows) { 20 }
|
|
9
|
+
|
|
10
|
+
subject { described_class.new(image_path, sample_offset, sample_count, max_rows) }
|
|
11
|
+
|
|
12
|
+
describe '#initialize' do
|
|
13
|
+
it 'sets the image path' do
|
|
14
|
+
expect(subject.image_path).to eq(image_path)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'sets the sample offset' do
|
|
18
|
+
expect(subject.sample_offset).to eq(5)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'sets the sample count' do
|
|
22
|
+
expect(subject.sample_count).to eq(10)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'sets the max rows' do
|
|
26
|
+
expect(subject.max_rows).to eq(20)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe '#collect_unique_colors' do
|
|
31
|
+
it 'returns an array of unique RGB color hashes' do
|
|
32
|
+
colors = subject.collect_unique_colors
|
|
33
|
+
|
|
34
|
+
expect(colors).to be_an(Array)
|
|
35
|
+
# May return fewer than sample_count if image has limited unique colors
|
|
36
|
+
expect(colors.length).to be > 0
|
|
37
|
+
|
|
38
|
+
colors.each do |color|
|
|
39
|
+
expect(color).to have_key(:r)
|
|
40
|
+
expect(color).to have_key(:g)
|
|
41
|
+
expect(color).to have_key(:b)
|
|
42
|
+
expect(color[:r]).to be_between(0, 255)
|
|
43
|
+
expect(color[:g]).to be_between(0, 255)
|
|
44
|
+
expect(color[:b]).to be_between(0, 255)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'collects up to the specified number of unique colors' do
|
|
49
|
+
colors = subject.collect_unique_colors
|
|
50
|
+
|
|
51
|
+
expect(colors.length).to be <= sample_count
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'returns only unique colors (no duplicates)' do
|
|
55
|
+
colors = subject.collect_unique_colors
|
|
56
|
+
|
|
57
|
+
# Convert to strings for comparison
|
|
58
|
+
color_strings = colors.map { |c| "#{c[:r]},#{c[:g]},#{c[:b]}" }
|
|
59
|
+
expect(color_strings.uniq.length).to eq(colors.length)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'samples starting at offset pixels from edge' do
|
|
63
|
+
# Mock ImageMagick calls to verify coordinates
|
|
64
|
+
allow(Open3).to receive(:capture3) do |cmd|
|
|
65
|
+
# Extract x,y from command like: magick <path> -format "%[pixel:p{5,5}]" info:
|
|
66
|
+
if cmd =~ /pixel:p\{(\d+),(\d+)\}/
|
|
67
|
+
x = $1.to_i
|
|
68
|
+
y = $2.to_i
|
|
69
|
+
|
|
70
|
+
# Verify x and y are at least offset pixels from edge
|
|
71
|
+
expect(x).to be >= sample_offset
|
|
72
|
+
expect(y).to be >= sample_offset
|
|
73
|
+
|
|
74
|
+
# Return mock color
|
|
75
|
+
["srgb(255,255,255)", "", double(success?: true)]
|
|
76
|
+
else
|
|
77
|
+
["100 100", "", double(success?: true)] # Dimensions
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
subject.collect_unique_colors
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'moves to next row if not enough unique colors found in first row' do
|
|
85
|
+
# Create a sampler that needs more colors than available in one row
|
|
86
|
+
sampler = described_class.new(image_path, 5, 10, 20)
|
|
87
|
+
|
|
88
|
+
colors = sampler.collect_unique_colors
|
|
89
|
+
|
|
90
|
+
# Should have collected some colors (exact count depends on image)
|
|
91
|
+
expect(colors).to be_an(Array)
|
|
92
|
+
expect(colors.length).to be > 0
|
|
93
|
+
|
|
94
|
+
# All colors should be unique
|
|
95
|
+
color_strings = colors.map { |c| "#{c[:r]},#{c[:g]},#{c[:b]}" }
|
|
96
|
+
expect(color_strings.uniq.length).to eq(colors.length)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
describe '#sample_pixel' do
|
|
101
|
+
it 'uses ImageMagick to get pixel color at specific coordinates' do
|
|
102
|
+
# Matches both 'magick' (Windows) and 'convert' (Unix)
|
|
103
|
+
expect(Open3).to receive(:capture3).with(/(magick|convert).*pixel:p\{10,10\}/).and_return(
|
|
104
|
+
["srgb(128,64,32)", "", double(success?: true)]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
color = subject.send(:sample_pixel, 10, 10)
|
|
108
|
+
|
|
109
|
+
expect(color).to eq({ r: 128, g: 64, b: 32 })
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it 'returns nil if ImageMagick command fails' do
|
|
113
|
+
allow(Open3).to receive(:capture3).and_return(
|
|
114
|
+
["", "error", double(success?: false)]
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
color = subject.send(:sample_pixel, 10, 10)
|
|
118
|
+
|
|
119
|
+
expect(color).to be_nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'handles grayscale images' do
|
|
123
|
+
allow(Open3).to receive(:capture3).and_return(
|
|
124
|
+
["gray(128)", "", double(success?: true)]
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
color = subject.send(:sample_pixel, 10, 10)
|
|
128
|
+
|
|
129
|
+
expect(color).to eq({ r: 128, g: 128, b: 128 })
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require_relative '../../lib/ruby_spriter/cell_cleanup_config'
|
|
3
|
+
|
|
4
|
+
RSpec.describe RubySpriter::CellCleanupConfig do
|
|
5
|
+
describe '#initialize' do
|
|
6
|
+
context 'with default options' do
|
|
7
|
+
it 'sets threshold to 15.0' do
|
|
8
|
+
config = described_class.new
|
|
9
|
+
expect(config.threshold).to eq(15.0)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
context 'with custom threshold' do
|
|
14
|
+
it 'accepts valid custom threshold' do
|
|
15
|
+
config = described_class.new(cell_cleanup_threshold: 20.0)
|
|
16
|
+
expect(config.threshold).to eq(20.0)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
context 'with invalid threshold' do
|
|
21
|
+
it 'raises error when threshold is too low' do
|
|
22
|
+
expect { described_class.new(cell_cleanup_threshold: 0.5) }
|
|
23
|
+
.to raise_error(RubySpriter::ValidationError, /between 1.0 and 50.0/)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'raises error when threshold is too high' do
|
|
27
|
+
expect { described_class.new(cell_cleanup_threshold: 55.0) }
|
|
28
|
+
.to raise_error(RubySpriter::ValidationError, /between 1.0 and 50.0/)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|