ruby_spriter 0.6.7 → 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 -405
- data/Gemfile +17 -17
- data/LICENSE +21 -21
- data/README.md +183 -902
- data/bin/ruby_spriter +20 -20
- data/lib/ruby_spriter/background_sampler.rb +140 -0
- data/lib/ruby_spriter/batch_processor.rb +268 -212
- 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 -174
- data/lib/ruby_spriter/ghost_edge_cleaner.rb +164 -0
- data/lib/ruby_spriter/gimp_processor.rb +1188 -667
- data/lib/ruby_spriter/metadata_manager.rb +117 -116
- data/lib/ruby_spriter/platform.rb +137 -82
- data/lib/ruby_spriter/processor.rb +1230 -886
- 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 -82
- 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,886 +1,1230 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'fileutils'
|
|
4
|
-
require 'tmpdir'
|
|
5
|
-
require 'open3'
|
|
6
|
-
|
|
7
|
-
module RubySpriter
|
|
8
|
-
# Main orchestration processor
|
|
9
|
-
class Processor
|
|
10
|
-
attr_reader :options, :gimp_path, :split_rows, :split_columns
|
|
11
|
-
|
|
12
|
-
# Valid ranges for numeric options
|
|
13
|
-
VALID_RANGES = {
|
|
14
|
-
frame_count: { min: 1, max: 10000, type: Integer },
|
|
15
|
-
columns: { min: 1, max: 100, type: Integer },
|
|
16
|
-
max_width: { min: 1, max: 1920, type: Integer },
|
|
17
|
-
scale_percent: { min: 1, max: 500, type: Integer },
|
|
18
|
-
grow_selection: { min: 0, max: 100, type: Integer },
|
|
19
|
-
sharpen_radius: { min: 0.1, max: 100.0, type: Float },
|
|
20
|
-
sharpen_gain: { min: 0.0, max: 10.0, type: Float },
|
|
21
|
-
sharpen_threshold: { min: 0.0, max: 1.0, type: Float },
|
|
22
|
-
bg_threshold: { min: 0.0, max: 100.0, type: Float }
|
|
23
|
-
}.freeze
|
|
24
|
-
|
|
25
|
-
def initialize(options = {})
|
|
26
|
-
@options = default_options.merge(options)
|
|
27
|
-
@gimp_path = nil
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if
|
|
110
|
-
raise ValidationError, "
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
unless
|
|
258
|
-
raise ValidationError, "
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
if
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
raise ValidationError, "
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
#
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
unless
|
|
315
|
-
raise
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
options[:
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
if
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
#
|
|
543
|
-
if
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
end
|
|
551
|
-
|
|
552
|
-
#
|
|
553
|
-
if options[:output]
|
|
554
|
-
final_output = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
end
|
|
562
|
-
|
|
563
|
-
#
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
#
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
#
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
end
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'open3'
|
|
6
|
+
|
|
7
|
+
module RubySpriter
|
|
8
|
+
# Main orchestration processor
|
|
9
|
+
class Processor
|
|
10
|
+
attr_reader :options, :gimp_path, :split_rows, :split_columns
|
|
11
|
+
|
|
12
|
+
# Valid ranges for numeric options
|
|
13
|
+
VALID_RANGES = {
|
|
14
|
+
frame_count: { min: 1, max: 10000, type: Integer },
|
|
15
|
+
columns: { min: 1, max: 100, type: Integer },
|
|
16
|
+
max_width: { min: 1, max: 1920, type: Integer },
|
|
17
|
+
scale_percent: { min: 1, max: 500, type: Integer },
|
|
18
|
+
grow_selection: { min: 0, max: 100, type: Integer },
|
|
19
|
+
sharpen_radius: { min: 0.1, max: 100.0, type: Float },
|
|
20
|
+
sharpen_gain: { min: 0.0, max: 10.0, type: Float },
|
|
21
|
+
sharpen_threshold: { min: 0.0, max: 1.0, type: Float },
|
|
22
|
+
bg_threshold: { min: 0.0, max: 100.0, type: Float }
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(options = {})
|
|
26
|
+
@options = default_options.merge(options)
|
|
27
|
+
@gimp_path = nil
|
|
28
|
+
@gimp_version = nil
|
|
29
|
+
validate_numeric_options!
|
|
30
|
+
validate_split_option!
|
|
31
|
+
validate_extract_option!
|
|
32
|
+
validate_add_meta_option!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Run the processing workflow
|
|
36
|
+
def run
|
|
37
|
+
validate_options!
|
|
38
|
+
check_dependencies!
|
|
39
|
+
setup_temp_directory
|
|
40
|
+
|
|
41
|
+
result = execute_workflow
|
|
42
|
+
|
|
43
|
+
cleanup unless options[:keep_temp]
|
|
44
|
+
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Check if using frame-by-frame background removal mode
|
|
51
|
+
# @return [Boolean] true if both --by-frame and --remove-bg flags are set
|
|
52
|
+
def using_frame_by_frame_background_removal?
|
|
53
|
+
options[:by_frame] && options[:remove_bg]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Normalize video processing result to standard format
|
|
57
|
+
# @param result [Hash] Result from process_with_background_removal
|
|
58
|
+
# @return [Hash] Normalized result with :output_file, :columns, :rows, :frames
|
|
59
|
+
def normalize_video_result_format(result)
|
|
60
|
+
{
|
|
61
|
+
output_file: result[:output_file],
|
|
62
|
+
columns: result[:columns],
|
|
63
|
+
rows: (result[:frames].to_f / result[:columns]).ceil,
|
|
64
|
+
frames: result[:frames]
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def default_options
|
|
69
|
+
{
|
|
70
|
+
video: nil,
|
|
71
|
+
image: nil,
|
|
72
|
+
consolidate: nil,
|
|
73
|
+
verify: nil,
|
|
74
|
+
output: nil,
|
|
75
|
+
frame_count: 16,
|
|
76
|
+
columns: 4,
|
|
77
|
+
max_width: 320,
|
|
78
|
+
padding: 0,
|
|
79
|
+
bg_color: 'black',
|
|
80
|
+
scale_percent: nil,
|
|
81
|
+
scale_interpolation: 'nohalo',
|
|
82
|
+
sharpen: false,
|
|
83
|
+
sharpen_radius: 2.0,
|
|
84
|
+
sharpen_gain: 0.5,
|
|
85
|
+
sharpen_threshold: 0.03,
|
|
86
|
+
remove_bg: false,
|
|
87
|
+
bg_threshold: 15.0,
|
|
88
|
+
feather_radius: 0.0,
|
|
89
|
+
grow_selection: 0, # Changed from 1 to 0 - don't grow by default!
|
|
90
|
+
fuzzy_select: true,
|
|
91
|
+
operation_order: :scale_then_remove_bg,
|
|
92
|
+
validate_columns: true,
|
|
93
|
+
temp_dir: nil,
|
|
94
|
+
keep_temp: false,
|
|
95
|
+
debug: false,
|
|
96
|
+
overwrite: false,
|
|
97
|
+
save_frames: false,
|
|
98
|
+
split: nil,
|
|
99
|
+
override_md: false,
|
|
100
|
+
extract: nil,
|
|
101
|
+
add_meta: nil,
|
|
102
|
+
overwrite_meta: false
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_options!
|
|
107
|
+
input_modes = [options[:video], options[:image], options[:consolidate_mode], options[:verify], options[:batch]].compact
|
|
108
|
+
|
|
109
|
+
if input_modes.empty?
|
|
110
|
+
raise ValidationError, "Must specify --video, --image, --consolidate, --verify, or --batch"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if input_modes.length > 1
|
|
114
|
+
raise ValidationError, "Cannot use multiple input modes together. Choose one."
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
validate_consolidate_options!
|
|
118
|
+
validate_input_files!
|
|
119
|
+
validate_numeric_options!
|
|
120
|
+
validate_split_option!
|
|
121
|
+
validate_extract_option!
|
|
122
|
+
validate_add_meta_option!
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def validate_consolidate_options!
|
|
126
|
+
return unless options[:consolidate_mode]
|
|
127
|
+
|
|
128
|
+
# Check for mutual exclusivity between file list and directory
|
|
129
|
+
if options[:consolidate] && options[:dir]
|
|
130
|
+
raise ValidationError, "Cannot use --dir with comma-separated file list for --consolidate. Choose one method."
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Require either file list or directory
|
|
134
|
+
unless options[:consolidate] || options[:dir]
|
|
135
|
+
raise ValidationError, "--consolidate requires either comma-separated files or --dir option"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Validate directory if using directory mode
|
|
139
|
+
if options[:dir] && !options[:consolidate]
|
|
140
|
+
unless File.directory?(options[:dir])
|
|
141
|
+
raise ValidationError, "Directory not found: #{options[:dir]}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_input_files!
|
|
147
|
+
if options[:video]
|
|
148
|
+
Utils::FileHelper.validate_exists!(options[:video])
|
|
149
|
+
validate_file_extension!(options[:video], ['.mp4'], '--video')
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
if options[:image]
|
|
153
|
+
Utils::FileHelper.validate_exists!(options[:image])
|
|
154
|
+
validate_file_extension!(options[:image], ['.png'], '--image')
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if options[:consolidate]
|
|
158
|
+
if options[:consolidate].length < 2
|
|
159
|
+
raise ValidationError, "--consolidate requires at least 2 files"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
options[:consolidate].each do |file|
|
|
163
|
+
Utils::FileHelper.validate_exists!(file)
|
|
164
|
+
validate_file_extension!(file, ['.png'], '--consolidate')
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
if options[:verify]
|
|
169
|
+
Utils::FileHelper.validate_exists!(options[:verify])
|
|
170
|
+
validate_file_extension!(options[:verify], ['.png'], '--verify')
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def validate_file_extension!(file_path, valid_extensions, flag_name)
|
|
175
|
+
ext = File.extname(file_path).downcase
|
|
176
|
+
unless valid_extensions.include?(ext)
|
|
177
|
+
expected = valid_extensions.join(', ')
|
|
178
|
+
raise ValidationError, "#{flag_name} expects #{expected} file, got: #{ext || '(no extension)'}"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def validate_numeric_options!
|
|
183
|
+
VALID_RANGES.each do |option_name, range_config|
|
|
184
|
+
value = options[option_name]
|
|
185
|
+
|
|
186
|
+
# Skip validation if option is not set (nil)
|
|
187
|
+
next if value.nil?
|
|
188
|
+
|
|
189
|
+
min = range_config[:min]
|
|
190
|
+
max = range_config[:max]
|
|
191
|
+
|
|
192
|
+
# Validate that value is within range
|
|
193
|
+
if value < min || value > max
|
|
194
|
+
raise ValidationError, "#{option_name} must be between #{min} and #{max}, got: #{value}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def validate_split_option!
|
|
200
|
+
return unless options[:split]
|
|
201
|
+
|
|
202
|
+
# Parse split format: R:C
|
|
203
|
+
unless options[:split] =~ /^\d+:\d+$/
|
|
204
|
+
raise ValidationError, "Invalid --split format. Use R:C (e.g., 4:4)"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
rows, columns = options[:split].split(':').map(&:to_i)
|
|
208
|
+
|
|
209
|
+
# Validate ranges
|
|
210
|
+
if rows < 1 || rows > 99
|
|
211
|
+
raise ValidationError, "rows must be between 1 and 99, got: #{rows}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
if columns < 1 || columns > 99
|
|
215
|
+
raise ValidationError, "columns must be between 1 and 99, got: #{columns}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Validate total frames < 1000
|
|
219
|
+
total_frames = rows * columns
|
|
220
|
+
if total_frames >= 1000
|
|
221
|
+
raise ValidationError, "Total frames (#{total_frames}) must be less than 1000"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Store parsed values for later use
|
|
225
|
+
@split_rows = rows
|
|
226
|
+
@split_columns = columns
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def validate_extract_option!
|
|
230
|
+
return unless options[:extract]
|
|
231
|
+
|
|
232
|
+
# Parse extract format: comma-separated integers (allow negatives for better error messages)
|
|
233
|
+
unless options[:extract] =~ /^-?\d+(,-?\d+)*$/
|
|
234
|
+
raise ValidationError, "Invalid --extract format. Use comma-separated frame numbers (e.g., 1,2,4,5,8)"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Parse frame numbers
|
|
238
|
+
frame_numbers = options[:extract].split(',').map(&:to_i)
|
|
239
|
+
|
|
240
|
+
# Validate minimum 2 frames
|
|
241
|
+
if frame_numbers.length < 2
|
|
242
|
+
raise ValidationError, "--extract requires at least 2 frames, got: #{frame_numbers.length}"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Validate frame numbers are 1-indexed (no 0 or negative)
|
|
246
|
+
invalid_frames = frame_numbers.select { |n| n <= 0 }
|
|
247
|
+
if invalid_frames.any?
|
|
248
|
+
raise ValidationError, "Frame numbers must be 1-indexed (positive integers), got invalid: #{invalid_frames.join(', ')}"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Check for metadata (required for extraction)
|
|
252
|
+
return unless options[:image] # Only validate bounds if we have an image path
|
|
253
|
+
|
|
254
|
+
image_file = options[:image]
|
|
255
|
+
metadata = MetadataManager.read(image_file)
|
|
256
|
+
|
|
257
|
+
unless metadata
|
|
258
|
+
raise ValidationError, "Image has no metadata. Cannot extract frames without knowing the grid layout. Use --add-meta first."
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Validate frame numbers are within bounds
|
|
262
|
+
total_frames = metadata[:frames]
|
|
263
|
+
out_of_bounds = frame_numbers.select { |n| n > total_frames }
|
|
264
|
+
if out_of_bounds.any?
|
|
265
|
+
first_oob = out_of_bounds.first
|
|
266
|
+
raise ValidationError, "Frame #{first_oob} is out of bounds (image only has #{total_frames} frames)"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Set default columns if not specified
|
|
270
|
+
options[:columns] ||= 4
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def validate_add_meta_option!
|
|
274
|
+
return unless options[:add_meta]
|
|
275
|
+
|
|
276
|
+
# Parse add-meta format: R:C
|
|
277
|
+
unless options[:add_meta] =~ /^\d+:\d+$/
|
|
278
|
+
raise ValidationError, "Invalid --add-meta format. Use R:C (e.g., 4:4)"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
rows, columns = options[:add_meta].split(':').map(&:to_i)
|
|
282
|
+
|
|
283
|
+
# Validate ranges
|
|
284
|
+
if rows < 1 || rows > 99
|
|
285
|
+
raise ValidationError, "rows must be between 1 and 99, got: #{rows}"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
if columns < 1 || columns > 99
|
|
289
|
+
raise ValidationError, "columns must be between 1 and 99, got: #{columns}"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Validate total frames < 1000
|
|
293
|
+
total_frames = rows * columns
|
|
294
|
+
if total_frames >= 1000
|
|
295
|
+
raise ValidationError, "Total frames (#{total_frames}) must be less than 1000"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Check if we need to validate against image file
|
|
299
|
+
return unless options[:image]
|
|
300
|
+
|
|
301
|
+
image_file = options[:image]
|
|
302
|
+
metadata = MetadataManager.read(image_file)
|
|
303
|
+
|
|
304
|
+
# Check for existing metadata
|
|
305
|
+
if metadata && !options[:overwrite_meta]
|
|
306
|
+
raise ValidationError, "Image already has spritesheet metadata. Use --overwrite-meta to replace it."
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Validate image dimensions divide evenly by grid
|
|
310
|
+
dimensions = get_image_dimensions(image_file)
|
|
311
|
+
tile_width = dimensions[:width] / columns.to_f
|
|
312
|
+
tile_height = dimensions[:height] / rows.to_f
|
|
313
|
+
|
|
314
|
+
unless tile_width == tile_width.to_i && tile_height == tile_height.to_i
|
|
315
|
+
raise ValidationError, "Image dimensions (#{dimensions[:width]}x#{dimensions[:height]}) must divide evenly by grid (#{rows}x#{columns}). Expected frame size: #{tile_width}x#{tile_height}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Validate custom frame count doesn't exceed grid size
|
|
319
|
+
if options[:frame_count] && options[:frame_count] > total_frames
|
|
320
|
+
raise ValidationError, "Frame count (#{options[:frame_count]}) exceeds grid size (#{total_frames})"
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def get_image_dimensions(image_file)
|
|
325
|
+
cmd = [
|
|
326
|
+
'magick',
|
|
327
|
+
'identify',
|
|
328
|
+
'-format', '%wx%h',
|
|
329
|
+
Utils::PathHelper.quote_path(image_file)
|
|
330
|
+
].join(' ')
|
|
331
|
+
|
|
332
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
333
|
+
|
|
334
|
+
unless status.success?
|
|
335
|
+
raise ProcessingError, "Could not get image dimensions: #{stderr}"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
width, height = stdout.strip.split('x').map(&:to_i)
|
|
339
|
+
{ width: width, height: height }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def check_dependencies!
|
|
343
|
+
checker = DependencyChecker.new(verbose: options[:debug])
|
|
344
|
+
results = checker.check_all
|
|
345
|
+
|
|
346
|
+
# Check required tools
|
|
347
|
+
missing = []
|
|
348
|
+
|
|
349
|
+
[:ffmpeg, :ffprobe, :imagemagick].each do |tool|
|
|
350
|
+
missing << tool unless results[tool][:available]
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# GIMP only needed for scaling and background removal (not for sharpen-only)
|
|
354
|
+
if needs_gimp_specifically? && !results[:gimp][:available]
|
|
355
|
+
missing << :gimp
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
if missing.any?
|
|
359
|
+
checker.print_report
|
|
360
|
+
raise DependencyError, "Missing required dependencies: #{missing.join(', ')}"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
if results[:gimp][:available]
|
|
364
|
+
@gimp_path = checker.gimp_path
|
|
365
|
+
@gimp_version = checker.gimp_version
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
if options[:debug]
|
|
369
|
+
checker.print_report
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def needs_gimp?
|
|
374
|
+
options[:scale_percent] || options[:remove_bg] || options[:sharpen]
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def needs_gimp_specifically?
|
|
378
|
+
options[:scale_percent] || options[:remove_bg]
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def setup_temp_directory
|
|
382
|
+
@options[:temp_dir] = Dir.mktmpdir('ruby_spriter_')
|
|
383
|
+
|
|
384
|
+
if options[:debug]
|
|
385
|
+
Utils::OutputFormatter.indent("Temp directory: #{options[:temp_dir]}")
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def execute_workflow
|
|
390
|
+
Utils::OutputFormatter.header("Ruby Spriter v#{VERSION}")
|
|
391
|
+
puts "Platform: #{Platform.current.to_s.capitalize}"
|
|
392
|
+
puts "Date: #{VERSION_DATE}\n\n"
|
|
393
|
+
|
|
394
|
+
if options[:verify]
|
|
395
|
+
MetadataManager.verify(options[:verify])
|
|
396
|
+
return { mode: :verify, file: options[:verify] }
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
if options[:batch]
|
|
400
|
+
return execute_batch_workflow
|
|
401
|
+
elsif options[:consolidate_mode]
|
|
402
|
+
return execute_consolidate_workflow
|
|
403
|
+
elsif options[:image]
|
|
404
|
+
return execute_image_workflow
|
|
405
|
+
else
|
|
406
|
+
return execute_video_workflow
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def execute_video_workflow
|
|
411
|
+
# Step 1: Determine output filename
|
|
412
|
+
desired_output = options[:output] || Utils::FileHelper.spritesheet_filename(options[:video])
|
|
413
|
+
final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
|
|
414
|
+
|
|
415
|
+
# Step 2: Convert video to spritesheet
|
|
416
|
+
# Pass gimp_path through options for background removal
|
|
417
|
+
video_options = options.merge(gimp_path: @gimp_path)
|
|
418
|
+
video_processor = VideoProcessor.new(video_options)
|
|
419
|
+
|
|
420
|
+
# Check if we need frame-by-frame background removal
|
|
421
|
+
if using_frame_by_frame_background_removal?
|
|
422
|
+
# Frame-by-frame processing with background removal
|
|
423
|
+
result = video_processor.process_with_background_removal(
|
|
424
|
+
options[:video],
|
|
425
|
+
final_output,
|
|
426
|
+
video_options
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# Convert result to match expected format
|
|
430
|
+
result = normalize_video_result_format(result)
|
|
431
|
+
else
|
|
432
|
+
# Standard video processing
|
|
433
|
+
result = video_processor.create_spritesheet(
|
|
434
|
+
options[:video],
|
|
435
|
+
final_output
|
|
436
|
+
)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
working_file = result[:output_file]
|
|
440
|
+
intermediate_files = []
|
|
441
|
+
|
|
442
|
+
# Step 3: Apply GIMP processing if requested
|
|
443
|
+
# Skip GIMP processing if by_frame already handled background removal
|
|
444
|
+
if needs_gimp? && !using_frame_by_frame_background_removal?
|
|
445
|
+
initial_file = working_file
|
|
446
|
+
working_file = process_with_gimp(working_file)
|
|
447
|
+
|
|
448
|
+
# Apply cell cleanup after GIMP background removal
|
|
449
|
+
if options[:cleanup_cells] && options[:remove_bg]
|
|
450
|
+
# Pass frame count and columns to cell cleanup
|
|
451
|
+
cleanup_options = options.merge(
|
|
452
|
+
frames: result[:frames],
|
|
453
|
+
columns: result[:columns]
|
|
454
|
+
)
|
|
455
|
+
working_file = apply_cell_cleanup(working_file, cleanup_options)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Track intermediate files for cleanup (everything except initial and final)
|
|
459
|
+
if working_file != initial_file
|
|
460
|
+
intermediate_files = collect_intermediate_files(initial_file, working_file)
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Step 4: Move to final output location if different
|
|
465
|
+
if final_output != working_file
|
|
466
|
+
FileUtils.cp(working_file, final_output)
|
|
467
|
+
# Add the GIMP output to intermediates if it's different from final
|
|
468
|
+
intermediate_files << working_file unless intermediate_files.include?(working_file)
|
|
469
|
+
working_file = final_output
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Step 5: Clean up intermediate files
|
|
473
|
+
cleanup_intermediate_files(intermediate_files)
|
|
474
|
+
|
|
475
|
+
# Step 6: Apply max compression if requested
|
|
476
|
+
if options[:max_compress]
|
|
477
|
+
working_file = apply_max_compression(working_file)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Step 7: Extract individual frames if requested
|
|
481
|
+
if options[:save_frames]
|
|
482
|
+
split_frames_from_spritesheet(working_file, result[:columns], result[:rows], result[:frames])
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
Utils::OutputFormatter.header("SUCCESS!")
|
|
486
|
+
Utils::OutputFormatter.success("Final output: #{working_file}")
|
|
487
|
+
|
|
488
|
+
result.merge(final_output: working_file)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def execute_image_workflow
|
|
492
|
+
working_file = options[:image]
|
|
493
|
+
intermediate_files = []
|
|
494
|
+
@background_palette = nil
|
|
495
|
+
|
|
496
|
+
# Handle metadata addition workflow first
|
|
497
|
+
if options[:add_meta]
|
|
498
|
+
return execute_add_meta_workflow
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Handle frame extraction workflow
|
|
502
|
+
if options[:extract]
|
|
503
|
+
return execute_extract_workflow
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# STEP 1: Sample edges ONCE if needed for background removal operations
|
|
507
|
+
if options[:remove_bg] && (options[:threshold_stepping] || options[:try_inner])
|
|
508
|
+
@background_palette = sample_edge_colors(working_file)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# STEP 2: Apply operations in correct order based on flags
|
|
512
|
+
if options[:threshold_stepping] && options[:remove_bg]
|
|
513
|
+
# Threshold stepping (uses background_palette, skips GIMP fuzzy select)
|
|
514
|
+
working_file = process_threshold_stepping(working_file, intermediate_files, @background_palette)
|
|
515
|
+
|
|
516
|
+
# Inner removal after threshold stepping (if requested)
|
|
517
|
+
if options[:try_inner]
|
|
518
|
+
working_file = process_inner_background_removal(working_file, intermediate_files, @background_palette)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
elsif options[:try_inner] && options[:remove_bg]
|
|
522
|
+
# CORRECT ORDER: GIMP fuzzy select FIRST, then inner removal
|
|
523
|
+
# GIMP removes outer background quickly, leaving less work for inner removal
|
|
524
|
+
initial_file = working_file
|
|
525
|
+
working_file = process_with_gimp(working_file)
|
|
526
|
+
if working_file != initial_file
|
|
527
|
+
intermediate_files = collect_intermediate_files(initial_file, working_file)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Inner removal processes interior using pre-sampled background_palette
|
|
531
|
+
working_file = process_inner_background_removal(working_file, intermediate_files, @background_palette)
|
|
532
|
+
|
|
533
|
+
elsif needs_gimp?
|
|
534
|
+
# Traditional GIMP processing only (no threshold stepping, no inner removal)
|
|
535
|
+
initial_file = working_file
|
|
536
|
+
working_file = process_with_gimp(working_file)
|
|
537
|
+
if working_file != initial_file
|
|
538
|
+
intermediate_files = collect_intermediate_files(initial_file, working_file)
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# STEP 3: Ghost edge cleaning (if multi-pass enabled)
|
|
543
|
+
if options[:multi_pass] && options[:remove_bg]
|
|
544
|
+
working_file = process_ghost_edge_cleaning(working_file, intermediate_files)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# STEP 4: Smoke detection and removal (only if explicitly enabled)
|
|
548
|
+
if options[:remove_smoke] == true
|
|
549
|
+
working_file = process_smoke_detection(working_file, intermediate_files)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Move to final output location if user specified explicit --output
|
|
553
|
+
if options[:output]
|
|
554
|
+
final_output = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
|
|
555
|
+
if working_file != final_output
|
|
556
|
+
FileUtils.cp(working_file, final_output)
|
|
557
|
+
# Add the GIMP output to intermediates if it's different from final
|
|
558
|
+
intermediate_files << working_file unless intermediate_files.include?(working_file)
|
|
559
|
+
working_file = final_output
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Clean up intermediate files
|
|
564
|
+
cleanup_intermediate_files(intermediate_files)
|
|
565
|
+
|
|
566
|
+
# Apply max compression if requested
|
|
567
|
+
if options[:max_compress]
|
|
568
|
+
working_file = apply_max_compression(working_file)
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# Determine if we should split the image into frames
|
|
572
|
+
should_split = options[:save_frames] || options[:split]
|
|
573
|
+
|
|
574
|
+
if should_split
|
|
575
|
+
# Determine rows, columns, and frames to use
|
|
576
|
+
rows, columns, frames = determine_split_parameters(working_file)
|
|
577
|
+
|
|
578
|
+
# Split the image into frames
|
|
579
|
+
split_frames_from_spritesheet(working_file, columns, rows, frames)
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
Utils::OutputFormatter.header("SUCCESS!")
|
|
583
|
+
Utils::OutputFormatter.success("Final output: #{working_file}")
|
|
584
|
+
|
|
585
|
+
{
|
|
586
|
+
mode: :image,
|
|
587
|
+
input_file: options[:image],
|
|
588
|
+
output_file: working_file
|
|
589
|
+
}
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def execute_extract_workflow
|
|
593
|
+
input_file = options[:image]
|
|
594
|
+
metadata = MetadataManager.read(input_file)
|
|
595
|
+
frame_numbers = options[:extract].split(',').map(&:to_i)
|
|
596
|
+
columns = options[:columns]
|
|
597
|
+
|
|
598
|
+
Utils::OutputFormatter.header("Frame Extraction")
|
|
599
|
+
Utils::OutputFormatter.indent("Input: #{input_file}")
|
|
600
|
+
Utils::OutputFormatter.indent("Frames to extract: #{frame_numbers.join(', ')}")
|
|
601
|
+
Utils::OutputFormatter.indent("Output columns: #{columns}")
|
|
602
|
+
|
|
603
|
+
# Step 1: Extract all frames to temp directory
|
|
604
|
+
temp_frames_dir = File.join(options[:temp_dir], 'extracted_frames')
|
|
605
|
+
splitter = Utils::SpritesheetSplitter.new
|
|
606
|
+
splitter.split_into_frames(input_file, temp_frames_dir, metadata[:columns], metadata[:rows], metadata[:frames])
|
|
607
|
+
|
|
608
|
+
# Step 2: Keep only requested frames, delete the rest
|
|
609
|
+
spritesheet_basename = File.basename(input_file, '.*')
|
|
610
|
+
all_frame_files = Dir.glob(File.join(temp_frames_dir, "FR*_#{spritesheet_basename}.png")).sort
|
|
611
|
+
requested_frame_files = frame_numbers.map do |frame_num|
|
|
612
|
+
# Frame files are named FR001, FR002, etc. (1-indexed)
|
|
613
|
+
File.join(temp_frames_dir, "FR#{format('%03d', frame_num)}_#{spritesheet_basename}.png")
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# Delete unwanted frames
|
|
617
|
+
(all_frame_files - requested_frame_files).each { |f| FileUtils.rm_f(f) }
|
|
618
|
+
|
|
619
|
+
Utils::OutputFormatter.indent("Kept #{requested_frame_files.length} frames, deleted #{all_frame_files.length - requested_frame_files.length} frames")
|
|
620
|
+
|
|
621
|
+
# Step 3: Reassemble into new spritesheet
|
|
622
|
+
Utils::OutputFormatter.header("Reassembling Spritesheet")
|
|
623
|
+
reassembled_file = File.join(options[:temp_dir], "reassembled_#{spritesheet_basename}.png")
|
|
624
|
+
reassemble_frames(requested_frame_files, reassembled_file, columns)
|
|
625
|
+
|
|
626
|
+
working_file = reassembled_file
|
|
627
|
+
intermediate_files = []
|
|
628
|
+
|
|
629
|
+
# Step 4: Apply GIMP processing if requested
|
|
630
|
+
if needs_gimp?
|
|
631
|
+
initial_file = working_file
|
|
632
|
+
working_file = process_with_gimp(working_file)
|
|
633
|
+
|
|
634
|
+
if working_file != initial_file
|
|
635
|
+
intermediate_files = collect_intermediate_files(initial_file, working_file)
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
# Step 5: Determine final output filename
|
|
640
|
+
if options[:output]
|
|
641
|
+
final_output = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
|
|
642
|
+
else
|
|
643
|
+
# Auto-generate output filename with _extracted suffix
|
|
644
|
+
base = File.basename(input_file, '.*')
|
|
645
|
+
ext = File.extname(input_file)
|
|
646
|
+
desired_output = File.join(File.dirname(input_file), "#{base}_extracted#{ext}")
|
|
647
|
+
final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# Step 6: Copy to final output
|
|
651
|
+
FileUtils.cp(working_file, final_output)
|
|
652
|
+
working_file = final_output
|
|
653
|
+
|
|
654
|
+
# Step 7: Clean up intermediate files
|
|
655
|
+
cleanup_intermediate_files(intermediate_files)
|
|
656
|
+
|
|
657
|
+
# Step 8: Apply max compression if requested
|
|
658
|
+
if options[:max_compress]
|
|
659
|
+
working_file = apply_max_compression(working_file)
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# Step 9: Optionally save individual frames
|
|
663
|
+
if options[:save_frames]
|
|
664
|
+
frames_output_dir = File.join(File.dirname(working_file), "#{File.basename(working_file, '.*')}_frames")
|
|
665
|
+
FileUtils.mkdir_p(frames_output_dir)
|
|
666
|
+
requested_frame_files.each_with_index do |frame_file, idx|
|
|
667
|
+
frame_num = frame_numbers[idx]
|
|
668
|
+
dest = File.join(frames_output_dir, "FR#{format('%03d', frame_num)}_#{spritesheet_basename}.png")
|
|
669
|
+
FileUtils.cp(frame_file, dest)
|
|
670
|
+
end
|
|
671
|
+
Utils::OutputFormatter.indent("Saved #{requested_frame_files.length} frames to: #{frames_output_dir}")
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
Utils::OutputFormatter.header("SUCCESS!")
|
|
675
|
+
Utils::OutputFormatter.success("Extracted spritesheet: #{working_file}")
|
|
676
|
+
|
|
677
|
+
{
|
|
678
|
+
mode: :extract,
|
|
679
|
+
input_file: input_file,
|
|
680
|
+
output_file: working_file,
|
|
681
|
+
frames_extracted: frame_numbers.length,
|
|
682
|
+
columns: columns
|
|
683
|
+
}
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def execute_add_meta_workflow
|
|
687
|
+
input_file = options[:image]
|
|
688
|
+
rows, columns = options[:add_meta].split(':').map(&:to_i)
|
|
689
|
+
|
|
690
|
+
# Determine frame count
|
|
691
|
+
frame_count = if options[:frame_count]
|
|
692
|
+
options[:frame_count]
|
|
693
|
+
else
|
|
694
|
+
rows * columns
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
Utils::OutputFormatter.header("Adding Metadata")
|
|
698
|
+
Utils::OutputFormatter.indent("Input: #{input_file}")
|
|
699
|
+
Utils::OutputFormatter.indent("Grid: #{rows}×#{columns} (#{frame_count} frames)")
|
|
700
|
+
|
|
701
|
+
# Determine output file
|
|
702
|
+
if options[:output]
|
|
703
|
+
# User specified explicit output
|
|
704
|
+
output_file = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
|
|
705
|
+
|
|
706
|
+
# Copy input to output
|
|
707
|
+
FileUtils.cp(input_file, output_file)
|
|
708
|
+
Utils::OutputFormatter.indent("Copied to: #{output_file}")
|
|
709
|
+
else
|
|
710
|
+
# In-place modification
|
|
711
|
+
if options[:overwrite]
|
|
712
|
+
output_file = input_file
|
|
713
|
+
Utils::OutputFormatter.indent("Modifying in-place (--overwrite specified)")
|
|
714
|
+
else
|
|
715
|
+
# Create unique filename
|
|
716
|
+
output_file = Utils::FileHelper.ensure_unique_output(input_file, overwrite: false)
|
|
717
|
+
FileUtils.cp(input_file, output_file)
|
|
718
|
+
Utils::OutputFormatter.indent("Created: #{output_file}")
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
# Embed metadata
|
|
723
|
+
MetadataManager.embed(output_file, columns, rows, frame_count)
|
|
724
|
+
Utils::OutputFormatter.indent("📝 Metadata embedded: #{columns}×#{rows} grid (#{frame_count} frames)")
|
|
725
|
+
|
|
726
|
+
Utils::OutputFormatter.header("SUCCESS!")
|
|
727
|
+
Utils::OutputFormatter.success("Metadata added to: #{output_file}")
|
|
728
|
+
|
|
729
|
+
{
|
|
730
|
+
mode: :add_meta,
|
|
731
|
+
input_file: input_file,
|
|
732
|
+
output_file: output_file,
|
|
733
|
+
columns: columns,
|
|
734
|
+
rows: rows,
|
|
735
|
+
frames: frame_count
|
|
736
|
+
}
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def execute_consolidate_workflow
|
|
740
|
+
consolidator = Consolidator.new(options)
|
|
741
|
+
|
|
742
|
+
# Determine file list: either from command line or from directory
|
|
743
|
+
files_to_consolidate = if options[:dir] && !options[:consolidate]
|
|
744
|
+
# Directory-based consolidation
|
|
745
|
+
consolidator.find_spritesheets_in_directory(options[:dir])
|
|
746
|
+
else
|
|
747
|
+
# File list consolidation
|
|
748
|
+
options[:consolidate]
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
# Determine output filename and directory
|
|
752
|
+
if options[:dir] && !options[:consolidate]
|
|
753
|
+
# Directory mode: output to dir or outputdir
|
|
754
|
+
output_dir = options[:outputdir] || options[:dir]
|
|
755
|
+
desired_output = if options[:output]
|
|
756
|
+
File.join(output_dir, File.basename(options[:output]))
|
|
757
|
+
else
|
|
758
|
+
File.join(output_dir, generate_consolidated_filename)
|
|
759
|
+
end
|
|
760
|
+
else
|
|
761
|
+
# File list mode: use current directory behavior
|
|
762
|
+
if options[:outputdir]
|
|
763
|
+
desired_output = File.join(options[:outputdir], options[:output] || generate_consolidated_filename)
|
|
764
|
+
else
|
|
765
|
+
desired_output = options[:output] || generate_consolidated_filename
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
|
|
770
|
+
|
|
771
|
+
result = consolidator.consolidate(files_to_consolidate, final_output)
|
|
772
|
+
|
|
773
|
+
# Apply max compression if requested
|
|
774
|
+
if options[:max_compress]
|
|
775
|
+
final_output = apply_max_compression(result[:output_file])
|
|
776
|
+
result[:output_file] = final_output
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
Utils::OutputFormatter.header("SUCCESS!")
|
|
780
|
+
Utils::OutputFormatter.success("Final output: #{result[:output_file]}")
|
|
781
|
+
|
|
782
|
+
result.merge(mode: :consolidate)
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
def execute_batch_workflow
|
|
786
|
+
batch_processor = BatchProcessor.new(options)
|
|
787
|
+
result = batch_processor.process
|
|
788
|
+
|
|
789
|
+
result.merge(mode: :batch)
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
def process_with_gimp(input_file)
|
|
793
|
+
gimp_options = options.merge(gimp_version: @gimp_version)
|
|
794
|
+
gimp_processor = GimpProcessor.new(@gimp_path, gimp_options)
|
|
795
|
+
gimp_processor.process(input_file)
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def apply_cell_cleanup(working_file, cleanup_options = {})
|
|
799
|
+
Utils::OutputFormatter.header("CELL CLEANUP")
|
|
800
|
+
Utils::OutputFormatter.indent("Analyzing and removing residual background colors from spritesheet cells...")
|
|
801
|
+
|
|
802
|
+
require_relative 'cell_cleanup_processor'
|
|
803
|
+
cell_processor = CellCleanupProcessor.new(cleanup_options.merge(gimp_path: @gimp_path))
|
|
804
|
+
|
|
805
|
+
# Process the spritesheet
|
|
806
|
+
stats = cell_processor.cleanup_cells(working_file, cleanup_options)
|
|
807
|
+
|
|
808
|
+
Utils::OutputFormatter.success("Cell cleanup complete")
|
|
809
|
+
if stats
|
|
810
|
+
Utils::OutputFormatter.indent("Processed: #{stats[:processed]} cells")
|
|
811
|
+
Utils::OutputFormatter.indent("Cleaned: #{stats[:cleaned]} cells")
|
|
812
|
+
Utils::OutputFormatter.indent("Skipped: #{stats[:skipped]} cells")
|
|
813
|
+
Utils::OutputFormatter.indent("Colors removed: #{stats[:colors_removed]}")
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# Cell cleanup modifies the file in-place, so return the same path
|
|
817
|
+
working_file
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
def sample_edge_colors(input_file)
|
|
821
|
+
Utils::OutputFormatter.header("EDGE SAMPLING")
|
|
822
|
+
Utils::OutputFormatter.indent("Sampling image edges for background colors...")
|
|
823
|
+
|
|
824
|
+
# Create configuration from options
|
|
825
|
+
config = InnerBgConfig.new(
|
|
826
|
+
edge_sample_interval: options[:edge_sample_interval] || 5,
|
|
827
|
+
edge_sample_depth: options[:edge_sample_depth] || 2
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
# Sample edges and build color palette
|
|
831
|
+
sampler = EdgeSampler.new(input_file, config)
|
|
832
|
+
samples = sampler.sample_edges
|
|
833
|
+
background_palette = sampler.build_color_palette(samples)
|
|
834
|
+
|
|
835
|
+
# Report sampling results
|
|
836
|
+
edge_report = sampler.report
|
|
837
|
+
Utils::OutputFormatter.indent("Samples collected: #{edge_report[:samples_collected]}")
|
|
838
|
+
Utils::OutputFormatter.indent("Unique colors: #{edge_report[:unique_colors]}")
|
|
839
|
+
|
|
840
|
+
if background_palette.empty?
|
|
841
|
+
Utils::OutputFormatter.indent("⚠️ No background colors detected, using fallback")
|
|
842
|
+
background_palette = [{ r: 255, g: 255, b: 255 }]
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
Utils::OutputFormatter.success("Edge sampling complete (#{background_palette.length} color(s))")
|
|
846
|
+
background_palette
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
def process_inner_background_removal(input_file, intermediate_files, background_palette = nil)
|
|
850
|
+
Utils::OutputFormatter.header("INNER BACKGROUND REMOVAL")
|
|
851
|
+
Utils::OutputFormatter.indent("Detecting and removing interior background regions...")
|
|
852
|
+
|
|
853
|
+
# Create configuration from options
|
|
854
|
+
config = InnerBgConfig.new(options)
|
|
855
|
+
|
|
856
|
+
# Validate configuration
|
|
857
|
+
unless config.valid?
|
|
858
|
+
warn "⚠️ Invalid inner background removal configuration. Skipping."
|
|
859
|
+
return input_file
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
# Use provided palette or sample edges (backward compatibility)
|
|
863
|
+
if background_palette.nil?
|
|
864
|
+
Utils::OutputFormatter.indent("Sampling edge colors...")
|
|
865
|
+
sampler = EdgeSampler.new(input_file, config)
|
|
866
|
+
background_palette = sampler.sample_edges.then { |samples| sampler.build_color_palette(samples) }
|
|
867
|
+
|
|
868
|
+
if background_palette.empty?
|
|
869
|
+
Utils::OutputFormatter.indent("⚠️ No background colors detected. Skipping inner removal.")
|
|
870
|
+
return input_file
|
|
871
|
+
end
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
Utils::OutputFormatter.indent("Using #{background_palette.length} background color(s)")
|
|
875
|
+
|
|
876
|
+
# Create output file path (unique to avoid conflicts)
|
|
877
|
+
dir = File.dirname(input_file)
|
|
878
|
+
basename = File.basename(input_file, '.*')
|
|
879
|
+
ext = File.extname(input_file)
|
|
880
|
+
output_file = File.join(dir, "#{basename}_inner_removed#{ext}")
|
|
881
|
+
|
|
882
|
+
# Process inner background removal
|
|
883
|
+
processor = InnerBackgroundProcessor.new(input_file, output_file, config, background_palette)
|
|
884
|
+
processor.process
|
|
885
|
+
|
|
886
|
+
# Display processing report
|
|
887
|
+
report = processor.report
|
|
888
|
+
Utils::OutputFormatter.indent("Regions detected: #{report[:regions_detected]}")
|
|
889
|
+
Utils::OutputFormatter.indent("Regions removed: #{report[:regions_removed]}")
|
|
890
|
+
Utils::OutputFormatter.indent("Processing time: #{report[:processing_time]}s")
|
|
891
|
+
|
|
892
|
+
# Track input file for cleanup if it's an intermediate file
|
|
893
|
+
if input_file != options[:image]
|
|
894
|
+
intermediate_files << input_file unless intermediate_files.include?(input_file)
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
Utils::OutputFormatter.success("Inner background removal complete")
|
|
898
|
+
output_file
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
def process_threshold_stepping(input_file, intermediate_files, background_palette = nil)
|
|
902
|
+
Utils::OutputFormatter.header('Threshold Stepping Background Removal')
|
|
903
|
+
|
|
904
|
+
# Use provided palette or sample edges (backward compatibility)
|
|
905
|
+
if background_palette.nil?
|
|
906
|
+
Utils::OutputFormatter.indent('Sampling image edges for background colors...')
|
|
907
|
+
config = InnerBgConfig.new(
|
|
908
|
+
edge_sample_interval: options[:edge_sample_interval] || 5,
|
|
909
|
+
edge_sample_depth: options[:edge_sample_depth] || 2,
|
|
910
|
+
threshold_stepping: true
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
edge_sampler = EdgeSampler.new(input_file, config)
|
|
914
|
+
samples = edge_sampler.sample_edges
|
|
915
|
+
background_palette = edge_sampler.build_color_palette(samples)
|
|
916
|
+
|
|
917
|
+
# Report edge sampling results
|
|
918
|
+
edge_report = edge_sampler.report
|
|
919
|
+
Utils::OutputFormatter.indent(" Samples collected: #{edge_report[:samples_collected]}")
|
|
920
|
+
Utils::OutputFormatter.indent(" Unique colors: #{edge_report[:unique_colors]}")
|
|
921
|
+
|
|
922
|
+
if background_palette.empty?
|
|
923
|
+
Utils::OutputFormatter.indent('WARNING: No background colors detected, using fallback')
|
|
924
|
+
background_palette = [{ r: 255, g: 255, b: 255 }]
|
|
925
|
+
end
|
|
926
|
+
else
|
|
927
|
+
Utils::OutputFormatter.indent("Using #{background_palette.length} background color(s) from edge sampling")
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
# Create GimpProcessor instance
|
|
931
|
+
gimp_path = Platform.find_gimp
|
|
932
|
+
gimp_version = Platform.get_gimp_version(gimp_path)
|
|
933
|
+
gimp_processor = GimpProcessor.new(gimp_path, options.merge(gimp_version: gimp_version))
|
|
934
|
+
|
|
935
|
+
# Step 3: Create output file path
|
|
936
|
+
dir = File.dirname(input_file)
|
|
937
|
+
basename = File.basename(input_file, '.*')
|
|
938
|
+
ext = File.extname(input_file)
|
|
939
|
+
output_file = File.join(dir, "#{basename}_threshold_stepped#{ext}")
|
|
940
|
+
|
|
941
|
+
# Step 4: Apply threshold stepping with GIMP
|
|
942
|
+
Utils::OutputFormatter.indent('Applying threshold-based removal with GIMP...')
|
|
943
|
+
threshold_options = options.merge(
|
|
944
|
+
threshold_values: options[:threshold_values],
|
|
945
|
+
threshold_timeout: options[:threshold_timeout] || 60,
|
|
946
|
+
total_threshold_timeout: options[:total_threshold_timeout] || 300
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
stepper = ThresholdStepper.new(
|
|
950
|
+
input_file,
|
|
951
|
+
output_file,
|
|
952
|
+
background_palette,
|
|
953
|
+
gimp_processor,
|
|
954
|
+
threshold_options
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
stepper.process
|
|
958
|
+
|
|
959
|
+
# Report results
|
|
960
|
+
report = stepper.report
|
|
961
|
+
Utils::OutputFormatter.indent(" Thresholds processed: #{report[:thresholds_processed]}")
|
|
962
|
+
Utils::OutputFormatter.indent(" Skipped thresholds: #{report[:skipped_thresholds]}") if report[:skipped_thresholds] > 0
|
|
963
|
+
Utils::OutputFormatter.indent(" Processing time: #{report[:total_time]}s")
|
|
964
|
+
|
|
965
|
+
# Track input file for cleanup if it's an intermediate file
|
|
966
|
+
if input_file != options[:image]
|
|
967
|
+
intermediate_files << input_file unless intermediate_files.include?(input_file)
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
Utils::OutputFormatter.success('Threshold stepping complete')
|
|
971
|
+
output_file
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
def process_ghost_edge_cleaning(input_file, intermediate_files)
|
|
975
|
+
Utils::OutputFormatter.header("GHOST EDGE CLEANING")
|
|
976
|
+
Utils::OutputFormatter.indent("Removing semi-transparent ghost pixels...")
|
|
977
|
+
|
|
978
|
+
# Create configuration from options
|
|
979
|
+
config = InnerBgConfig.new(options)
|
|
980
|
+
|
|
981
|
+
# Create output file path
|
|
982
|
+
dir = File.dirname(input_file)
|
|
983
|
+
basename = File.basename(input_file, '.*')
|
|
984
|
+
ext = File.extname(input_file)
|
|
985
|
+
output_file = File.join(dir, "#{basename}_ghost_cleaned#{ext}")
|
|
986
|
+
|
|
987
|
+
# Process ghost edge cleaning
|
|
988
|
+
cleaner = GhostEdgeCleaner.new(input_file, output_file, config)
|
|
989
|
+
cleaner.process
|
|
990
|
+
|
|
991
|
+
# Display processing report
|
|
992
|
+
report = cleaner.report
|
|
993
|
+
Utils::OutputFormatter.indent("Ghost pixels detected: #{report[:ghost_pixels_detected]}")
|
|
994
|
+
Utils::OutputFormatter.indent("Passes performed: #{report[:passes_performed]}")
|
|
995
|
+
Utils::OutputFormatter.indent("Processing time: #{report[:processing_time]}s")
|
|
996
|
+
|
|
997
|
+
# Track input file for cleanup if it's an intermediate file
|
|
998
|
+
if input_file != options[:image]
|
|
999
|
+
intermediate_files << input_file unless intermediate_files.include?(input_file)
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
Utils::OutputFormatter.success("Ghost edge cleaning complete")
|
|
1003
|
+
output_file
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
def process_smoke_detection(input_file, intermediate_files)
|
|
1007
|
+
Utils::OutputFormatter.header("SMOKE DETECTION")
|
|
1008
|
+
Utils::OutputFormatter.indent("Detecting transparency gradients (smoke effects)...")
|
|
1009
|
+
|
|
1010
|
+
# Validate input file exists
|
|
1011
|
+
unless File.exist?(input_file)
|
|
1012
|
+
Utils::OutputFormatter.indent("⚠️ Input file not found. Skipping smoke detection.")
|
|
1013
|
+
return input_file
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
# Create configuration from options
|
|
1017
|
+
config = InnerBgConfig.new(options)
|
|
1018
|
+
|
|
1019
|
+
# Create output file path
|
|
1020
|
+
dir = File.dirname(input_file)
|
|
1021
|
+
basename = File.basename(input_file, '.*')
|
|
1022
|
+
ext = File.extname(input_file)
|
|
1023
|
+
output_file = File.join(dir, "#{basename}_smoke_processed#{ext}")
|
|
1024
|
+
|
|
1025
|
+
# Process smoke detection/removal
|
|
1026
|
+
detector = SmokeDetector.new(input_file, output_file, config)
|
|
1027
|
+
result = detector.process
|
|
1028
|
+
|
|
1029
|
+
# If processing failed, return input file unchanged
|
|
1030
|
+
unless result
|
|
1031
|
+
Utils::OutputFormatter.indent("⚠️ Smoke detection failed. Continuing with previous output.")
|
|
1032
|
+
return input_file
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
# Display processing report
|
|
1036
|
+
report = detector.report
|
|
1037
|
+
Utils::OutputFormatter.indent("Smoke regions detected: #{report[:smoke_detected]}")
|
|
1038
|
+
if report[:smoke_removed]
|
|
1039
|
+
Utils::OutputFormatter.indent("Smoke removal: ENABLED")
|
|
1040
|
+
else
|
|
1041
|
+
Utils::OutputFormatter.indent("Smoke removal: disabled (detection only)")
|
|
1042
|
+
end
|
|
1043
|
+
Utils::OutputFormatter.indent("Processing time: #{report[:processing_time]}s")
|
|
1044
|
+
|
|
1045
|
+
# Track input file for cleanup if it's an intermediate file
|
|
1046
|
+
if input_file != options[:image]
|
|
1047
|
+
intermediate_files << input_file unless intermediate_files.include?(input_file)
|
|
1048
|
+
end
|
|
1049
|
+
|
|
1050
|
+
Utils::OutputFormatter.success("Smoke detection complete")
|
|
1051
|
+
output_file
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
def generate_consolidated_filename
|
|
1055
|
+
"consolidated_spritesheet.png"
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
def split_frames_from_spritesheet(spritesheet_file, columns, rows, frames)
|
|
1059
|
+
# Determine frames directory based on spritesheet filename
|
|
1060
|
+
spritesheet_basename = File.basename(spritesheet_file, '.*')
|
|
1061
|
+
frames_dir = File.join(File.dirname(spritesheet_file), "#{spritesheet_basename}_frames")
|
|
1062
|
+
|
|
1063
|
+
# Split the spritesheet into individual frames
|
|
1064
|
+
splitter = Utils::SpritesheetSplitter.new
|
|
1065
|
+
splitter.split_into_frames(spritesheet_file, frames_dir, columns, rows, frames)
|
|
1066
|
+
end
|
|
1067
|
+
|
|
1068
|
+
def reassemble_frames(frame_files, output_file, columns)
|
|
1069
|
+
# Calculate rows needed for the specified columns
|
|
1070
|
+
total_frames = frame_files.length
|
|
1071
|
+
rows = (total_frames.to_f / columns).ceil
|
|
1072
|
+
|
|
1073
|
+
Utils::OutputFormatter.indent("Layout: #{columns}×#{rows} grid (#{total_frames} frames)")
|
|
1074
|
+
|
|
1075
|
+
# Use ImageMagick montage to create spritesheet
|
|
1076
|
+
# Montage arranges images in a grid
|
|
1077
|
+
cmd = [
|
|
1078
|
+
'magick',
|
|
1079
|
+
'montage',
|
|
1080
|
+
frame_files.map { |f| Utils::PathHelper.quote_path(f) }.join(' '),
|
|
1081
|
+
'-tile', "#{columns}x#{rows}",
|
|
1082
|
+
'-geometry', '+0+0', # No spacing between tiles
|
|
1083
|
+
'-background', 'none', # Transparent background
|
|
1084
|
+
Utils::PathHelper.quote_path(output_file)
|
|
1085
|
+
].join(' ')
|
|
1086
|
+
|
|
1087
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
1088
|
+
|
|
1089
|
+
unless status.success?
|
|
1090
|
+
raise ProcessingError, "Failed to reassemble frames: #{stderr}"
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
# Embed metadata in the reassembled spritesheet
|
|
1094
|
+
MetadataManager.embed(output_file, columns, rows, total_frames)
|
|
1095
|
+
|
|
1096
|
+
Utils::OutputFormatter.indent("✅ Reassembled into #{columns}×#{rows} spritesheet")
|
|
1097
|
+
Utils::OutputFormatter.indent("📝 Metadata embedded: #{columns}×#{rows} grid (#{total_frames} frames)")
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
def collect_intermediate_files(initial_file, final_file)
|
|
1101
|
+
# Find all files that were created during GIMP processing
|
|
1102
|
+
# Pattern: initial_file + suffixes like -nobg-fuzzy, -scaled-40pct, etc.
|
|
1103
|
+
# Note: output_filename uses DASH separator, not underscore
|
|
1104
|
+
dir = File.dirname(initial_file)
|
|
1105
|
+
basename = File.basename(initial_file, '.*')
|
|
1106
|
+
ext = File.extname(initial_file)
|
|
1107
|
+
|
|
1108
|
+
# Get all PNG files in the directory that start with the basename and have a dash
|
|
1109
|
+
pattern = File.join(dir, "#{basename}-*#{ext}")
|
|
1110
|
+
intermediate_files = Dir.glob(pattern)
|
|
1111
|
+
|
|
1112
|
+
# Normalize paths for comparison (Windows compatibility)
|
|
1113
|
+
initial_normalized = File.expand_path(initial_file)
|
|
1114
|
+
final_normalized = File.expand_path(final_file)
|
|
1115
|
+
|
|
1116
|
+
# Exclude the initial and final files
|
|
1117
|
+
intermediate_files.reject do |f|
|
|
1118
|
+
f_normalized = File.expand_path(f)
|
|
1119
|
+
f_normalized == initial_normalized || f_normalized == final_normalized
|
|
1120
|
+
end
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
def cleanup_intermediate_files(files)
|
|
1124
|
+
return if files.empty?
|
|
1125
|
+
|
|
1126
|
+
if options[:debug]
|
|
1127
|
+
Utils::OutputFormatter.note("Cleaning up #{files.length} intermediate file(s):")
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
files.each do |file|
|
|
1131
|
+
if File.exist?(file)
|
|
1132
|
+
File.delete(file)
|
|
1133
|
+
if options[:debug]
|
|
1134
|
+
Utils::OutputFormatter.indent("Deleted: #{File.basename(file)}")
|
|
1135
|
+
end
|
|
1136
|
+
end
|
|
1137
|
+
end
|
|
1138
|
+
end
|
|
1139
|
+
|
|
1140
|
+
def cleanup
|
|
1141
|
+
if options[:temp_dir] && Dir.exist?(options[:temp_dir])
|
|
1142
|
+
FileUtils.rm_rf(options[:temp_dir])
|
|
1143
|
+
Utils::OutputFormatter.note("Cleaned up temporary files") if options[:debug]
|
|
1144
|
+
end
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
def determine_split_parameters(image_file)
|
|
1148
|
+
metadata = MetadataManager.read(image_file)
|
|
1149
|
+
|
|
1150
|
+
# Check if we have metadata
|
|
1151
|
+
if metadata && metadata[:columns] && metadata[:rows] && metadata[:frames]
|
|
1152
|
+
# Metadata exists
|
|
1153
|
+
if options[:split] && !options[:override_md]
|
|
1154
|
+
# Warn user that split values will be ignored
|
|
1155
|
+
Utils::OutputFormatter.note("Image has metadata (#{metadata[:rows]}×#{metadata[:columns]}). Your --split values will be ignored. Use --override-md to override.")
|
|
1156
|
+
return [metadata[:rows], metadata[:columns], metadata[:frames]]
|
|
1157
|
+
elsif options[:split] && options[:override_md]
|
|
1158
|
+
# Use user's split values
|
|
1159
|
+
frames = @split_rows * @split_columns
|
|
1160
|
+
validate_image_dimensions(image_file, @split_rows, @split_columns)
|
|
1161
|
+
return [@split_rows, @split_columns, frames]
|
|
1162
|
+
else
|
|
1163
|
+
# Use metadata
|
|
1164
|
+
return [metadata[:rows], metadata[:columns], metadata[:frames]]
|
|
1165
|
+
end
|
|
1166
|
+
else
|
|
1167
|
+
# No metadata
|
|
1168
|
+
if options[:split]
|
|
1169
|
+
# Use user's split values
|
|
1170
|
+
frames = @split_rows * @split_columns
|
|
1171
|
+
validate_image_dimensions(image_file, @split_rows, @split_columns)
|
|
1172
|
+
return [@split_rows, @split_columns, frames]
|
|
1173
|
+
else
|
|
1174
|
+
# Error: no metadata and no split option
|
|
1175
|
+
raise ValidationError, "Image has no metadata. Please provide --split R:C"
|
|
1176
|
+
end
|
|
1177
|
+
end
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
def validate_image_dimensions(image_file, rows, columns)
|
|
1181
|
+
# Get image dimensions using ImageMagick
|
|
1182
|
+
cmd = [
|
|
1183
|
+
'magick',
|
|
1184
|
+
'identify',
|
|
1185
|
+
'-format', '%wx%h',
|
|
1186
|
+
Utils::PathHelper.quote_path(image_file)
|
|
1187
|
+
].join(' ')
|
|
1188
|
+
|
|
1189
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
1190
|
+
|
|
1191
|
+
unless status.success?
|
|
1192
|
+
raise ProcessingError, "Could not get image dimensions: #{stderr}"
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
width, height = stdout.strip.split('x').map(&:to_i)
|
|
1196
|
+
|
|
1197
|
+
# Check if dimensions divide evenly
|
|
1198
|
+
unless width % columns == 0
|
|
1199
|
+
raise ValidationError, "Image width (#{width}) not evenly divisible by #{columns} columns"
|
|
1200
|
+
end
|
|
1201
|
+
|
|
1202
|
+
unless height % rows == 0
|
|
1203
|
+
raise ValidationError, "Image height (#{height}) not evenly divisible by #{rows} rows"
|
|
1204
|
+
end
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
def apply_max_compression(file)
|
|
1208
|
+
Utils::OutputFormatter.note("Applying maximum compression...")
|
|
1209
|
+
|
|
1210
|
+
original_size = File.size(file)
|
|
1211
|
+
temp_file = file.gsub('.png', '_compressed_temp.png')
|
|
1212
|
+
|
|
1213
|
+
CompressionManager.compress_with_metadata(file, temp_file, debug: options[:debug])
|
|
1214
|
+
|
|
1215
|
+
# Show compression stats
|
|
1216
|
+
stats = CompressionManager.compression_stats(file, temp_file)
|
|
1217
|
+
|
|
1218
|
+
if options[:debug] || stats[:saved_bytes] > 0
|
|
1219
|
+
Utils::OutputFormatter.indent("Original: #{Utils::FileHelper.format_size(stats[:original_size])}")
|
|
1220
|
+
Utils::OutputFormatter.indent("Compressed: #{Utils::FileHelper.format_size(stats[:compressed_size])}")
|
|
1221
|
+
Utils::OutputFormatter.indent("Saved: #{Utils::FileHelper.format_size(stats[:saved_bytes])} (#{stats[:reduction_percent].round(1)}% reduction)")
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
# Replace original with compressed
|
|
1225
|
+
FileUtils.mv(temp_file, file)
|
|
1226
|
+
|
|
1227
|
+
file
|
|
1228
|
+
end
|
|
1229
|
+
end
|
|
1230
|
+
end
|