ruby_spriter 0.6.5 → 0.6.6
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/CHANGELOG.md +50 -0
- data/README.md +40 -2
- data/lib/ruby_spriter/cli.rb +16 -0
- data/lib/ruby_spriter/gimp_processor.rb +6 -3
- data/lib/ruby_spriter/processor.rb +253 -23
- data/lib/ruby_spriter/utils/file_helper.rb +25 -0
- data/lib/ruby_spriter/utils/spritesheet_splitter.rb +86 -0
- data/lib/ruby_spriter/version.rb +1 -1
- data/lib/ruby_spriter/video_processor.rb +7 -7
- data/lib/ruby_spriter.rb +1 -0
- data/spec/ruby_spriter/cli_spec.rb +363 -0
- data/spec/ruby_spriter/processor_spec.rb +385 -0
- data/spec/ruby_spriter/utils/file_helper_spec.rb +80 -1
- data/spec/ruby_spriter/utils/spritesheet_splitter_spec.rb +104 -0
- data/spec/ruby_spriter/video_processor_spec.rb +29 -0
- metadata +4 -2
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubySpriter::Processor do
|
|
6
|
+
describe 'default options' do
|
|
7
|
+
it 'sets default max_width to 320' do
|
|
8
|
+
processor = described_class.new
|
|
9
|
+
expect(processor.options[:max_width]).to eq(320)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
describe 'numeric option validation' do
|
|
14
|
+
describe '--frames validation' do
|
|
15
|
+
it 'raises error when frame_count is below minimum (0)' do
|
|
16
|
+
expect {
|
|
17
|
+
described_class.new(frame_count: 0)
|
|
18
|
+
}.to raise_error(RubySpriter::ValidationError, /frame_count must be between 1 and 10000/)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'raises error when frame_count is above maximum (10001)' do
|
|
22
|
+
expect {
|
|
23
|
+
described_class.new(frame_count: 10001)
|
|
24
|
+
}.to raise_error(RubySpriter::ValidationError, /frame_count must be between 1 and 10000/)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe '--columns validation' do
|
|
29
|
+
it 'raises error when columns is below minimum (0)' do
|
|
30
|
+
expect {
|
|
31
|
+
described_class.new(columns: 0)
|
|
32
|
+
}.to raise_error(RubySpriter::ValidationError, /columns must be between 1 and 100/)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'raises error when columns is above maximum (101)' do
|
|
36
|
+
expect {
|
|
37
|
+
described_class.new(columns: 101)
|
|
38
|
+
}.to raise_error(RubySpriter::ValidationError, /columns must be between 1 and 100/)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe '--width validation' do
|
|
43
|
+
it 'raises error when max_width is below minimum (0)' do
|
|
44
|
+
expect {
|
|
45
|
+
described_class.new(max_width: 0)
|
|
46
|
+
}.to raise_error(RubySpriter::ValidationError, /max_width must be between 1 and 1920/)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'raises error when max_width is above maximum (1921)' do
|
|
50
|
+
expect {
|
|
51
|
+
described_class.new(max_width: 1921)
|
|
52
|
+
}.to raise_error(RubySpriter::ValidationError, /max_width must be between 1 and 1920/)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe '--scale validation' do
|
|
57
|
+
it 'raises error when scale_percent is below minimum (0)' do
|
|
58
|
+
expect {
|
|
59
|
+
described_class.new(scale_percent: 0)
|
|
60
|
+
}.to raise_error(RubySpriter::ValidationError, /scale_percent must be between 1 and 500/)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'raises error when scale_percent is above maximum (501)' do
|
|
64
|
+
expect {
|
|
65
|
+
described_class.new(scale_percent: 501)
|
|
66
|
+
}.to raise_error(RubySpriter::ValidationError, /scale_percent must be between 1 and 500/)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
describe '--grow validation' do
|
|
71
|
+
it 'raises error when grow_selection is below minimum (-1)' do
|
|
72
|
+
expect {
|
|
73
|
+
described_class.new(grow_selection: -1)
|
|
74
|
+
}.to raise_error(RubySpriter::ValidationError, /grow_selection must be between 0 and 100/)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'raises error when grow_selection is above maximum (101)' do
|
|
78
|
+
expect {
|
|
79
|
+
described_class.new(grow_selection: 101)
|
|
80
|
+
}.to raise_error(RubySpriter::ValidationError, /grow_selection must be between 0 and 100/)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
describe '--sharpen-radius validation' do
|
|
85
|
+
it 'raises error when sharpen_radius is below minimum (0.0)' do
|
|
86
|
+
expect {
|
|
87
|
+
described_class.new(sharpen_radius: 0.0)
|
|
88
|
+
}.to raise_error(RubySpriter::ValidationError, /sharpen_radius must be between 0.1 and 100.0/)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'raises error when sharpen_radius is above maximum (100.1)' do
|
|
92
|
+
expect {
|
|
93
|
+
described_class.new(sharpen_radius: 100.1)
|
|
94
|
+
}.to raise_error(RubySpriter::ValidationError, /sharpen_radius must be between 0.1 and 100.0/)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
describe '--sharpen-gain validation' do
|
|
99
|
+
it 'raises error when sharpen_gain is below minimum (-0.1)' do
|
|
100
|
+
expect {
|
|
101
|
+
described_class.new(sharpen_gain: -0.1)
|
|
102
|
+
}.to raise_error(RubySpriter::ValidationError, /sharpen_gain must be between 0.0 and 10.0/)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'raises error when sharpen_gain is above maximum (10.1)' do
|
|
106
|
+
expect {
|
|
107
|
+
described_class.new(sharpen_gain: 10.1)
|
|
108
|
+
}.to raise_error(RubySpriter::ValidationError, /sharpen_gain must be between 0.0 and 10.0/)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
describe '--sharpen-threshold validation' do
|
|
113
|
+
it 'raises error when sharpen_threshold is below minimum (-0.1)' do
|
|
114
|
+
expect {
|
|
115
|
+
described_class.new(sharpen_threshold: -0.1)
|
|
116
|
+
}.to raise_error(RubySpriter::ValidationError, /sharpen_threshold must be between 0.0 and 1.0/)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'raises error when sharpen_threshold is above maximum (1.1)' do
|
|
120
|
+
expect {
|
|
121
|
+
described_class.new(sharpen_threshold: 1.1)
|
|
122
|
+
}.to raise_error(RubySpriter::ValidationError, /sharpen_threshold must be between 0.0 and 1.0/)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
describe '--threshold validation' do
|
|
127
|
+
it 'raises error when bg_threshold is below minimum (-0.1)' do
|
|
128
|
+
expect {
|
|
129
|
+
described_class.new(bg_threshold: -0.1)
|
|
130
|
+
}.to raise_error(RubySpriter::ValidationError, /bg_threshold must be between 0.0 and 100.0/)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'raises error when bg_threshold is above maximum (100.1)' do
|
|
134
|
+
expect {
|
|
135
|
+
described_class.new(bg_threshold: 100.1)
|
|
136
|
+
}.to raise_error(RubySpriter::ValidationError, /bg_threshold must be between 0.0 and 100.0/)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
describe '--split validation' do
|
|
142
|
+
describe 'format validation' do
|
|
143
|
+
it 'raises error for invalid split format (missing colon)' do
|
|
144
|
+
expect {
|
|
145
|
+
described_class.new(image: 'test.png', split: '44')
|
|
146
|
+
}.to raise_error(RubySpriter::ValidationError, /Invalid --split format/)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'raises error for invalid split format (non-numeric rows)' do
|
|
150
|
+
expect {
|
|
151
|
+
described_class.new(image: 'test.png', split: 'a:4')
|
|
152
|
+
}.to raise_error(RubySpriter::ValidationError, /Invalid --split format/)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it 'raises error for invalid split format (non-numeric columns)' do
|
|
156
|
+
expect {
|
|
157
|
+
described_class.new(image: 'test.png', split: '4:b')
|
|
158
|
+
}.to raise_error(RubySpriter::ValidationError, /Invalid --split format/)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
describe 'range validation' do
|
|
163
|
+
it 'raises error when rows is below minimum (0)' do
|
|
164
|
+
expect {
|
|
165
|
+
described_class.new(image: 'test.png', split: '0:4')
|
|
166
|
+
}.to raise_error(RubySpriter::ValidationError, /rows must be between 1 and 99/)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it 'raises error when rows is above maximum (100)' do
|
|
170
|
+
expect {
|
|
171
|
+
described_class.new(image: 'test.png', split: '100:4')
|
|
172
|
+
}.to raise_error(RubySpriter::ValidationError, /rows must be between 1 and 99/)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it 'raises error when columns is below minimum (0)' do
|
|
176
|
+
expect {
|
|
177
|
+
described_class.new(image: 'test.png', split: '4:0')
|
|
178
|
+
}.to raise_error(RubySpriter::ValidationError, /columns must be between 1 and 99/)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'raises error when columns is above maximum (100)' do
|
|
182
|
+
expect {
|
|
183
|
+
described_class.new(image: 'test.png', split: '4:100')
|
|
184
|
+
}.to raise_error(RubySpriter::ValidationError, /columns must be between 1 and 99/)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'raises error when total frames >= 1000' do
|
|
188
|
+
expect {
|
|
189
|
+
described_class.new(image: 'test.png', split: '32:32')
|
|
190
|
+
}.to raise_error(RubySpriter::ValidationError, /Total frames \(1024\) must be less than 1000/)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it 'raises error when total frames equals 1000' do
|
|
194
|
+
expect {
|
|
195
|
+
described_class.new(image: 'test.png', split: '20:50')
|
|
196
|
+
}.to raise_error(RubySpriter::ValidationError, /Total frames \(1000\) must be less than 1000/)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it 'allows maximum valid frames (999)' do
|
|
200
|
+
expect {
|
|
201
|
+
described_class.new(image: 'test.png', split: '27:37')
|
|
202
|
+
}.not_to raise_error
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
describe '--split metadata priority logic' do
|
|
208
|
+
let(:temp_dir) { Dir.mktmpdir('test_split_') }
|
|
209
|
+
let(:image_file) { File.join(temp_dir, 'spritesheet.png') }
|
|
210
|
+
|
|
211
|
+
before do
|
|
212
|
+
FileUtils.touch(image_file)
|
|
213
|
+
|
|
214
|
+
allow_any_instance_of(RubySpriter::DependencyChecker).to receive(:check_all).and_return({
|
|
215
|
+
ffmpeg: { available: true },
|
|
216
|
+
ffprobe: { available: true },
|
|
217
|
+
imagemagick: { available: true },
|
|
218
|
+
gimp: { available: true }
|
|
219
|
+
})
|
|
220
|
+
allow_any_instance_of(RubySpriter::DependencyChecker).to receive(:gimp_path).and_return('/usr/bin/gimp')
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
after do
|
|
224
|
+
FileUtils.rm_rf(temp_dir)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
context 'when image has metadata' do
|
|
228
|
+
before do
|
|
229
|
+
allow(RubySpriter::MetadataManager).to receive(:read).with(image_file).and_return({
|
|
230
|
+
columns: 4,
|
|
231
|
+
rows: 4,
|
|
232
|
+
frames: 16
|
|
233
|
+
})
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it 'uses metadata when --split not provided' do
|
|
237
|
+
processor = described_class.new(image: image_file, save_frames: true)
|
|
238
|
+
|
|
239
|
+
splitter = instance_double(RubySpriter::Utils::SpritesheetSplitter)
|
|
240
|
+
allow(RubySpriter::Utils::SpritesheetSplitter).to receive(:new).and_return(splitter)
|
|
241
|
+
expect(splitter).to receive(:split_into_frames).with(image_file, anything, 4, 4, 16)
|
|
242
|
+
|
|
243
|
+
processor.run
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
it 'warns and uses metadata when --split provided without --override-md' do
|
|
247
|
+
processor = described_class.new(image: image_file, split: '5:5')
|
|
248
|
+
|
|
249
|
+
splitter = instance_double(RubySpriter::Utils::SpritesheetSplitter)
|
|
250
|
+
allow(RubySpriter::Utils::SpritesheetSplitter).to receive(:new).and_return(splitter)
|
|
251
|
+
expect(splitter).to receive(:split_into_frames).with(image_file, anything, 4, 4, 16)
|
|
252
|
+
|
|
253
|
+
expect(RubySpriter::Utils::OutputFormatter).to receive(:note).with(/Image has metadata.*Your --split values will be ignored/)
|
|
254
|
+
|
|
255
|
+
processor.run
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it 'uses --split when --override-md provided' do
|
|
259
|
+
processor = described_class.new(image: image_file, split: '5:5', override_md: true)
|
|
260
|
+
|
|
261
|
+
# Mock ImageMagick identify for dimension validation
|
|
262
|
+
allow(Open3).to receive(:capture3).with(/identify/).and_return(
|
|
263
|
+
["500x500\n", '', instance_double(Process::Status, success?: true)]
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
splitter = instance_double(RubySpriter::Utils::SpritesheetSplitter)
|
|
267
|
+
allow(RubySpriter::Utils::SpritesheetSplitter).to receive(:new).and_return(splitter)
|
|
268
|
+
expect(splitter).to receive(:split_into_frames).with(image_file, anything, 5, 5, 25)
|
|
269
|
+
|
|
270
|
+
processor.run
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
context 'when image has no metadata' do
|
|
275
|
+
before do
|
|
276
|
+
allow(RubySpriter::MetadataManager).to receive(:read).with(image_file).and_return(nil)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it 'uses --split values when provided' do
|
|
280
|
+
processor = described_class.new(image: image_file, split: '6:6')
|
|
281
|
+
|
|
282
|
+
# Mock ImageMagick identify for dimension validation
|
|
283
|
+
allow(Open3).to receive(:capture3).with(/identify/).and_return(
|
|
284
|
+
["600x600\n", '', instance_double(Process::Status, success?: true)]
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
splitter = instance_double(RubySpriter::Utils::SpritesheetSplitter)
|
|
288
|
+
allow(RubySpriter::Utils::SpritesheetSplitter).to receive(:new).and_return(splitter)
|
|
289
|
+
expect(splitter).to receive(:split_into_frames).with(image_file, anything, 6, 6, 36)
|
|
290
|
+
|
|
291
|
+
processor.run
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
it 'raises error when --split not provided' do
|
|
295
|
+
processor = described_class.new(image: image_file, save_frames: true)
|
|
296
|
+
|
|
297
|
+
expect {
|
|
298
|
+
processor.run
|
|
299
|
+
}.to raise_error(RubySpriter::ValidationError, /Image has no metadata.*Please provide --split/)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
describe 'frame extraction with --save-frames' do
|
|
305
|
+
let(:temp_dir) { Dir.mktmpdir('test_') }
|
|
306
|
+
let(:video_file) { File.join(temp_dir, 'test.mp4') }
|
|
307
|
+
let(:spritesheet_file) { File.join(temp_dir, 'spritesheet.png') }
|
|
308
|
+
|
|
309
|
+
before do
|
|
310
|
+
FileUtils.touch(video_file)
|
|
311
|
+
FileUtils.touch(spritesheet_file)
|
|
312
|
+
|
|
313
|
+
allow_any_instance_of(RubySpriter::DependencyChecker).to receive(:check_all).and_return({
|
|
314
|
+
ffmpeg: { available: true },
|
|
315
|
+
ffprobe: { available: true },
|
|
316
|
+
imagemagick: { available: true },
|
|
317
|
+
gimp: { available: true }
|
|
318
|
+
})
|
|
319
|
+
allow_any_instance_of(RubySpriter::DependencyChecker).to receive(:gimp_path).and_return('/usr/bin/gimp')
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
after do
|
|
323
|
+
FileUtils.rm_rf(temp_dir)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
it 'splits spritesheet into frames after video processing when save_frames is true' do
|
|
327
|
+
processor = described_class.new(video: video_file, save_frames: true)
|
|
328
|
+
|
|
329
|
+
# The output file will be generated from video filename
|
|
330
|
+
expected_output = File.join(temp_dir, 'test_spritesheet.png')
|
|
331
|
+
|
|
332
|
+
video_processor = instance_double(RubySpriter::VideoProcessor)
|
|
333
|
+
allow(RubySpriter::VideoProcessor).to receive(:new).and_return(video_processor)
|
|
334
|
+
allow(video_processor).to receive(:create_spritesheet).and_return({
|
|
335
|
+
output_file: expected_output,
|
|
336
|
+
columns: 4,
|
|
337
|
+
rows: 4,
|
|
338
|
+
frames: 16
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
splitter = instance_double(RubySpriter::Utils::SpritesheetSplitter)
|
|
342
|
+
allow(RubySpriter::Utils::SpritesheetSplitter).to receive(:new).and_return(splitter)
|
|
343
|
+
expect(splitter).to receive(:split_into_frames).with(expected_output, anything, 4, 4, 16)
|
|
344
|
+
|
|
345
|
+
processor.run
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
it 'splits spritesheet into frames after image processing when save_frames is true' do
|
|
349
|
+
processor = described_class.new(image: spritesheet_file, save_frames: true)
|
|
350
|
+
|
|
351
|
+
# Mock metadata reading
|
|
352
|
+
allow(RubySpriter::MetadataManager).to receive(:read).with(spritesheet_file).and_return({
|
|
353
|
+
columns: 4,
|
|
354
|
+
rows: 4,
|
|
355
|
+
frames: 16
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
splitter = instance_double(RubySpriter::Utils::SpritesheetSplitter)
|
|
359
|
+
allow(RubySpriter::Utils::SpritesheetSplitter).to receive(:new).and_return(splitter)
|
|
360
|
+
expect(splitter).to receive(:split_into_frames).with(spritesheet_file, anything, 4, 4, 16)
|
|
361
|
+
|
|
362
|
+
processor.run
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
it 'does not split frames when save_frames is false' do
|
|
366
|
+
processor = described_class.new(video: video_file, save_frames: false)
|
|
367
|
+
|
|
368
|
+
# The output file will be generated from video filename
|
|
369
|
+
expected_output = File.join(temp_dir, 'test_spritesheet.png')
|
|
370
|
+
|
|
371
|
+
video_processor = instance_double(RubySpriter::VideoProcessor)
|
|
372
|
+
allow(RubySpriter::VideoProcessor).to receive(:new).and_return(video_processor)
|
|
373
|
+
allow(video_processor).to receive(:create_spritesheet).and_return({
|
|
374
|
+
output_file: expected_output,
|
|
375
|
+
columns: 4,
|
|
376
|
+
rows: 4,
|
|
377
|
+
frames: 16
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
expect(RubySpriter::Utils::SpritesheetSplitter).not_to receive(:new)
|
|
381
|
+
|
|
382
|
+
processor.run
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
@@ -62,10 +62,89 @@ RSpec.describe RubySpriter::Utils::FileHelper do
|
|
|
62
62
|
it 'validates file exists and is readable' do
|
|
63
63
|
file = File.join(@test_dir, 'test.txt')
|
|
64
64
|
File.write(file, 'test')
|
|
65
|
-
|
|
65
|
+
|
|
66
66
|
expect {
|
|
67
67
|
described_class.validate_readable!(file)
|
|
68
68
|
}.not_to raise_error
|
|
69
69
|
end
|
|
70
70
|
end
|
|
71
|
+
|
|
72
|
+
describe '.unique_filename' do
|
|
73
|
+
it 'returns original filename when file does not exist' do
|
|
74
|
+
result = described_class.unique_filename('/path/to/output.png')
|
|
75
|
+
expect(result).to eq('/path/to/output.png')
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'adds timestamp when file exists' do
|
|
79
|
+
file = File.join(@test_dir, 'existing.png')
|
|
80
|
+
File.write(file, 'test')
|
|
81
|
+
|
|
82
|
+
result = described_class.unique_filename(file)
|
|
83
|
+
expect(result).to match(/existing_\d{8}_\d{6}_\d{3}\.png$/)
|
|
84
|
+
expect(result).not_to eq(file)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'handles files with multiple dots in name' do
|
|
88
|
+
file = File.join(@test_dir, 'my.sprite.sheet.png')
|
|
89
|
+
File.write(file, 'test')
|
|
90
|
+
|
|
91
|
+
result = described_class.unique_filename(file)
|
|
92
|
+
expect(result).to match(/my\.sprite\.sheet_\d{8}_\d{6}_\d{3}\.png$/)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'preserves directory path' do
|
|
96
|
+
file = File.join(@test_dir, 'subdir', 'output.png')
|
|
97
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
98
|
+
File.write(file, 'test')
|
|
99
|
+
|
|
100
|
+
result = described_class.unique_filename(file)
|
|
101
|
+
expect(result).to start_with(File.join(@test_dir, 'subdir'))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'generates different filenames for consecutive calls' do
|
|
105
|
+
file = File.join(@test_dir, 'test.png')
|
|
106
|
+
File.write(file, 'test')
|
|
107
|
+
|
|
108
|
+
result1 = described_class.unique_filename(file)
|
|
109
|
+
sleep(0.01) # Small delay to ensure different timestamps
|
|
110
|
+
result2 = described_class.unique_filename(file)
|
|
111
|
+
|
|
112
|
+
expect(result1).not_to eq(result2)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
describe '.ensure_unique_output' do
|
|
117
|
+
it 'returns original path when overwrite is true' do
|
|
118
|
+
file = File.join(@test_dir, 'output.png')
|
|
119
|
+
File.write(file, 'test')
|
|
120
|
+
|
|
121
|
+
result = described_class.ensure_unique_output(file, overwrite: true)
|
|
122
|
+
expect(result).to eq(file)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'returns original path when file does not exist and overwrite is false' do
|
|
126
|
+
file = File.join(@test_dir, 'new_output.png')
|
|
127
|
+
|
|
128
|
+
result = described_class.ensure_unique_output(file, overwrite: false)
|
|
129
|
+
expect(result).to eq(file)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'returns unique filename when file exists and overwrite is false' do
|
|
133
|
+
file = File.join(@test_dir, 'existing_output.png')
|
|
134
|
+
File.write(file, 'test')
|
|
135
|
+
|
|
136
|
+
result = described_class.ensure_unique_output(file, overwrite: false)
|
|
137
|
+
expect(result).to match(/existing_output_\d{8}_\d{6}_\d{3}\.png$/)
|
|
138
|
+
expect(result).not_to eq(file)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it 'defaults to overwrite false when not specified' do
|
|
142
|
+
file = File.join(@test_dir, 'default_test.png')
|
|
143
|
+
File.write(file, 'test')
|
|
144
|
+
|
|
145
|
+
result = described_class.ensure_unique_output(file)
|
|
146
|
+
expect(result).not_to eq(file)
|
|
147
|
+
expect(result).to match(/default_test_\d{8}_\d{6}_\d{3}\.png$/)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
71
150
|
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubySpriter::Utils::SpritesheetSplitter do
|
|
6
|
+
describe '#split_into_frames' do
|
|
7
|
+
let(:spritesheet_file) { 'spritesheet.png' }
|
|
8
|
+
let(:output_dir) { '/tmp/frames' }
|
|
9
|
+
let(:columns) { 4 }
|
|
10
|
+
let(:rows) { 4 }
|
|
11
|
+
let(:frames) { 16 }
|
|
12
|
+
let(:tile_width) { 100 }
|
|
13
|
+
let(:tile_height) { 100 }
|
|
14
|
+
|
|
15
|
+
let(:splitter) { described_class.new }
|
|
16
|
+
|
|
17
|
+
before do
|
|
18
|
+
allow(FileUtils).to receive(:mkdir_p)
|
|
19
|
+
allow(File).to receive(:exist?).and_return(true)
|
|
20
|
+
allow(Open3).to receive(:capture3).and_return(['', '', instance_double(Process::Status, success?: true)])
|
|
21
|
+
|
|
22
|
+
# Mock ImageMagick identify to return dimensions
|
|
23
|
+
allow(Open3).to receive(:capture3).with(/identify/).and_return(
|
|
24
|
+
["400x400\n", '', instance_double(Process::Status, success?: true)]
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'creates output directory for frames' do
|
|
29
|
+
expect(FileUtils).to receive(:mkdir_p).with(output_dir)
|
|
30
|
+
|
|
31
|
+
splitter.split_into_frames(spritesheet_file, output_dir, columns, rows, frames)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'extracts each frame with FR prefix and zero-padded numbers' do
|
|
35
|
+
# Expect ImageMagick convert commands for each frame
|
|
36
|
+
expect(Open3).to receive(:capture3).with(/identify/).once.and_return(
|
|
37
|
+
["400x400\n", '', instance_double(Process::Status, success?: true)]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
(1..frames).each do |i|
|
|
41
|
+
expect(Open3).to receive(:capture3).with(/convert.*FR#{format('%03d', i)}_/).and_return(
|
|
42
|
+
['', '', instance_double(Process::Status, success?: true)]
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
splitter.split_into_frames(spritesheet_file, output_dir, columns, rows, frames)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'calculates tile dimensions from spritesheet size and grid' do
|
|
50
|
+
# For 400x400 image with 4x4 grid, tiles should be 100x100
|
|
51
|
+
expect(Open3).to receive(:capture3).with(/identify/).and_return(
|
|
52
|
+
["400x400\n", '', instance_double(Process::Status, success?: true)]
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Check that crop parameters use 100x100
|
|
56
|
+
expect(Open3).to receive(:capture3).with(/100x100\+0\+0/).and_return(
|
|
57
|
+
['', '', instance_double(Process::Status, success?: true)]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
allow(Open3).to receive(:capture3).and_return(['', '', instance_double(Process::Status, success?: true)])
|
|
61
|
+
|
|
62
|
+
splitter.split_into_frames(spritesheet_file, output_dir, columns, rows, 1)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'includes spritesheet basename in frame output names' do
|
|
66
|
+
basename = File.basename(spritesheet_file, '.*')
|
|
67
|
+
|
|
68
|
+
# Mock identify first
|
|
69
|
+
allow(Open3).to receive(:capture3).with(/identify/).and_return(
|
|
70
|
+
["400x400\n", '', instance_double(Process::Status, success?: true)]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Expect convert with frame filename
|
|
74
|
+
expect(Open3).to receive(:capture3).with(/FR001_#{basename}\.png/).and_return(
|
|
75
|
+
['', '', instance_double(Process::Status, success?: true)]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
splitter.split_into_frames(spritesheet_file, output_dir, columns, rows, 1)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'raises ProcessingError when ImageMagick fails' do
|
|
82
|
+
allow(Open3).to receive(:capture3).with(/identify/).and_return(
|
|
83
|
+
["400x400\n", '', instance_double(Process::Status, success?: true)]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
allow(Open3).to receive(:capture3).with(/convert/).and_return(
|
|
87
|
+
['', 'Error message', instance_double(Process::Status, success?: false)]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
expect {
|
|
91
|
+
splitter.split_into_frames(spritesheet_file, output_dir, columns, rows, 1)
|
|
92
|
+
}.to raise_error(RubySpriter::ProcessingError, /Could not extract frame/)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'displays progress information' do
|
|
96
|
+
expect(RubySpriter::Utils::OutputFormatter).to receive(:header).with(/Extracting Frames/)
|
|
97
|
+
expect(RubySpriter::Utils::OutputFormatter).to receive(:indent).with(/Splitting spritesheet into #{frames} frames/)
|
|
98
|
+
expect(RubySpriter::Utils::OutputFormatter).to receive(:indent).with(/Output directory:/)
|
|
99
|
+
expect(RubySpriter::Utils::OutputFormatter).to receive(:indent).with(/Frames extracted successfully/)
|
|
100
|
+
|
|
101
|
+
splitter.split_into_frames(spritesheet_file, output_dir, columns, rows, frames)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubySpriter::VideoProcessor do
|
|
6
|
+
describe '#create_spritesheet' do
|
|
7
|
+
let(:video_file) { 'test_video.mp4' }
|
|
8
|
+
let(:output_file) { 'spritesheet.png' }
|
|
9
|
+
let(:processor) { described_class.new(frame_count: 16, columns: 4) }
|
|
10
|
+
|
|
11
|
+
before do
|
|
12
|
+
allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
|
|
13
|
+
allow(File).to receive(:size).and_return(1000)
|
|
14
|
+
allow(File).to receive(:exist?).and_return(true)
|
|
15
|
+
allow(File).to receive(:delete)
|
|
16
|
+
allow(RubySpriter::MetadataManager).to receive(:embed)
|
|
17
|
+
allow(Open3).to receive(:capture3).and_return(['2.0', '', instance_double(Process::Status, success?: true)])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'creates spritesheet from video file' do
|
|
21
|
+
result = processor.create_spritesheet(video_file, output_file)
|
|
22
|
+
|
|
23
|
+
expect(result[:output_file]).to eq(output_file)
|
|
24
|
+
expect(result[:columns]).to eq(4)
|
|
25
|
+
expect(result[:rows]).to eq(4)
|
|
26
|
+
expect(result[:frames]).to eq(16)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_spriter
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- scooter-indie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-10-
|
|
11
|
+
date: 2025-10-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: |
|
|
14
14
|
Ruby Spriter is a cross-platform tool for creating spritesheets from video files
|
|
@@ -38,6 +38,7 @@ files:
|
|
|
38
38
|
- lib/ruby_spriter/utils/file_helper.rb
|
|
39
39
|
- lib/ruby_spriter/utils/output_formatter.rb
|
|
40
40
|
- lib/ruby_spriter/utils/path_helper.rb
|
|
41
|
+
- lib/ruby_spriter/utils/spritesheet_splitter.rb
|
|
41
42
|
- lib/ruby_spriter/version.rb
|
|
42
43
|
- lib/ruby_spriter/video_processor.rb
|
|
43
44
|
- ruby_spriter.gemspec
|
|
@@ -57,6 +58,7 @@ files:
|
|
|
57
58
|
- spec/ruby_spriter/utils/file_helper_spec.rb
|
|
58
59
|
- spec/ruby_spriter/utils/output_formatter_spec.rb
|
|
59
60
|
- spec/ruby_spriter/utils/path_helper_spec.rb
|
|
61
|
+
- spec/ruby_spriter/utils/spritesheet_splitter_spec.rb
|
|
60
62
|
- spec/ruby_spriter/video_processor_spec.rb
|
|
61
63
|
- spec/spec_helper.rb
|
|
62
64
|
homepage: https://github.com/scooter-indie/ruby-spriter
|