ruby_spriter 0.6.7.1 → 0.7.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rspec +3 -3
- data/CHANGELOG.md +1035 -524
- data/Gemfile +17 -17
- data/LICENSE +21 -21
- data/README.md +183 -950
- data/bin/ruby_spriter +20 -20
- data/lib/ruby_spriter/background_sampler.rb +140 -0
- data/lib/ruby_spriter/batch_processor.rb +268 -214
- data/lib/ruby_spriter/cell_cleanup_config.rb +21 -0
- data/lib/ruby_spriter/cell_cleanup_gimp_script.rb +123 -0
- data/lib/ruby_spriter/cell_cleanup_processor.rb +230 -0
- data/lib/ruby_spriter/cli.rb +676 -612
- data/lib/ruby_spriter/compression_manager.rb +101 -101
- data/lib/ruby_spriter/consolidator.rb +179 -179
- data/lib/ruby_spriter/dependency_checker.rb +224 -224
- data/lib/ruby_spriter/ghost_edge_cleaner.rb +164 -0
- data/lib/ruby_spriter/gimp_processor.rb +1188 -1058
- data/lib/ruby_spriter/metadata_manager.rb +117 -116
- data/lib/ruby_spriter/platform.rb +137 -137
- data/lib/ruby_spriter/processor.rb +1230 -891
- data/lib/ruby_spriter/smoke_detector.rb +223 -0
- data/lib/ruby_spriter/threshold_stepper.rb +227 -0
- data/lib/ruby_spriter/utils/file_helper.rb +82 -82
- data/lib/ruby_spriter/utils/image_helper.rb +16 -0
- data/lib/ruby_spriter/utils/output_formatter.rb +65 -65
- data/lib/ruby_spriter/utils/path_helper.rb +59 -59
- data/lib/ruby_spriter/utils/spritesheet_splitter.rb +86 -86
- data/lib/ruby_spriter/version.rb +6 -7
- data/lib/ruby_spriter/video_processor.rb +357 -139
- data/lib/ruby_spriter.rb +38 -34
- data/ruby_spriter.gemspec +44 -42
- data/spec/fixtures/centered_sprite_with_inner_bg.png +0 -0
- data/spec/fixtures/centered_sprite_with_inner_bg_inner_removed.png +0 -0
- data/spec/fixtures/centered_sprite_with_inner_bg_threshold_stepped.png +0 -0
- data/spec/fixtures/complex_background_sprite.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing.mp4 +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet-nobg-global.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431-nobg-global.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738-nobg-global.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738.png +0 -0
- data/spec/fixtures/has_inner_bg.png +0 -0
- data/spec/fixtures/has_small_inner_bg.png +0 -0
- data/spec/fixtures/smoke_effect_sprite.png +0 -0
- data/spec/fixtures/spritesheet_with_metadata.png +0 -0
- data/spec/fixtures/test_sprite.png +0 -0
- data/spec/fixtures/test_sprite_smoke_processed.png +0 -0
- data/spec/fixtures/test_video_spritesheet.png +0 -0
- data/spec/fixtures/transparent_bg_sprite.png +0 -0
- data/spec/fixtures/walk_north_sprite-sheet.png +0 -0
- data/spec/integration/no_fuzzy_mode_spec.rb +100 -0
- data/spec/ruby_spriter/batch_processor_spec.rb +509 -200
- data/spec/ruby_spriter/cli_spec.rb +2026 -1892
- data/spec/ruby_spriter/compression_manager_spec.rb +157 -157
- data/spec/ruby_spriter/consolidator_spec.rb +538 -538
- data/spec/ruby_spriter/gimp_processor_spec.rb +523 -425
- data/spec/ruby_spriter/platform_spec.rb +92 -92
- data/spec/ruby_spriter/processor_spec.rb +911 -735
- data/spec/ruby_spriter/utils/file_helper_spec.rb +150 -150
- data/spec/ruby_spriter/utils/path_helper_spec.rb +78 -78
- data/spec/ruby_spriter/utils/spritesheet_splitter_spec.rb +104 -104
- data/spec/ruby_spriter/video_processor_spec.rb +346 -29
- data/spec/spec_helper.rb +41 -41
- data/spec/tmp/cli_test_output.png +0 -0
- data/spec/tmp/cli_test_output_20251105_114647_395.png +0 -0
- data/spec/tmp/combined_test.png +0 -0
- data/spec/tmp/compat_test.png +0 -0
- data/spec/tmp/compat_test_20251030_174233_361.png +0 -0
- data/spec/tmp/final_all_features.png +0 -0
- data/spec/tmp/final_test_all_features.png +0 -0
- data/spec/tmp/full_pipeline_test.png +0 -0
- data/spec/tmp/inner_test.png +0 -0
- data/spec/tmp/integration_test.png +0 -0
- data/spec/tmp/validation_test.png +0 -0
- data/spec/unit/background_sampler_spec.rb +132 -0
- data/spec/unit/cell_cleanup_config_spec.rb +32 -0
- data/spec/unit/cell_cleanup_gimp_script_spec.rb +59 -0
- data/spec/unit/cell_cleanup_processor_spec.rb +261 -0
- data/spec/unit/ghost_edge_cleaner_spec.rb +223 -0
- data/spec/unit/gimp_processor_single_point_selection_spec.rb +81 -0
- data/spec/unit/smoke_detector_spec.rb +246 -0
- data/spec/unit/threshold_stepper_spec.rb +195 -0
- metadata +56 -10
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module RubySpriter
|
|
2
|
+
class CellCleanupConfig
|
|
3
|
+
attr_accessor :threshold, :parallel, :skip_empty
|
|
4
|
+
|
|
5
|
+
def initialize(options = {})
|
|
6
|
+
@threshold = options[:cell_cleanup_threshold] || 15.0
|
|
7
|
+
@parallel = options.fetch(:cell_cleanup_parallel, true)
|
|
8
|
+
@skip_empty = options.fetch(:cell_cleanup_skip_empty, true)
|
|
9
|
+
|
|
10
|
+
validate!
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def validate!
|
|
16
|
+
unless @threshold.between?(1.0, 50.0)
|
|
17
|
+
raise ValidationError, "cell_cleanup_threshold must be between 1.0 and 50.0 (got: #{@threshold})"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
module RubySpriter
|
|
2
|
+
module CellCleanupGimpScript
|
|
3
|
+
def self.generate_cleanup_script(input_path, output_path, dominant_colors)
|
|
4
|
+
# Normalize paths for Python
|
|
5
|
+
input_normalized = Utils::PathHelper.normalize_for_python(input_path)
|
|
6
|
+
output_normalized = Utils::PathHelper.normalize_for_python(output_path)
|
|
7
|
+
|
|
8
|
+
# Convert RGB strings to Python color strings
|
|
9
|
+
colors_py = dominant_colors.map do |color_str|
|
|
10
|
+
# Parse "rgb(255,0,0)" format
|
|
11
|
+
match = color_str.match(/rgb\((\d+),(\d+),(\d+)\)/)
|
|
12
|
+
r = match[1].to_i
|
|
13
|
+
g = match[2].to_i
|
|
14
|
+
b = match[3].to_i
|
|
15
|
+
"{'r': #{r}, 'g': #{g}, 'b': #{b}}"
|
|
16
|
+
end.join(', ')
|
|
17
|
+
|
|
18
|
+
<<~PYTHON
|
|
19
|
+
import sys
|
|
20
|
+
import gc
|
|
21
|
+
from gi.repository import Gimp, Gio, Gegl
|
|
22
|
+
|
|
23
|
+
img = None
|
|
24
|
+
layer = None
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
print("Loading image...")
|
|
28
|
+
img = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE,
|
|
29
|
+
Gio.File.new_for_path(r'#{input_normalized}'))
|
|
30
|
+
|
|
31
|
+
w = img.get_width()
|
|
32
|
+
h = img.get_height()
|
|
33
|
+
print(f"Image size: {w}x{h}")
|
|
34
|
+
|
|
35
|
+
layers = img.get_layers()
|
|
36
|
+
if not layers or len(layers) == 0:
|
|
37
|
+
raise Exception("No layers found")
|
|
38
|
+
layer = layers[0]
|
|
39
|
+
|
|
40
|
+
# Add alpha channel if needed
|
|
41
|
+
if not layer.has_alpha():
|
|
42
|
+
layer.add_alpha()
|
|
43
|
+
print("Added alpha channel")
|
|
44
|
+
|
|
45
|
+
pdb = Gimp.get_pdb()
|
|
46
|
+
|
|
47
|
+
# Select dominant colors for removal
|
|
48
|
+
print("Selecting dominant colors...")
|
|
49
|
+
select_proc = pdb.lookup_procedure('gimp-image-select-color')
|
|
50
|
+
if not select_proc:
|
|
51
|
+
raise Exception("Could not find gimp-image-select-color procedure")
|
|
52
|
+
|
|
53
|
+
for i, color_dict in enumerate([#{colors_py}]):
|
|
54
|
+
r, g, b = color_dict['r'], color_dict['g'], color_dict['b']
|
|
55
|
+
print(f" Selecting color {i+1}: RGB({r}, {g}, {b})")
|
|
56
|
+
|
|
57
|
+
# Create Gegl.Color from normalized RGB values
|
|
58
|
+
color = Gegl.Color.new(f"rgb({r/255.0}, {g/255.0}, {b/255.0})")
|
|
59
|
+
|
|
60
|
+
config = select_proc.create_config()
|
|
61
|
+
config.set_property('image', img)
|
|
62
|
+
# Use REPLACE for first color, ADD for subsequent colors
|
|
63
|
+
if i == 0:
|
|
64
|
+
config.set_property('operation', Gimp.ChannelOps.REPLACE)
|
|
65
|
+
else:
|
|
66
|
+
config.set_property('operation', Gimp.ChannelOps.ADD)
|
|
67
|
+
config.set_property('drawable', layer)
|
|
68
|
+
config.set_property('color', color)
|
|
69
|
+
select_proc.run(config)
|
|
70
|
+
|
|
71
|
+
print("Colors selected")
|
|
72
|
+
|
|
73
|
+
# Delete selection (make transparent)
|
|
74
|
+
print("Removing selected colors...")
|
|
75
|
+
edit_clear = pdb.lookup_procedure('gimp-drawable-edit-clear')
|
|
76
|
+
if edit_clear:
|
|
77
|
+
config = edit_clear.create_config()
|
|
78
|
+
config.set_property('drawable', layer)
|
|
79
|
+
edit_clear.run(config)
|
|
80
|
+
print("Selection cleared")
|
|
81
|
+
|
|
82
|
+
# Deselect
|
|
83
|
+
print("Deselecting...")
|
|
84
|
+
select_none = pdb.lookup_procedure('gimp-selection-none')
|
|
85
|
+
if select_none:
|
|
86
|
+
config = select_none.create_config()
|
|
87
|
+
config.set_property('image', img)
|
|
88
|
+
select_none.run(config)
|
|
89
|
+
|
|
90
|
+
# Export
|
|
91
|
+
print("Exporting...")
|
|
92
|
+
export_proc = pdb.lookup_procedure('file-png-export')
|
|
93
|
+
if export_proc:
|
|
94
|
+
config = export_proc.create_config()
|
|
95
|
+
config.set_property('image', img)
|
|
96
|
+
config.set_property('file', Gio.File.new_for_path(r'#{output_normalized}'))
|
|
97
|
+
export_proc.run(config)
|
|
98
|
+
print("SUCCESS - Cell cleanup complete!")
|
|
99
|
+
else:
|
|
100
|
+
raise Exception("Could not find file-png-export procedure")
|
|
101
|
+
|
|
102
|
+
except Exception as e:
|
|
103
|
+
print(f"ERROR: {e}")
|
|
104
|
+
import traceback
|
|
105
|
+
traceback.print_exc()
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
finally:
|
|
108
|
+
# Explicit cleanup to minimize GEGL warnings
|
|
109
|
+
try:
|
|
110
|
+
if layer is not None:
|
|
111
|
+
layer = None
|
|
112
|
+
if img is not None:
|
|
113
|
+
gc.collect() # Force garbage collection
|
|
114
|
+
img.delete()
|
|
115
|
+
img = None
|
|
116
|
+
gc.collect() # Force again after deletion
|
|
117
|
+
except Exception as cleanup_error:
|
|
118
|
+
print(f"Cleanup warning: {cleanup_error}")
|
|
119
|
+
pass
|
|
120
|
+
PYTHON
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
require 'open3'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require_relative 'cell_cleanup_config'
|
|
5
|
+
require_relative 'cell_cleanup_gimp_script'
|
|
6
|
+
require_relative 'gimp_processor'
|
|
7
|
+
require_relative 'utils/image_helper'
|
|
8
|
+
|
|
9
|
+
module RubySpriter
|
|
10
|
+
class CellCleanupProcessor
|
|
11
|
+
|
|
12
|
+
def initialize(options = {})
|
|
13
|
+
@config = CellCleanupConfig.new(options)
|
|
14
|
+
@gimp_processor = GimpProcessor.new(options[:gimp_path], options)
|
|
15
|
+
@options = options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Main method to cleanup background colors in spritesheet cells
|
|
19
|
+
# @param spritesheet_path [String] Path to the spritesheet PNG
|
|
20
|
+
# @param options [Hash] Processing options
|
|
21
|
+
# @return [Hash] Statistics about cleanup process
|
|
22
|
+
def cleanup_cells(spritesheet_path, options)
|
|
23
|
+
cell_dims = calculate_cell_dimensions(spritesheet_path, options)
|
|
24
|
+
rows = calculate_rows(options)
|
|
25
|
+
columns = options[:columns]
|
|
26
|
+
|
|
27
|
+
temp_dir = create_temp_dir
|
|
28
|
+
cleaned_cells = []
|
|
29
|
+
stats = { processed: 0, cleaned: 0, skipped: 0, colors_removed: 0 }
|
|
30
|
+
|
|
31
|
+
puts " Analyzing spritesheet: #{columns}×#{rows} grid (#{rows * columns} cells)"
|
|
32
|
+
puts " Dominance threshold: #{@config.threshold}%\n\n"
|
|
33
|
+
|
|
34
|
+
(0...rows).each do |row|
|
|
35
|
+
(0...columns).each do |col|
|
|
36
|
+
stats[:processed] += 1
|
|
37
|
+
|
|
38
|
+
# Extract cell region
|
|
39
|
+
cell_path = extract_cell(spritesheet_path, row, col, cell_dims[:width], cell_dims[:height], temp_dir)
|
|
40
|
+
|
|
41
|
+
# Analyze for dominant colors
|
|
42
|
+
dominant_colors = analyze_cell_colors(cell_path)
|
|
43
|
+
|
|
44
|
+
if dominant_colors && !dominant_colors.empty?
|
|
45
|
+
# Remove dominant colors via GIMP
|
|
46
|
+
cleaned_cell = remove_dominant_colors(cell_path, dominant_colors, options, temp_dir)
|
|
47
|
+
cleaned_cells << cleaned_cell
|
|
48
|
+
stats[:cleaned] += 1
|
|
49
|
+
stats[:colors_removed] += dominant_colors.length
|
|
50
|
+
|
|
51
|
+
puts " Cell [#{row},#{col}]: Removed #{dominant_colors.length} dominant color(s)"
|
|
52
|
+
else
|
|
53
|
+
# No cleanup needed
|
|
54
|
+
cleaned_cells << cell_path
|
|
55
|
+
stats[:skipped] += 1
|
|
56
|
+
puts " Cell [#{row},#{col}]: No dominant colors detected (skipped)"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Reassemble cleaned cells
|
|
62
|
+
reassemble_spritesheet(cleaned_cells, columns, rows, spritesheet_path)
|
|
63
|
+
|
|
64
|
+
puts "\n ✓ Cleanup complete"
|
|
65
|
+
puts " - Processed: #{stats[:processed]} cells"
|
|
66
|
+
puts " - Cleaned: #{stats[:cleaned]} cells"
|
|
67
|
+
puts " - Skipped: #{stats[:skipped]} cells"
|
|
68
|
+
puts " - Dominant colors removed: #{stats[:colors_removed]} total\n"
|
|
69
|
+
|
|
70
|
+
stats
|
|
71
|
+
ensure
|
|
72
|
+
cleanup_temp_dir(temp_dir) if temp_dir
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def calculate_cell_dimensions(spritesheet_path, options)
|
|
78
|
+
image_info = Utils::ImageHelper.get_dimensions(spritesheet_path)
|
|
79
|
+
|
|
80
|
+
columns = options[:columns]
|
|
81
|
+
raise ProcessingError, "columns is nil or zero" if columns.nil? || columns == 0
|
|
82
|
+
|
|
83
|
+
rows = calculate_rows(options)
|
|
84
|
+
raise ProcessingError, "rows is nil or zero" if rows.nil? || rows == 0
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
width: image_info[:width] / columns,
|
|
88
|
+
height: image_info[:height] / rows
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def calculate_rows(options)
|
|
93
|
+
frames = options[:frames]
|
|
94
|
+
columns = options[:columns]
|
|
95
|
+
|
|
96
|
+
raise ProcessingError, "frames is nil or zero: #{frames.inspect}" if frames.nil? || frames == 0
|
|
97
|
+
raise ProcessingError, "columns is nil or zero: #{columns.inspect}" if columns.nil? || columns == 0
|
|
98
|
+
|
|
99
|
+
(frames.to_f / columns).ceil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_histogram(histogram_output)
|
|
103
|
+
colors = {}
|
|
104
|
+
|
|
105
|
+
histogram_output.each_line do |line|
|
|
106
|
+
# Parse ImageMagick histogram format:
|
|
107
|
+
# "1234: (255,0,0) #FF0000 srgb(255,0,0)"
|
|
108
|
+
next unless line.match(/^\s*(\d+):\s*\((\d+),(\d+),(\d+)/)
|
|
109
|
+
|
|
110
|
+
count = $1.to_i
|
|
111
|
+
r = $2.to_i
|
|
112
|
+
g = $3.to_i
|
|
113
|
+
b = $4.to_i
|
|
114
|
+
|
|
115
|
+
# Skip fully transparent pixels (indicated by rgba format with alpha=0)
|
|
116
|
+
next if line.include?('srgba') && line.include?(',0)')
|
|
117
|
+
|
|
118
|
+
colors["rgb(#{r},#{g},#{b})"] = count
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
colors
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def analyze_cell_colors(cell_image_path)
|
|
125
|
+
# Extract histogram using ImageMagick
|
|
126
|
+
convert_cmd = Platform.imagemagick_convert_cmd
|
|
127
|
+
cmd = "#{convert_cmd} #{Utils::PathHelper.quote_path(cell_image_path)} -define histogram:unique-colors=true -format %c histogram:info:-"
|
|
128
|
+
histogram_output = execute_command(cmd)
|
|
129
|
+
|
|
130
|
+
# Parse histogram into color => pixel_count hash
|
|
131
|
+
colors = parse_histogram(histogram_output)
|
|
132
|
+
|
|
133
|
+
# Calculate total non-transparent pixels
|
|
134
|
+
total_pixels = colors.values.sum
|
|
135
|
+
return nil if total_pixels == 0 # Empty cell
|
|
136
|
+
|
|
137
|
+
# Find colors exceeding dominance threshold
|
|
138
|
+
dominant_colors = colors.select do |color, count|
|
|
139
|
+
percentage = (count.to_f / total_pixels) * 100
|
|
140
|
+
percentage >= @config.threshold
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Return dominant colors or nil if none found
|
|
144
|
+
dominant_colors.empty? ? nil : dominant_colors.keys
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def execute_command(cmd)
|
|
148
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
149
|
+
raise ProcessingError, "Command failed: #{stderr}" unless status.success?
|
|
150
|
+
stdout
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def create_temp_dir
|
|
154
|
+
Dir.mktmpdir('cell_cleanup')
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def cleanup_temp_dir(temp_dir)
|
|
158
|
+
FileUtils.rm_rf(temp_dir) if temp_dir && Dir.exist?(temp_dir)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def extract_cell(spritesheet_path, row, col, cell_width, cell_height, temp_dir)
|
|
162
|
+
x_offset = col * cell_width
|
|
163
|
+
y_offset = row * cell_height
|
|
164
|
+
cell_path = File.join(temp_dir, "cell_#{row}_#{col}.png")
|
|
165
|
+
|
|
166
|
+
# Use ImageMagick crop: convert spritesheet.png -crop WxH+X+Y +repage cell.png
|
|
167
|
+
convert_cmd = Platform.imagemagick_convert_cmd
|
|
168
|
+
cmd = [
|
|
169
|
+
convert_cmd,
|
|
170
|
+
Utils::PathHelper.quote_path(spritesheet_path),
|
|
171
|
+
'-crop', "#{cell_width}x#{cell_height}+#{x_offset}+#{y_offset}",
|
|
172
|
+
'+repage',
|
|
173
|
+
Utils::PathHelper.quote_path(cell_path)
|
|
174
|
+
].join(' ')
|
|
175
|
+
|
|
176
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
177
|
+
|
|
178
|
+
unless status.success?
|
|
179
|
+
raise ProcessingError, "Failed to extract cell: #{stderr}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
cell_path
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def remove_dominant_colors(cell_path, dominant_colors, options, temp_dir)
|
|
186
|
+
cleaned_path = cell_path.sub('.png', '_cleaned.png')
|
|
187
|
+
|
|
188
|
+
# Generate GIMP Python-fu script
|
|
189
|
+
script_content = CellCleanupGimpScript.generate_cleanup_script(
|
|
190
|
+
cell_path,
|
|
191
|
+
cleaned_path,
|
|
192
|
+
dominant_colors
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Execute GIMP script - pass script content and expected output file
|
|
196
|
+
success = @gimp_processor.execute_python_script(script_content, cleaned_path)
|
|
197
|
+
|
|
198
|
+
unless success
|
|
199
|
+
raise ProcessingError, "GIMP script failed to create output file: #{cleaned_path}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Validate output was created
|
|
203
|
+
Utils::FileHelper.validate_exists!(cleaned_path)
|
|
204
|
+
|
|
205
|
+
cleaned_path
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def reassemble_spritesheet(cell_paths, columns, rows, output_path)
|
|
209
|
+
# Use ImageMagick montage to reassemble cells
|
|
210
|
+
quoted_paths = cell_paths.map { |p| Utils::PathHelper.quote_path(p) }.join(' ')
|
|
211
|
+
|
|
212
|
+
cmd = [
|
|
213
|
+
'magick', 'montage',
|
|
214
|
+
quoted_paths,
|
|
215
|
+
'-tile', "#{columns}x#{rows}",
|
|
216
|
+
'-geometry', '+0+0', # No gaps/borders
|
|
217
|
+
'-background', 'none',
|
|
218
|
+
Utils::PathHelper.quote_path(output_path)
|
|
219
|
+
].join(' ')
|
|
220
|
+
|
|
221
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
222
|
+
|
|
223
|
+
unless status.success?
|
|
224
|
+
raise ProcessingError, "Failed to reassemble spritesheet: #{stderr}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
Utils::FileHelper.validate_exists!(output_path)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|