ruby_spriter 0.6.6 → 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 +138 -0
- data/README.md +336 -33
- data/lib/ruby_spriter/batch_processor.rb +212 -0
- data/lib/ruby_spriter/cli.rb +354 -7
- data/lib/ruby_spriter/compression_manager.rb +101 -0
- data/lib/ruby_spriter/consolidator.rb +33 -0
- data/lib/ruby_spriter/processor.rb +412 -7
- data/lib/ruby_spriter/version.rb +2 -2
- data/lib/ruby_spriter.rb +2 -0
- data/spec/ruby_spriter/batch_processor_spec.rb +200 -0
- data/spec/ruby_spriter/cli_spec.rb +387 -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 +350 -0
- metadata +5 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module RubySpriter
|
|
7
|
+
# Manages PNG compression with metadata preservation
|
|
8
|
+
class CompressionManager
|
|
9
|
+
# Compress PNG file using ImageMagick with maximum compression
|
|
10
|
+
# @param input_file [String] Source PNG file
|
|
11
|
+
# @param output_file [String] Destination PNG file
|
|
12
|
+
# @param debug [Boolean] Enable debug output
|
|
13
|
+
def self.compress(input_file, output_file, debug: false)
|
|
14
|
+
Utils::FileHelper.validate_readable!(input_file)
|
|
15
|
+
|
|
16
|
+
cmd = build_compression_command(input_file, output_file)
|
|
17
|
+
|
|
18
|
+
if debug
|
|
19
|
+
Utils::OutputFormatter.indent("DEBUG: Compression command: #{cmd}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
23
|
+
|
|
24
|
+
unless status.success?
|
|
25
|
+
raise ProcessingError, "Failed to compress PNG: #{stderr}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Utils::FileHelper.validate_exists!(output_file)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Compress PNG file while preserving embedded metadata
|
|
32
|
+
# @param input_file [String] Source PNG file
|
|
33
|
+
# @param output_file [String] Destination PNG file
|
|
34
|
+
# @param debug [Boolean] Enable debug output
|
|
35
|
+
def self.compress_with_metadata(input_file, output_file, debug: false)
|
|
36
|
+
# Read metadata before compression
|
|
37
|
+
metadata = MetadataManager.read(input_file)
|
|
38
|
+
|
|
39
|
+
# Compress the file
|
|
40
|
+
temp_file = output_file.gsub('.png', '_compress_temp.png')
|
|
41
|
+
compress(input_file, temp_file, debug: debug)
|
|
42
|
+
|
|
43
|
+
# Re-embed metadata if it existed
|
|
44
|
+
if metadata
|
|
45
|
+
MetadataManager.embed(
|
|
46
|
+
temp_file,
|
|
47
|
+
output_file,
|
|
48
|
+
columns: metadata[:columns],
|
|
49
|
+
rows: metadata[:rows],
|
|
50
|
+
frames: metadata[:frames],
|
|
51
|
+
debug: debug
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Clean up temp file
|
|
55
|
+
FileUtils.rm_f(temp_file) if File.exist?(temp_file)
|
|
56
|
+
else
|
|
57
|
+
# No metadata, just move temp to output
|
|
58
|
+
FileUtils.mv(temp_file, output_file)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get compression statistics
|
|
63
|
+
# @param original_file [String] Original file path
|
|
64
|
+
# @param compressed_file [String] Compressed file path
|
|
65
|
+
# @return [Hash] Statistics including sizes and reduction percentage
|
|
66
|
+
def self.compression_stats(original_file, compressed_file)
|
|
67
|
+
original_size = File.size(original_file)
|
|
68
|
+
compressed_size = File.size(compressed_file)
|
|
69
|
+
saved_bytes = original_size - compressed_size
|
|
70
|
+
reduction_percent = (saved_bytes.to_f / original_size * 100.0)
|
|
71
|
+
|
|
72
|
+
{
|
|
73
|
+
original_size: original_size,
|
|
74
|
+
compressed_size: compressed_size,
|
|
75
|
+
saved_bytes: saved_bytes,
|
|
76
|
+
reduction_percent: reduction_percent
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private_class_method def self.build_compression_command(input_file, output_file)
|
|
81
|
+
magick_cmd = Platform.imagemagick_convert_cmd
|
|
82
|
+
|
|
83
|
+
# Use maximum PNG compression settings:
|
|
84
|
+
# - compression-level=9: Maximum zlib compression
|
|
85
|
+
# - compression-filter=5: Paeth filter (best for most images)
|
|
86
|
+
# - compression-strategy=1: Filtered strategy
|
|
87
|
+
# - quality=95: High quality
|
|
88
|
+
# - strip: Remove all metadata (we'll re-add it later)
|
|
89
|
+
[
|
|
90
|
+
magick_cmd,
|
|
91
|
+
Utils::PathHelper.quote_path(input_file),
|
|
92
|
+
'-strip',
|
|
93
|
+
'-define', 'png:compression-level=9',
|
|
94
|
+
'-define', 'png:compression-filter=5',
|
|
95
|
+
'-define', 'png:compression-strategy=1',
|
|
96
|
+
'-quality', '95',
|
|
97
|
+
Utils::PathHelper.quote_path(output_file)
|
|
98
|
+
].join(' ')
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -58,6 +58,39 @@ module RubySpriter
|
|
|
58
58
|
}
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# Find all PNG files with spritesheet metadata in a directory
|
|
62
|
+
# @param directory [String] Directory path to scan
|
|
63
|
+
# @return [Array<String>] Sorted array of spritesheet file paths
|
|
64
|
+
def find_spritesheets_in_directory(directory)
|
|
65
|
+
# Validate directory exists
|
|
66
|
+
unless File.directory?(directory)
|
|
67
|
+
raise ValidationError, "Directory not found: #{directory}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Find all PNG files
|
|
71
|
+
pattern = File.join(directory, '*.png')
|
|
72
|
+
png_files = Dir.glob(pattern)
|
|
73
|
+
|
|
74
|
+
# Filter to only files with metadata
|
|
75
|
+
spritesheets = png_files.select do |file|
|
|
76
|
+
metadata = MetadataManager.read(file)
|
|
77
|
+
!metadata.nil?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Validate we found at least one
|
|
81
|
+
if spritesheets.empty?
|
|
82
|
+
raise ValidationError, "No PNG files with spritesheet metadata found in directory: #{directory}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Validate we have at least 2
|
|
86
|
+
if spritesheets.length < 2
|
|
87
|
+
raise ValidationError, "Found only #{spritesheets.length} spritesheet, need at least 2 for consolidation"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Sort alphabetically by filename
|
|
91
|
+
spritesheets.sort
|
|
92
|
+
end
|
|
93
|
+
|
|
61
94
|
private
|
|
62
95
|
|
|
63
96
|
def validate_files!(files)
|
|
@@ -27,6 +27,8 @@ module RubySpriter
|
|
|
27
27
|
@gimp_path = nil
|
|
28
28
|
validate_numeric_options!
|
|
29
29
|
validate_split_option!
|
|
30
|
+
validate_extract_option!
|
|
31
|
+
validate_add_meta_option!
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
# Run the processing workflow
|
|
@@ -74,24 +76,51 @@ module RubySpriter
|
|
|
74
76
|
overwrite: false,
|
|
75
77
|
save_frames: false,
|
|
76
78
|
split: nil,
|
|
77
|
-
override_md: false
|
|
79
|
+
override_md: false,
|
|
80
|
+
extract: nil,
|
|
81
|
+
add_meta: nil,
|
|
82
|
+
overwrite_meta: false
|
|
78
83
|
}
|
|
79
84
|
end
|
|
80
85
|
|
|
81
86
|
def validate_options!
|
|
82
|
-
input_modes = [options[:video], options[:image], options[:
|
|
87
|
+
input_modes = [options[:video], options[:image], options[:consolidate_mode], options[:verify], options[:batch]].compact
|
|
83
88
|
|
|
84
89
|
if input_modes.empty?
|
|
85
|
-
raise ValidationError, "Must specify --video, --image, --consolidate, or --
|
|
90
|
+
raise ValidationError, "Must specify --video, --image, --consolidate, --verify, or --batch"
|
|
86
91
|
end
|
|
87
92
|
|
|
88
93
|
if input_modes.length > 1
|
|
89
94
|
raise ValidationError, "Cannot use multiple input modes together. Choose one."
|
|
90
95
|
end
|
|
91
96
|
|
|
97
|
+
validate_consolidate_options!
|
|
92
98
|
validate_input_files!
|
|
93
99
|
validate_numeric_options!
|
|
94
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
|
|
95
124
|
end
|
|
96
125
|
|
|
97
126
|
def validate_input_files!
|
|
@@ -177,6 +206,119 @@ module RubySpriter
|
|
|
177
206
|
@split_columns = columns
|
|
178
207
|
end
|
|
179
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
|
+
|
|
180
322
|
def check_dependencies!
|
|
181
323
|
checker = DependencyChecker.new(verbose: options[:debug])
|
|
182
324
|
results = checker.check_all
|
|
@@ -231,7 +373,9 @@ module RubySpriter
|
|
|
231
373
|
return { mode: :verify, file: options[:verify] }
|
|
232
374
|
end
|
|
233
375
|
|
|
234
|
-
if options[:
|
|
376
|
+
if options[:batch]
|
|
377
|
+
return execute_batch_workflow
|
|
378
|
+
elsif options[:consolidate_mode]
|
|
235
379
|
return execute_consolidate_workflow
|
|
236
380
|
elsif options[:image]
|
|
237
381
|
return execute_image_workflow
|
|
@@ -277,7 +421,12 @@ module RubySpriter
|
|
|
277
421
|
# Step 5: Clean up intermediate files
|
|
278
422
|
cleanup_intermediate_files(intermediate_files)
|
|
279
423
|
|
|
280
|
-
# Step 6:
|
|
424
|
+
# Step 6: Apply max compression if requested
|
|
425
|
+
if options[:max_compress]
|
|
426
|
+
working_file = apply_max_compression(working_file)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Step 7: Extract individual frames if requested
|
|
281
430
|
if options[:save_frames]
|
|
282
431
|
split_frames_from_spritesheet(working_file, result[:columns], result[:rows], result[:frames])
|
|
283
432
|
end
|
|
@@ -292,6 +441,16 @@ module RubySpriter
|
|
|
292
441
|
working_file = options[:image]
|
|
293
442
|
intermediate_files = []
|
|
294
443
|
|
|
444
|
+
# Handle metadata addition workflow first
|
|
445
|
+
if options[:add_meta]
|
|
446
|
+
return execute_add_meta_workflow
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Handle frame extraction workflow
|
|
450
|
+
if options[:extract]
|
|
451
|
+
return execute_extract_workflow
|
|
452
|
+
end
|
|
453
|
+
|
|
295
454
|
# Apply GIMP processing if requested (GimpProcessor handles uniqueness)
|
|
296
455
|
if needs_gimp?
|
|
297
456
|
initial_file = working_file
|
|
@@ -317,6 +476,11 @@ module RubySpriter
|
|
|
317
476
|
# Clean up intermediate files
|
|
318
477
|
cleanup_intermediate_files(intermediate_files)
|
|
319
478
|
|
|
479
|
+
# Apply max compression if requested
|
|
480
|
+
if options[:max_compress]
|
|
481
|
+
working_file = apply_max_compression(working_file)
|
|
482
|
+
end
|
|
483
|
+
|
|
320
484
|
# Determine if we should split the image into frames
|
|
321
485
|
should_split = options[:save_frames] || options[:split]
|
|
322
486
|
|
|
@@ -338,13 +502,192 @@ module RubySpriter
|
|
|
338
502
|
}
|
|
339
503
|
end
|
|
340
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
|
+
|
|
341
652
|
def execute_consolidate_workflow
|
|
342
653
|
consolidator = Consolidator.new(options)
|
|
343
654
|
|
|
344
|
-
|
|
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
|
+
|
|
345
682
|
final_output = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
|
|
346
683
|
|
|
347
|
-
result = consolidator.consolidate(
|
|
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
|
|
348
691
|
|
|
349
692
|
Utils::OutputFormatter.header("SUCCESS!")
|
|
350
693
|
Utils::OutputFormatter.success("Final output: #{result[:output_file]}")
|
|
@@ -352,6 +695,13 @@ module RubySpriter
|
|
|
352
695
|
result.merge(mode: :consolidate)
|
|
353
696
|
end
|
|
354
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
|
+
|
|
355
705
|
def process_with_gimp(input_file)
|
|
356
706
|
gimp_processor = GimpProcessor.new(@gimp_path, options)
|
|
357
707
|
gimp_processor.process(input_file)
|
|
@@ -371,6 +721,38 @@ module RubySpriter
|
|
|
371
721
|
splitter.split_into_frames(spritesheet_file, frames_dir, columns, rows, frames)
|
|
372
722
|
end
|
|
373
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
|
+
|
|
374
756
|
def collect_intermediate_files(initial_file, final_file)
|
|
375
757
|
# Find all files that were created during GIMP processing
|
|
376
758
|
# Pattern: initial_file + suffixes like -nobg-fuzzy, -scaled-40pct, etc.
|
|
@@ -477,5 +859,28 @@ module RubySpriter
|
|
|
477
859
|
raise ValidationError, "Image height (#{height}) not evenly divisible by #{rows} rows"
|
|
478
860
|
end
|
|
479
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
|
|
480
885
|
end
|
|
481
886
|
end
|
data/lib/ruby_spriter/version.rb
CHANGED
data/lib/ruby_spriter.rb
CHANGED
|
@@ -26,6 +26,8 @@ require_relative 'ruby_spriter/metadata_manager'
|
|
|
26
26
|
require_relative 'ruby_spriter/video_processor'
|
|
27
27
|
require_relative 'ruby_spriter/gimp_processor'
|
|
28
28
|
require_relative 'ruby_spriter/consolidator'
|
|
29
|
+
require_relative 'ruby_spriter/compression_manager'
|
|
30
|
+
require_relative 'ruby_spriter/batch_processor'
|
|
29
31
|
|
|
30
32
|
# Load orchestration
|
|
31
33
|
require_relative 'ruby_spriter/processor'
|