ruby_spriter 0.6.5 → 0.6.7
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 +188 -0
- data/README.md +374 -33
- data/lib/ruby_spriter/batch_processor.rb +212 -0
- data/lib/ruby_spriter/cli.rb +369 -6
- data/lib/ruby_spriter/compression_manager.rb +101 -0
- data/lib/ruby_spriter/consolidator.rb +33 -0
- data/lib/ruby_spriter/gimp_processor.rb +6 -3
- data/lib/ruby_spriter/processor.rb +661 -26
- 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 +2 -2
- data/lib/ruby_spriter/video_processor.rb +7 -7
- data/lib/ruby_spriter.rb +3 -0
- data/spec/ruby_spriter/batch_processor_spec.rb +200 -0
- data/spec/ruby_spriter/cli_spec.rb +750 -0
- data/spec/ruby_spriter/compression_manager_spec.rb +157 -0
- data/spec/ruby_spriter/consolidator_spec.rb +163 -0
- data/spec/ruby_spriter/processor_spec.rb +735 -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 +8 -2
|
@@ -2,15 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'tmpdir'
|
|
5
|
+
require 'open3'
|
|
5
6
|
|
|
6
7
|
module RubySpriter
|
|
7
8
|
# Main orchestration processor
|
|
8
9
|
class Processor
|
|
9
|
-
attr_reader :options, :gimp_path
|
|
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
|
|
10
24
|
|
|
11
25
|
def initialize(options = {})
|
|
12
26
|
@options = default_options.merge(options)
|
|
13
27
|
@gimp_path = nil
|
|
28
|
+
validate_numeric_options!
|
|
29
|
+
validate_split_option!
|
|
30
|
+
validate_extract_option!
|
|
31
|
+
validate_add_meta_option!
|
|
14
32
|
end
|
|
15
33
|
|
|
16
34
|
# Run the processing workflow
|
|
@@ -54,22 +72,55 @@ module RubySpriter
|
|
|
54
72
|
validate_columns: true,
|
|
55
73
|
temp_dir: nil,
|
|
56
74
|
keep_temp: false,
|
|
57
|
-
debug: false
|
|
75
|
+
debug: false,
|
|
76
|
+
overwrite: false,
|
|
77
|
+
save_frames: false,
|
|
78
|
+
split: nil,
|
|
79
|
+
override_md: false,
|
|
80
|
+
extract: nil,
|
|
81
|
+
add_meta: nil,
|
|
82
|
+
overwrite_meta: false
|
|
58
83
|
}
|
|
59
84
|
end
|
|
60
85
|
|
|
61
86
|
def validate_options!
|
|
62
|
-
input_modes = [options[:video], options[:image], options[:
|
|
63
|
-
|
|
87
|
+
input_modes = [options[:video], options[:image], options[:consolidate_mode], options[:verify], options[:batch]].compact
|
|
88
|
+
|
|
64
89
|
if input_modes.empty?
|
|
65
|
-
raise ValidationError, "Must specify --video, --image, --consolidate, or --
|
|
90
|
+
raise ValidationError, "Must specify --video, --image, --consolidate, --verify, or --batch"
|
|
66
91
|
end
|
|
67
92
|
|
|
68
93
|
if input_modes.length > 1
|
|
69
94
|
raise ValidationError, "Cannot use multiple input modes together. Choose one."
|
|
70
95
|
end
|
|
71
96
|
|
|
97
|
+
validate_consolidate_options!
|
|
72
98
|
validate_input_files!
|
|
99
|
+
validate_numeric_options!
|
|
100
|
+
validate_split_option!
|
|
101
|
+
validate_extract_option!
|
|
102
|
+
validate_add_meta_option!
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_consolidate_options!
|
|
106
|
+
return unless options[:consolidate_mode]
|
|
107
|
+
|
|
108
|
+
# Check for mutual exclusivity between file list and directory
|
|
109
|
+
if options[:consolidate] && options[:dir]
|
|
110
|
+
raise ValidationError, "Cannot use --dir with comma-separated file list for --consolidate. Choose one method."
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Require either file list or directory
|
|
114
|
+
unless options[:consolidate] || options[:dir]
|
|
115
|
+
raise ValidationError, "--consolidate requires either comma-separated files or --dir option"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Validate directory if using directory mode
|
|
119
|
+
if options[:dir] && !options[:consolidate]
|
|
120
|
+
unless File.directory?(options[:dir])
|
|
121
|
+
raise ValidationError, "Directory not found: #{options[:dir]}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
73
124
|
end
|
|
74
125
|
|
|
75
126
|
def validate_input_files!
|
|
@@ -108,6 +159,166 @@ module RubySpriter
|
|
|
108
159
|
end
|
|
109
160
|
end
|
|
110
161
|
|
|
162
|
+
def validate_numeric_options!
|
|
163
|
+
VALID_RANGES.each do |option_name, range_config|
|
|
164
|
+
value = options[option_name]
|
|
165
|
+
|
|
166
|
+
# Skip validation if option is not set (nil)
|
|
167
|
+
next if value.nil?
|
|
168
|
+
|
|
169
|
+
min = range_config[:min]
|
|
170
|
+
max = range_config[:max]
|
|
171
|
+
|
|
172
|
+
# Validate that value is within range
|
|
173
|
+
if value < min || value > max
|
|
174
|
+
raise ValidationError, "#{option_name} must be between #{min} and #{max}, got: #{value}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def validate_split_option!
|
|
180
|
+
return unless options[:split]
|
|
181
|
+
|
|
182
|
+
# Parse split format: R:C
|
|
183
|
+
unless options[:split] =~ /^\d+:\d+$/
|
|
184
|
+
raise ValidationError, "Invalid --split format. Use R:C (e.g., 4:4)"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
rows, columns = options[:split].split(':').map(&:to_i)
|
|
188
|
+
|
|
189
|
+
# Validate ranges
|
|
190
|
+
if rows < 1 || rows > 99
|
|
191
|
+
raise ValidationError, "rows must be between 1 and 99, got: #{rows}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
if columns < 1 || columns > 99
|
|
195
|
+
raise ValidationError, "columns must be between 1 and 99, got: #{columns}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Validate total frames < 1000
|
|
199
|
+
total_frames = rows * columns
|
|
200
|
+
if total_frames >= 1000
|
|
201
|
+
raise ValidationError, "Total frames (#{total_frames}) must be less than 1000"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Store parsed values for later use
|
|
205
|
+
@split_rows = rows
|
|
206
|
+
@split_columns = columns
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def validate_extract_option!
|
|
210
|
+
return unless options[:extract]
|
|
211
|
+
|
|
212
|
+
# Parse extract format: comma-separated integers (allow negatives for better error messages)
|
|
213
|
+
unless options[:extract] =~ /^-?\d+(,-?\d+)*$/
|
|
214
|
+
raise ValidationError, "Invalid --extract format. Use comma-separated frame numbers (e.g., 1,2,4,5,8)"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Parse frame numbers
|
|
218
|
+
frame_numbers = options[:extract].split(',').map(&:to_i)
|
|
219
|
+
|
|
220
|
+
# Validate minimum 2 frames
|
|
221
|
+
if frame_numbers.length < 2
|
|
222
|
+
raise ValidationError, "--extract requires at least 2 frames, got: #{frame_numbers.length}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Validate frame numbers are 1-indexed (no 0 or negative)
|
|
226
|
+
invalid_frames = frame_numbers.select { |n| n <= 0 }
|
|
227
|
+
if invalid_frames.any?
|
|
228
|
+
raise ValidationError, "Frame numbers must be 1-indexed (positive integers), got invalid: #{invalid_frames.join(', ')}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Check for metadata (required for extraction)
|
|
232
|
+
return unless options[:image] # Only validate bounds if we have an image path
|
|
233
|
+
|
|
234
|
+
image_file = options[:image]
|
|
235
|
+
metadata = MetadataManager.read(image_file)
|
|
236
|
+
|
|
237
|
+
unless metadata
|
|
238
|
+
raise ValidationError, "Image has no metadata. Cannot extract frames without knowing the grid layout. Use --add-meta first."
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Validate frame numbers are within bounds
|
|
242
|
+
total_frames = metadata[:frames]
|
|
243
|
+
out_of_bounds = frame_numbers.select { |n| n > total_frames }
|
|
244
|
+
if out_of_bounds.any?
|
|
245
|
+
first_oob = out_of_bounds.first
|
|
246
|
+
raise ValidationError, "Frame #{first_oob} is out of bounds (image only has #{total_frames} frames)"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Set default columns if not specified
|
|
250
|
+
options[:columns] ||= 4
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def validate_add_meta_option!
|
|
254
|
+
return unless options[:add_meta]
|
|
255
|
+
|
|
256
|
+
# Parse add-meta format: R:C
|
|
257
|
+
unless options[:add_meta] =~ /^\d+:\d+$/
|
|
258
|
+
raise ValidationError, "Invalid --add-meta format. Use R:C (e.g., 4:4)"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
rows, columns = options[:add_meta].split(':').map(&:to_i)
|
|
262
|
+
|
|
263
|
+
# Validate ranges
|
|
264
|
+
if rows < 1 || rows > 99
|
|
265
|
+
raise ValidationError, "rows must be between 1 and 99, got: #{rows}"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
if columns < 1 || columns > 99
|
|
269
|
+
raise ValidationError, "columns must be between 1 and 99, got: #{columns}"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Validate total frames < 1000
|
|
273
|
+
total_frames = rows * columns
|
|
274
|
+
if total_frames >= 1000
|
|
275
|
+
raise ValidationError, "Total frames (#{total_frames}) must be less than 1000"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Check if we need to validate against image file
|
|
279
|
+
return unless options[:image]
|
|
280
|
+
|
|
281
|
+
image_file = options[:image]
|
|
282
|
+
metadata = MetadataManager.read(image_file)
|
|
283
|
+
|
|
284
|
+
# Check for existing metadata
|
|
285
|
+
if metadata && !options[:overwrite_meta]
|
|
286
|
+
raise ValidationError, "Image already has spritesheet metadata. Use --overwrite-meta to replace it."
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Validate image dimensions divide evenly by grid
|
|
290
|
+
dimensions = get_image_dimensions(image_file)
|
|
291
|
+
tile_width = dimensions[:width] / columns.to_f
|
|
292
|
+
tile_height = dimensions[:height] / rows.to_f
|
|
293
|
+
|
|
294
|
+
unless tile_width == tile_width.to_i && tile_height == tile_height.to_i
|
|
295
|
+
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}"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Validate custom frame count doesn't exceed grid size
|
|
299
|
+
if options[:frame_count] && options[:frame_count] > total_frames
|
|
300
|
+
raise ValidationError, "Frame count (#{options[:frame_count]}) exceeds grid size (#{total_frames})"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def get_image_dimensions(image_file)
|
|
305
|
+
cmd = [
|
|
306
|
+
'magick',
|
|
307
|
+
'identify',
|
|
308
|
+
'-format', '%wx%h',
|
|
309
|
+
Utils::PathHelper.quote_path(image_file)
|
|
310
|
+
].join(' ')
|
|
311
|
+
|
|
312
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
313
|
+
|
|
314
|
+
unless status.success?
|
|
315
|
+
raise ProcessingError, "Could not get image dimensions: #{stderr}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
width, height = stdout.strip.split('x').map(&:to_i)
|
|
319
|
+
{ width: width, height: height }
|
|
320
|
+
end
|
|
321
|
+
|
|
111
322
|
def check_dependencies!
|
|
112
323
|
checker = DependencyChecker.new(verbose: options[:debug])
|
|
113
324
|
results = checker.check_all
|
|
@@ -119,8 +330,8 @@ module RubySpriter
|
|
|
119
330
|
missing << tool unless results[tool][:available]
|
|
120
331
|
end
|
|
121
332
|
|
|
122
|
-
# GIMP only needed for
|
|
123
|
-
if
|
|
333
|
+
# GIMP only needed for scaling and background removal (not for sharpen-only)
|
|
334
|
+
if needs_gimp_specifically? && !results[:gimp][:available]
|
|
124
335
|
missing << :gimp
|
|
125
336
|
end
|
|
126
337
|
|
|
@@ -137,6 +348,10 @@ module RubySpriter
|
|
|
137
348
|
end
|
|
138
349
|
|
|
139
350
|
def needs_gimp?
|
|
351
|
+
options[:scale_percent] || options[:remove_bg] || options[:sharpen]
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def needs_gimp_specifically?
|
|
140
355
|
options[:scale_percent] || options[:remove_bg]
|
|
141
356
|
end
|
|
142
357
|
|
|
@@ -158,7 +373,9 @@ module RubySpriter
|
|
|
158
373
|
return { mode: :verify, file: options[:verify] }
|
|
159
374
|
end
|
|
160
375
|
|
|
161
|
-
if options[:
|
|
376
|
+
if options[:batch]
|
|
377
|
+
return execute_batch_workflow
|
|
378
|
+
elsif options[:consolidate_mode]
|
|
162
379
|
return execute_consolidate_workflow
|
|
163
380
|
elsif options[:image]
|
|
164
381
|
return execute_image_workflow
|
|
@@ -168,24 +385,50 @@ module RubySpriter
|
|
|
168
385
|
end
|
|
169
386
|
|
|
170
387
|
def execute_video_workflow
|
|
171
|
-
# Step 1:
|
|
388
|
+
# Step 1: Determine output filename
|
|
389
|
+
desired_output = options[:output] || Utils::FileHelper.spritesheet_filename(options[:video])
|
|
390
|
+
final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
|
|
391
|
+
|
|
392
|
+
# Step 2: Convert video to spritesheet
|
|
172
393
|
video_processor = VideoProcessor.new(options)
|
|
173
394
|
result = video_processor.create_spritesheet(
|
|
174
395
|
options[:video],
|
|
175
|
-
|
|
396
|
+
final_output
|
|
176
397
|
)
|
|
177
398
|
|
|
178
399
|
working_file = result[:output_file]
|
|
400
|
+
intermediate_files = []
|
|
179
401
|
|
|
180
|
-
# Step
|
|
402
|
+
# Step 3: Apply GIMP processing if requested
|
|
181
403
|
if needs_gimp?
|
|
404
|
+
initial_file = working_file
|
|
182
405
|
working_file = process_with_gimp(working_file)
|
|
406
|
+
|
|
407
|
+
# Track intermediate files for cleanup (everything except initial and final)
|
|
408
|
+
if working_file != initial_file
|
|
409
|
+
intermediate_files = collect_intermediate_files(initial_file, working_file)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Step 4: Move to final output location if different
|
|
414
|
+
if final_output != working_file
|
|
415
|
+
FileUtils.cp(working_file, final_output)
|
|
416
|
+
# Add the GIMP output to intermediates if it's different from final
|
|
417
|
+
intermediate_files << working_file unless intermediate_files.include?(working_file)
|
|
418
|
+
working_file = final_output
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Step 5: Clean up intermediate files
|
|
422
|
+
cleanup_intermediate_files(intermediate_files)
|
|
423
|
+
|
|
424
|
+
# Step 6: Apply max compression if requested
|
|
425
|
+
if options[:max_compress]
|
|
426
|
+
working_file = apply_max_compression(working_file)
|
|
183
427
|
end
|
|
184
428
|
|
|
185
|
-
# Step
|
|
186
|
-
if options[:
|
|
187
|
-
|
|
188
|
-
working_file = options[:output]
|
|
429
|
+
# Step 7: Extract individual frames if requested
|
|
430
|
+
if options[:save_frames]
|
|
431
|
+
split_frames_from_spritesheet(working_file, result[:columns], result[:rows], result[:frames])
|
|
189
432
|
end
|
|
190
433
|
|
|
191
434
|
Utils::OutputFormatter.header("SUCCESS!")
|
|
@@ -196,16 +439,57 @@ module RubySpriter
|
|
|
196
439
|
|
|
197
440
|
def execute_image_workflow
|
|
198
441
|
working_file = options[:image]
|
|
442
|
+
intermediate_files = []
|
|
443
|
+
|
|
444
|
+
# Handle metadata addition workflow first
|
|
445
|
+
if options[:add_meta]
|
|
446
|
+
return execute_add_meta_workflow
|
|
447
|
+
end
|
|
199
448
|
|
|
200
|
-
#
|
|
449
|
+
# Handle frame extraction workflow
|
|
450
|
+
if options[:extract]
|
|
451
|
+
return execute_extract_workflow
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Apply GIMP processing if requested (GimpProcessor handles uniqueness)
|
|
201
455
|
if needs_gimp?
|
|
456
|
+
initial_file = working_file
|
|
202
457
|
working_file = process_with_gimp(working_file)
|
|
458
|
+
|
|
459
|
+
# Track intermediate files for cleanup (everything except initial and final)
|
|
460
|
+
if working_file != initial_file
|
|
461
|
+
intermediate_files = collect_intermediate_files(initial_file, working_file)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Move to final output location if user specified explicit --output
|
|
466
|
+
if options[:output]
|
|
467
|
+
final_output = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
|
|
468
|
+
if working_file != final_output
|
|
469
|
+
FileUtils.cp(working_file, final_output)
|
|
470
|
+
# Add the GIMP output to intermediates if it's different from final
|
|
471
|
+
intermediate_files << working_file unless intermediate_files.include?(working_file)
|
|
472
|
+
working_file = final_output
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Clean up intermediate files
|
|
477
|
+
cleanup_intermediate_files(intermediate_files)
|
|
478
|
+
|
|
479
|
+
# Apply max compression if requested
|
|
480
|
+
if options[:max_compress]
|
|
481
|
+
working_file = apply_max_compression(working_file)
|
|
203
482
|
end
|
|
204
483
|
|
|
205
|
-
#
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
484
|
+
# Determine if we should split the image into frames
|
|
485
|
+
should_split = options[:save_frames] || options[:split]
|
|
486
|
+
|
|
487
|
+
if should_split
|
|
488
|
+
# Determine rows, columns, and frames to use
|
|
489
|
+
rows, columns, frames = determine_split_parameters(working_file)
|
|
490
|
+
|
|
491
|
+
# Split the image into frames
|
|
492
|
+
split_frames_from_spritesheet(working_file, columns, rows, frames)
|
|
209
493
|
end
|
|
210
494
|
|
|
211
495
|
Utils::OutputFormatter.header("SUCCESS!")
|
|
@@ -218,12 +502,192 @@ module RubySpriter
|
|
|
218
502
|
}
|
|
219
503
|
end
|
|
220
504
|
|
|
505
|
+
def execute_extract_workflow
|
|
506
|
+
input_file = options[:image]
|
|
507
|
+
metadata = MetadataManager.read(input_file)
|
|
508
|
+
frame_numbers = options[:extract].split(',').map(&:to_i)
|
|
509
|
+
columns = options[:columns]
|
|
510
|
+
|
|
511
|
+
Utils::OutputFormatter.header("Frame Extraction")
|
|
512
|
+
Utils::OutputFormatter.indent("Input: #{input_file}")
|
|
513
|
+
Utils::OutputFormatter.indent("Frames to extract: #{frame_numbers.join(', ')}")
|
|
514
|
+
Utils::OutputFormatter.indent("Output columns: #{columns}")
|
|
515
|
+
|
|
516
|
+
# Step 1: Extract all frames to temp directory
|
|
517
|
+
temp_frames_dir = File.join(options[:temp_dir], 'extracted_frames')
|
|
518
|
+
splitter = Utils::SpritesheetSplitter.new
|
|
519
|
+
splitter.split_into_frames(input_file, temp_frames_dir, metadata[:columns], metadata[:rows], metadata[:frames])
|
|
520
|
+
|
|
521
|
+
# Step 2: Keep only requested frames, delete the rest
|
|
522
|
+
spritesheet_basename = File.basename(input_file, '.*')
|
|
523
|
+
all_frame_files = Dir.glob(File.join(temp_frames_dir, "FR*_#{spritesheet_basename}.png")).sort
|
|
524
|
+
requested_frame_files = frame_numbers.map do |frame_num|
|
|
525
|
+
# Frame files are named FR001, FR002, etc. (1-indexed)
|
|
526
|
+
File.join(temp_frames_dir, "FR#{format('%03d', frame_num)}_#{spritesheet_basename}.png")
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Delete unwanted frames
|
|
530
|
+
(all_frame_files - requested_frame_files).each { |f| FileUtils.rm_f(f) }
|
|
531
|
+
|
|
532
|
+
Utils::OutputFormatter.indent("Kept #{requested_frame_files.length} frames, deleted #{all_frame_files.length - requested_frame_files.length} frames")
|
|
533
|
+
|
|
534
|
+
# Step 3: Reassemble into new spritesheet
|
|
535
|
+
Utils::OutputFormatter.header("Reassembling Spritesheet")
|
|
536
|
+
reassembled_file = File.join(options[:temp_dir], "reassembled_#{spritesheet_basename}.png")
|
|
537
|
+
reassemble_frames(requested_frame_files, reassembled_file, columns)
|
|
538
|
+
|
|
539
|
+
working_file = reassembled_file
|
|
540
|
+
intermediate_files = []
|
|
541
|
+
|
|
542
|
+
# Step 4: Apply GIMP processing if requested
|
|
543
|
+
if needs_gimp?
|
|
544
|
+
initial_file = working_file
|
|
545
|
+
working_file = process_with_gimp(working_file)
|
|
546
|
+
|
|
547
|
+
if working_file != initial_file
|
|
548
|
+
intermediate_files = collect_intermediate_files(initial_file, working_file)
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Step 5: Determine final output filename
|
|
553
|
+
if options[:output]
|
|
554
|
+
final_output = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
|
|
555
|
+
else
|
|
556
|
+
# Auto-generate output filename with _extracted suffix
|
|
557
|
+
base = File.basename(input_file, '.*')
|
|
558
|
+
ext = File.extname(input_file)
|
|
559
|
+
desired_output = File.join(File.dirname(input_file), "#{base}_extracted#{ext}")
|
|
560
|
+
final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Step 6: Copy to final output
|
|
564
|
+
FileUtils.cp(working_file, final_output)
|
|
565
|
+
working_file = final_output
|
|
566
|
+
|
|
567
|
+
# Step 7: Clean up intermediate files
|
|
568
|
+
cleanup_intermediate_files(intermediate_files)
|
|
569
|
+
|
|
570
|
+
# Step 8: Apply max compression if requested
|
|
571
|
+
if options[:max_compress]
|
|
572
|
+
working_file = apply_max_compression(working_file)
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Step 9: Optionally save individual frames
|
|
576
|
+
if options[:save_frames]
|
|
577
|
+
frames_output_dir = File.join(File.dirname(working_file), "#{File.basename(working_file, '.*')}_frames")
|
|
578
|
+
FileUtils.mkdir_p(frames_output_dir)
|
|
579
|
+
requested_frame_files.each_with_index do |frame_file, idx|
|
|
580
|
+
frame_num = frame_numbers[idx]
|
|
581
|
+
dest = File.join(frames_output_dir, "FR#{format('%03d', frame_num)}_#{spritesheet_basename}.png")
|
|
582
|
+
FileUtils.cp(frame_file, dest)
|
|
583
|
+
end
|
|
584
|
+
Utils::OutputFormatter.indent("Saved #{requested_frame_files.length} frames to: #{frames_output_dir}")
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
Utils::OutputFormatter.header("SUCCESS!")
|
|
588
|
+
Utils::OutputFormatter.success("Extracted spritesheet: #{working_file}")
|
|
589
|
+
|
|
590
|
+
{
|
|
591
|
+
mode: :extract,
|
|
592
|
+
input_file: input_file,
|
|
593
|
+
output_file: working_file,
|
|
594
|
+
frames_extracted: frame_numbers.length,
|
|
595
|
+
columns: columns
|
|
596
|
+
}
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def execute_add_meta_workflow
|
|
600
|
+
input_file = options[:image]
|
|
601
|
+
rows, columns = options[:add_meta].split(':').map(&:to_i)
|
|
602
|
+
|
|
603
|
+
# Determine frame count
|
|
604
|
+
frame_count = if options[:frame_count]
|
|
605
|
+
options[:frame_count]
|
|
606
|
+
else
|
|
607
|
+
rows * columns
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
Utils::OutputFormatter.header("Adding Metadata")
|
|
611
|
+
Utils::OutputFormatter.indent("Input: #{input_file}")
|
|
612
|
+
Utils::OutputFormatter.indent("Grid: #{rows}×#{columns} (#{frame_count} frames)")
|
|
613
|
+
|
|
614
|
+
# Determine output file
|
|
615
|
+
if options[:output]
|
|
616
|
+
# User specified explicit output
|
|
617
|
+
output_file = Utils::FileHelper.ensure_unique_output(options[:output], overwrite: options[:overwrite])
|
|
618
|
+
|
|
619
|
+
# Copy input to output
|
|
620
|
+
FileUtils.cp(input_file, output_file)
|
|
621
|
+
Utils::OutputFormatter.indent("Copied to: #{output_file}")
|
|
622
|
+
else
|
|
623
|
+
# In-place modification
|
|
624
|
+
if options[:overwrite]
|
|
625
|
+
output_file = input_file
|
|
626
|
+
Utils::OutputFormatter.indent("Modifying in-place (--overwrite specified)")
|
|
627
|
+
else
|
|
628
|
+
# Create unique filename
|
|
629
|
+
output_file = Utils::FileHelper.ensure_unique_output(input_file, overwrite: false)
|
|
630
|
+
FileUtils.cp(input_file, output_file)
|
|
631
|
+
Utils::OutputFormatter.indent("Created: #{output_file}")
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Embed metadata
|
|
636
|
+
MetadataManager.embed(output_file, columns, rows, frame_count)
|
|
637
|
+
Utils::OutputFormatter.indent("📝 Metadata embedded: #{columns}×#{rows} grid (#{frame_count} frames)")
|
|
638
|
+
|
|
639
|
+
Utils::OutputFormatter.header("SUCCESS!")
|
|
640
|
+
Utils::OutputFormatter.success("Metadata added to: #{output_file}")
|
|
641
|
+
|
|
642
|
+
{
|
|
643
|
+
mode: :add_meta,
|
|
644
|
+
input_file: input_file,
|
|
645
|
+
output_file: output_file,
|
|
646
|
+
columns: columns,
|
|
647
|
+
rows: rows,
|
|
648
|
+
frames: frame_count
|
|
649
|
+
}
|
|
650
|
+
end
|
|
651
|
+
|
|
221
652
|
def execute_consolidate_workflow
|
|
222
653
|
consolidator = Consolidator.new(options)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
654
|
+
|
|
655
|
+
# Determine file list: either from command line or from directory
|
|
656
|
+
files_to_consolidate = if options[:dir] && !options[:consolidate]
|
|
657
|
+
# Directory-based consolidation
|
|
658
|
+
consolidator.find_spritesheets_in_directory(options[:dir])
|
|
659
|
+
else
|
|
660
|
+
# File list consolidation
|
|
661
|
+
options[:consolidate]
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Determine output filename and directory
|
|
665
|
+
if options[:dir] && !options[:consolidate]
|
|
666
|
+
# Directory mode: output to dir or outputdir
|
|
667
|
+
output_dir = options[:outputdir] || options[:dir]
|
|
668
|
+
desired_output = if options[:output]
|
|
669
|
+
File.join(output_dir, File.basename(options[:output]))
|
|
670
|
+
else
|
|
671
|
+
File.join(output_dir, generate_consolidated_filename)
|
|
672
|
+
end
|
|
673
|
+
else
|
|
674
|
+
# File list mode: use current directory behavior
|
|
675
|
+
if options[:outputdir]
|
|
676
|
+
desired_output = File.join(options[:outputdir], options[:output] || generate_consolidated_filename)
|
|
677
|
+
else
|
|
678
|
+
desired_output = options[:output] || generate_consolidated_filename
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
|
|
683
|
+
|
|
684
|
+
result = consolidator.consolidate(files_to_consolidate, final_output)
|
|
685
|
+
|
|
686
|
+
# Apply max compression if requested
|
|
687
|
+
if options[:max_compress]
|
|
688
|
+
final_output = apply_max_compression(result[:output_file])
|
|
689
|
+
result[:output_file] = final_output
|
|
690
|
+
end
|
|
227
691
|
|
|
228
692
|
Utils::OutputFormatter.header("SUCCESS!")
|
|
229
693
|
Utils::OutputFormatter.success("Final output: #{result[:output_file]}")
|
|
@@ -231,14 +695,102 @@ module RubySpriter
|
|
|
231
695
|
result.merge(mode: :consolidate)
|
|
232
696
|
end
|
|
233
697
|
|
|
698
|
+
def execute_batch_workflow
|
|
699
|
+
batch_processor = BatchProcessor.new(options)
|
|
700
|
+
result = batch_processor.process
|
|
701
|
+
|
|
702
|
+
result.merge(mode: :batch)
|
|
703
|
+
end
|
|
704
|
+
|
|
234
705
|
def process_with_gimp(input_file)
|
|
235
706
|
gimp_processor = GimpProcessor.new(@gimp_path, options)
|
|
236
707
|
gimp_processor.process(input_file)
|
|
237
708
|
end
|
|
238
709
|
|
|
239
710
|
def generate_consolidated_filename
|
|
240
|
-
|
|
241
|
-
|
|
711
|
+
"consolidated_spritesheet.png"
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
def split_frames_from_spritesheet(spritesheet_file, columns, rows, frames)
|
|
715
|
+
# Determine frames directory based on spritesheet filename
|
|
716
|
+
spritesheet_basename = File.basename(spritesheet_file, '.*')
|
|
717
|
+
frames_dir = File.join(File.dirname(spritesheet_file), "#{spritesheet_basename}_frames")
|
|
718
|
+
|
|
719
|
+
# Split the spritesheet into individual frames
|
|
720
|
+
splitter = Utils::SpritesheetSplitter.new
|
|
721
|
+
splitter.split_into_frames(spritesheet_file, frames_dir, columns, rows, frames)
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
def reassemble_frames(frame_files, output_file, columns)
|
|
725
|
+
# Calculate rows needed for the specified columns
|
|
726
|
+
total_frames = frame_files.length
|
|
727
|
+
rows = (total_frames.to_f / columns).ceil
|
|
728
|
+
|
|
729
|
+
Utils::OutputFormatter.indent("Layout: #{columns}×#{rows} grid (#{total_frames} frames)")
|
|
730
|
+
|
|
731
|
+
# Use ImageMagick montage to create spritesheet
|
|
732
|
+
# Montage arranges images in a grid
|
|
733
|
+
cmd = [
|
|
734
|
+
'magick',
|
|
735
|
+
'montage',
|
|
736
|
+
frame_files.map { |f| Utils::PathHelper.quote_path(f) }.join(' '),
|
|
737
|
+
'-tile', "#{columns}x#{rows}",
|
|
738
|
+
'-geometry', '+0+0', # No spacing between tiles
|
|
739
|
+
'-background', 'none', # Transparent background
|
|
740
|
+
Utils::PathHelper.quote_path(output_file)
|
|
741
|
+
].join(' ')
|
|
742
|
+
|
|
743
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
744
|
+
|
|
745
|
+
unless status.success?
|
|
746
|
+
raise ProcessingError, "Failed to reassemble frames: #{stderr}"
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# Embed metadata in the reassembled spritesheet
|
|
750
|
+
MetadataManager.embed(output_file, columns, rows, total_frames)
|
|
751
|
+
|
|
752
|
+
Utils::OutputFormatter.indent("✅ Reassembled into #{columns}×#{rows} spritesheet")
|
|
753
|
+
Utils::OutputFormatter.indent("📝 Metadata embedded: #{columns}×#{rows} grid (#{total_frames} frames)")
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def collect_intermediate_files(initial_file, final_file)
|
|
757
|
+
# Find all files that were created during GIMP processing
|
|
758
|
+
# Pattern: initial_file + suffixes like -nobg-fuzzy, -scaled-40pct, etc.
|
|
759
|
+
# Note: output_filename uses DASH separator, not underscore
|
|
760
|
+
dir = File.dirname(initial_file)
|
|
761
|
+
basename = File.basename(initial_file, '.*')
|
|
762
|
+
ext = File.extname(initial_file)
|
|
763
|
+
|
|
764
|
+
# Get all PNG files in the directory that start with the basename and have a dash
|
|
765
|
+
pattern = File.join(dir, "#{basename}-*#{ext}")
|
|
766
|
+
intermediate_files = Dir.glob(pattern)
|
|
767
|
+
|
|
768
|
+
# Normalize paths for comparison (Windows compatibility)
|
|
769
|
+
initial_normalized = File.expand_path(initial_file)
|
|
770
|
+
final_normalized = File.expand_path(final_file)
|
|
771
|
+
|
|
772
|
+
# Exclude the initial and final files
|
|
773
|
+
intermediate_files.reject do |f|
|
|
774
|
+
f_normalized = File.expand_path(f)
|
|
775
|
+
f_normalized == initial_normalized || f_normalized == final_normalized
|
|
776
|
+
end
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
def cleanup_intermediate_files(files)
|
|
780
|
+
return if files.empty?
|
|
781
|
+
|
|
782
|
+
if options[:debug]
|
|
783
|
+
Utils::OutputFormatter.note("Cleaning up #{files.length} intermediate file(s):")
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
files.each do |file|
|
|
787
|
+
if File.exist?(file)
|
|
788
|
+
File.delete(file)
|
|
789
|
+
if options[:debug]
|
|
790
|
+
Utils::OutputFormatter.indent("Deleted: #{File.basename(file)}")
|
|
791
|
+
end
|
|
792
|
+
end
|
|
793
|
+
end
|
|
242
794
|
end
|
|
243
795
|
|
|
244
796
|
def cleanup
|
|
@@ -247,5 +799,88 @@ module RubySpriter
|
|
|
247
799
|
Utils::OutputFormatter.note("Cleaned up temporary files") if options[:debug]
|
|
248
800
|
end
|
|
249
801
|
end
|
|
802
|
+
|
|
803
|
+
def determine_split_parameters(image_file)
|
|
804
|
+
metadata = MetadataManager.read(image_file)
|
|
805
|
+
|
|
806
|
+
# Check if we have metadata
|
|
807
|
+
if metadata && metadata[:columns] && metadata[:rows] && metadata[:frames]
|
|
808
|
+
# Metadata exists
|
|
809
|
+
if options[:split] && !options[:override_md]
|
|
810
|
+
# Warn user that split values will be ignored
|
|
811
|
+
Utils::OutputFormatter.note("Image has metadata (#{metadata[:rows]}×#{metadata[:columns]}). Your --split values will be ignored. Use --override-md to override.")
|
|
812
|
+
return [metadata[:rows], metadata[:columns], metadata[:frames]]
|
|
813
|
+
elsif options[:split] && options[:override_md]
|
|
814
|
+
# Use user's split values
|
|
815
|
+
frames = @split_rows * @split_columns
|
|
816
|
+
validate_image_dimensions(image_file, @split_rows, @split_columns)
|
|
817
|
+
return [@split_rows, @split_columns, frames]
|
|
818
|
+
else
|
|
819
|
+
# Use metadata
|
|
820
|
+
return [metadata[:rows], metadata[:columns], metadata[:frames]]
|
|
821
|
+
end
|
|
822
|
+
else
|
|
823
|
+
# No metadata
|
|
824
|
+
if options[:split]
|
|
825
|
+
# Use user's split values
|
|
826
|
+
frames = @split_rows * @split_columns
|
|
827
|
+
validate_image_dimensions(image_file, @split_rows, @split_columns)
|
|
828
|
+
return [@split_rows, @split_columns, frames]
|
|
829
|
+
else
|
|
830
|
+
# Error: no metadata and no split option
|
|
831
|
+
raise ValidationError, "Image has no metadata. Please provide --split R:C"
|
|
832
|
+
end
|
|
833
|
+
end
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def validate_image_dimensions(image_file, rows, columns)
|
|
837
|
+
# Get image dimensions using ImageMagick
|
|
838
|
+
cmd = [
|
|
839
|
+
'magick',
|
|
840
|
+
'identify',
|
|
841
|
+
'-format', '%wx%h',
|
|
842
|
+
Utils::PathHelper.quote_path(image_file)
|
|
843
|
+
].join(' ')
|
|
844
|
+
|
|
845
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
846
|
+
|
|
847
|
+
unless status.success?
|
|
848
|
+
raise ProcessingError, "Could not get image dimensions: #{stderr}"
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
width, height = stdout.strip.split('x').map(&:to_i)
|
|
852
|
+
|
|
853
|
+
# Check if dimensions divide evenly
|
|
854
|
+
unless width % columns == 0
|
|
855
|
+
raise ValidationError, "Image width (#{width}) not evenly divisible by #{columns} columns"
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
unless height % rows == 0
|
|
859
|
+
raise ValidationError, "Image height (#{height}) not evenly divisible by #{rows} rows"
|
|
860
|
+
end
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
def apply_max_compression(file)
|
|
864
|
+
Utils::OutputFormatter.note("Applying maximum compression...")
|
|
865
|
+
|
|
866
|
+
original_size = File.size(file)
|
|
867
|
+
temp_file = file.gsub('.png', '_compressed_temp.png')
|
|
868
|
+
|
|
869
|
+
CompressionManager.compress_with_metadata(file, temp_file, debug: options[:debug])
|
|
870
|
+
|
|
871
|
+
# Show compression stats
|
|
872
|
+
stats = CompressionManager.compression_stats(file, temp_file)
|
|
873
|
+
|
|
874
|
+
if options[:debug] || stats[:saved_bytes] > 0
|
|
875
|
+
Utils::OutputFormatter.indent("Original: #{Utils::FileHelper.format_size(stats[:original_size])}")
|
|
876
|
+
Utils::OutputFormatter.indent("Compressed: #{Utils::FileHelper.format_size(stats[:compressed_size])}")
|
|
877
|
+
Utils::OutputFormatter.indent("Saved: #{Utils::FileHelper.format_size(stats[:saved_bytes])} (#{stats[:reduction_percent].round(1)}% reduction)")
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
# Replace original with compressed
|
|
881
|
+
FileUtils.mv(temp_file, file)
|
|
882
|
+
|
|
883
|
+
file
|
|
884
|
+
end
|
|
250
885
|
end
|
|
251
886
|
end
|