ruby_spriter 0.6.5
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 +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +217 -0
- data/Gemfile +17 -0
- data/LICENSE +21 -0
- data/README.md +561 -0
- data/bin/ruby_spriter +20 -0
- data/lib/ruby_spriter/cli.rb +249 -0
- data/lib/ruby_spriter/consolidator.rb +146 -0
- data/lib/ruby_spriter/dependency_checker.rb +174 -0
- data/lib/ruby_spriter/gimp_processor.rb +664 -0
- data/lib/ruby_spriter/metadata_manager.rb +116 -0
- data/lib/ruby_spriter/platform.rb +82 -0
- data/lib/ruby_spriter/processor.rb +251 -0
- data/lib/ruby_spriter/utils/file_helper.rb +57 -0
- data/lib/ruby_spriter/utils/output_formatter.rb +65 -0
- data/lib/ruby_spriter/utils/path_helper.rb +59 -0
- data/lib/ruby_spriter/version.rb +7 -0
- data/lib/ruby_spriter/video_processor.rb +139 -0
- data/lib/ruby_spriter.rb +31 -0
- data/ruby_spriter.gemspec +42 -0
- data/spec/fixtures/image_without_metadata.png +0 -0
- data/spec/fixtures/spritesheet_4x2.png +0 -0
- data/spec/fixtures/spritesheet_4x4.png +0 -0
- data/spec/fixtures/spritesheet_6x2.png +0 -0
- data/spec/fixtures/spritesheet_with_metadata.png +0 -0
- data/spec/fixtures/test_video.mp4 +0 -0
- data/spec/ruby_spriter/cli_spec.rb +1142 -0
- data/spec/ruby_spriter/consolidator_spec.rb +375 -0
- data/spec/ruby_spriter/dependency_checker_spec.rb +0 -0
- data/spec/ruby_spriter/gimp_processor_spec.rb +425 -0
- data/spec/ruby_spriter/metadata_manager_spec.rb +0 -0
- data/spec/ruby_spriter/platform_spec.rb +82 -0
- data/spec/ruby_spriter/processor_spec.rb +0 -0
- data/spec/ruby_spriter/utils/file_helper_spec.rb +71 -0
- data/spec/ruby_spriter/utils/output_formatter_spec.rb +0 -0
- data/spec/ruby_spriter/utils/path_helper_spec.rb +78 -0
- data/spec/ruby_spriter/video_processor_spec.rb +0 -0
- data/spec/spec_helper.rb +41 -0
- metadata +88 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
6
|
+
module RubySpriter
|
|
7
|
+
# Processes images with GIMP
|
|
8
|
+
class GimpProcessor
|
|
9
|
+
attr_reader :options, :gimp_path
|
|
10
|
+
|
|
11
|
+
def initialize(gimp_path, options = {})
|
|
12
|
+
@gimp_path = gimp_path
|
|
13
|
+
@options = options
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Process image with GIMP operations
|
|
17
|
+
# @param input_file [String] Path to input image
|
|
18
|
+
# @return [String] Path to processed output file
|
|
19
|
+
def process(input_file)
|
|
20
|
+
Utils::FileHelper.validate_readable!(input_file)
|
|
21
|
+
|
|
22
|
+
Utils::OutputFormatter.header("GIMP Processing")
|
|
23
|
+
|
|
24
|
+
# Inform user if automatic operation order optimization is applied
|
|
25
|
+
if options[:scale_percent] && options[:remove_bg] &&
|
|
26
|
+
options[:operation_order] == :scale_then_remove_bg
|
|
27
|
+
Utils::OutputFormatter.note("Auto-optimized: Removing background before scaling for better quality")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
working_file = input_file
|
|
31
|
+
operations = determine_operations
|
|
32
|
+
|
|
33
|
+
# Execute operations in configured order
|
|
34
|
+
operations.each do |operation|
|
|
35
|
+
working_file = send(operation, working_file)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Apply sharpening at the very end, after all GIMP operations
|
|
39
|
+
if options[:sharpen]
|
|
40
|
+
working_file = apply_sharpen_imagemagick(working_file)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
working_file
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def determine_operations
|
|
49
|
+
ops = []
|
|
50
|
+
|
|
51
|
+
# Automatically use bg_first when both scaling and background removal are enabled
|
|
52
|
+
# This produces cleaner results because:
|
|
53
|
+
# - Background removal works better at higher resolution
|
|
54
|
+
# - Scaling smooths out any rough edges from background removal
|
|
55
|
+
auto_bg_first = options[:scale_percent] && options[:remove_bg] &&
|
|
56
|
+
options[:operation_order] == :scale_then_remove_bg
|
|
57
|
+
|
|
58
|
+
if options[:operation_order] == :remove_bg_then_scale || auto_bg_first
|
|
59
|
+
ops << :remove_background if options[:remove_bg]
|
|
60
|
+
ops << :scale_image if options[:scale_percent]
|
|
61
|
+
else # :scale_then_remove_bg (when only one operation, or explicitly requested)
|
|
62
|
+
ops << :scale_image if options[:scale_percent]
|
|
63
|
+
ops << :remove_background if options[:remove_bg]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
ops
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def scale_image(input_file)
|
|
70
|
+
percent = options[:scale_percent]
|
|
71
|
+
output_file = Utils::FileHelper.output_filename(input_file, "scaled-#{percent}pct")
|
|
72
|
+
|
|
73
|
+
Utils::OutputFormatter.indent("Scaling to #{percent}%...")
|
|
74
|
+
|
|
75
|
+
script = generate_scale_script(input_file, output_file, percent)
|
|
76
|
+
execute_gimp_script(script, output_file, "Scale")
|
|
77
|
+
|
|
78
|
+
# Preserve metadata from input file
|
|
79
|
+
preserve_metadata(input_file, output_file)
|
|
80
|
+
|
|
81
|
+
# Note: Sharpening is applied at the end in process() method
|
|
82
|
+
|
|
83
|
+
output_file
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def remove_background(input_file)
|
|
87
|
+
method = options[:fuzzy_select] ? 'fuzzy' : 'global'
|
|
88
|
+
output_file = Utils::FileHelper.output_filename(input_file, "nobg-#{method}")
|
|
89
|
+
|
|
90
|
+
Utils::OutputFormatter.indent("Removing background (#{method} select)...")
|
|
91
|
+
|
|
92
|
+
script = generate_remove_bg_script(input_file, output_file)
|
|
93
|
+
execute_gimp_script(script, output_file, "Background Removal")
|
|
94
|
+
|
|
95
|
+
# Preserve metadata from input file
|
|
96
|
+
preserve_metadata(input_file, output_file)
|
|
97
|
+
|
|
98
|
+
output_file
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def generate_scale_script(input_file, output_file, percent)
|
|
102
|
+
input_path = Utils::PathHelper.normalize_for_python(input_file)
|
|
103
|
+
output_path = Utils::PathHelper.normalize_for_python(output_file)
|
|
104
|
+
interpolation = map_interpolation_method(options[:scale_interpolation] || 'nohalo')
|
|
105
|
+
|
|
106
|
+
# Get sharpen parameters with proper defaults
|
|
107
|
+
sharpen_enabled = options[:sharpen] || false
|
|
108
|
+
sharpen_radius = options[:sharpen_radius] || 3.0
|
|
109
|
+
sharpen_amount = options[:sharpen_amount] || 0.5
|
|
110
|
+
sharpen_threshold = options[:sharpen_threshold] || 0
|
|
111
|
+
|
|
112
|
+
<<~PYTHON
|
|
113
|
+
import sys
|
|
114
|
+
import gc
|
|
115
|
+
from gi.repository import Gimp, Gio, Gegl
|
|
116
|
+
|
|
117
|
+
img = None
|
|
118
|
+
layer = None
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
print("Loading image...")
|
|
122
|
+
img = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, Gio.File.new_for_path(r'#{input_path}'))
|
|
123
|
+
|
|
124
|
+
w = img.get_width()
|
|
125
|
+
h = img.get_height()
|
|
126
|
+
print(f"Image size: {w}x{h}")
|
|
127
|
+
|
|
128
|
+
layers = img.get_layers()
|
|
129
|
+
if not layers or len(layers) == 0:
|
|
130
|
+
raise Exception("No layers found")
|
|
131
|
+
layer = layers[0]
|
|
132
|
+
|
|
133
|
+
# Calculate new dimensions
|
|
134
|
+
new_width = int(w * #{percent} / 100.0)
|
|
135
|
+
new_height = int(h * #{percent} / 100.0)
|
|
136
|
+
print(f"Scaling to: {new_width}x{new_height}")
|
|
137
|
+
print(f"Interpolation: #{interpolation}")
|
|
138
|
+
|
|
139
|
+
# Set interpolation method via context
|
|
140
|
+
pdb = Gimp.get_pdb()
|
|
141
|
+
context_set_interp = pdb.lookup_procedure('gimp-context-set-interpolation')
|
|
142
|
+
if context_set_interp:
|
|
143
|
+
config = context_set_interp.create_config()
|
|
144
|
+
config.set_property('interpolation', #{interpolation})
|
|
145
|
+
context_set_interp.run(config)
|
|
146
|
+
print("Interpolation method set in context")
|
|
147
|
+
|
|
148
|
+
# Scale layer using the context interpolation
|
|
149
|
+
scale_proc = pdb.lookup_procedure('gimp-layer-scale')
|
|
150
|
+
if scale_proc:
|
|
151
|
+
config = scale_proc.create_config()
|
|
152
|
+
config.set_property('layer', layer)
|
|
153
|
+
config.set_property('new-width', new_width)
|
|
154
|
+
config.set_property('new-height', new_height)
|
|
155
|
+
config.set_property('local-origin', False)
|
|
156
|
+
scale_proc.run(config)
|
|
157
|
+
print("Layer scaled with interpolation")
|
|
158
|
+
|
|
159
|
+
# Resize canvas to match layer
|
|
160
|
+
img.resize(new_width, new_height, 0, 0)
|
|
161
|
+
print("Canvas resized")
|
|
162
|
+
|
|
163
|
+
# Only flatten if there are multiple layers AND no transparency is needed
|
|
164
|
+
# Otherwise, preserve the alpha channel for transparent images
|
|
165
|
+
layers = img.get_layers()
|
|
166
|
+
if len(layers) > 1:
|
|
167
|
+
# Merge down multiple layers while preserving alpha
|
|
168
|
+
merge_proc = pdb.lookup_procedure('gimp-image-merge-visible-layers')
|
|
169
|
+
if merge_proc:
|
|
170
|
+
config = merge_proc.create_config()
|
|
171
|
+
config.set_property('image', img)
|
|
172
|
+
config.set_property('merge-type', Gimp.MergeType.EXPAND_AS_NECESSARY)
|
|
173
|
+
merge_proc.run(config)
|
|
174
|
+
print("Multiple layers merged (alpha preserved)")
|
|
175
|
+
else:
|
|
176
|
+
print("Single layer - no merge needed, alpha preserved")
|
|
177
|
+
|
|
178
|
+
# Get the final layer
|
|
179
|
+
layers = img.get_layers()
|
|
180
|
+
final_layer = layers[0]
|
|
181
|
+
|
|
182
|
+
# Note: Sharpening will be applied using ImageMagick after GIMP export
|
|
183
|
+
# This is because GEGL operations in GIMP 3.x batch mode are unreliable
|
|
184
|
+
|
|
185
|
+
# Export with alpha channel intact
|
|
186
|
+
print("Exporting with alpha channel...")
|
|
187
|
+
export_proc = pdb.lookup_procedure('file-png-export')
|
|
188
|
+
if export_proc:
|
|
189
|
+
config = export_proc.create_config()
|
|
190
|
+
config.set_property('image', img)
|
|
191
|
+
config.set_property('file', Gio.File.new_for_path(r'#{output_path}'))
|
|
192
|
+
export_proc.run(config)
|
|
193
|
+
|
|
194
|
+
print("SUCCESS - Image scaled!")
|
|
195
|
+
|
|
196
|
+
except Exception as e:
|
|
197
|
+
print(f"ERROR: {e}")
|
|
198
|
+
import traceback
|
|
199
|
+
traceback.print_exc()
|
|
200
|
+
sys.exit(1)
|
|
201
|
+
finally:
|
|
202
|
+
# Explicit cleanup to minimize GEGL warnings
|
|
203
|
+
try:
|
|
204
|
+
if layer is not None:
|
|
205
|
+
layer = None
|
|
206
|
+
if img is not None:
|
|
207
|
+
gc.collect() # Force garbage collection
|
|
208
|
+
img.delete()
|
|
209
|
+
img = None
|
|
210
|
+
gc.collect() # Force again after deletion
|
|
211
|
+
except Exception as cleanup_error:
|
|
212
|
+
print(f"Cleanup warning: {cleanup_error}")
|
|
213
|
+
pass
|
|
214
|
+
PYTHON
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def generate_remove_bg_script(input_file, output_file)
|
|
218
|
+
input_path = Utils::PathHelper.normalize_for_python(input_file)
|
|
219
|
+
output_path = Utils::PathHelper.normalize_for_python(output_file)
|
|
220
|
+
|
|
221
|
+
use_fuzzy = options[:fuzzy_select]
|
|
222
|
+
|
|
223
|
+
# Build the selection code block
|
|
224
|
+
selection_code = if use_fuzzy
|
|
225
|
+
generate_fuzzy_select_code
|
|
226
|
+
else
|
|
227
|
+
generate_global_select_code
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Build optional processing code
|
|
231
|
+
grow_code = generate_grow_selection_code
|
|
232
|
+
feather_code = generate_feather_selection_code
|
|
233
|
+
|
|
234
|
+
<<~PYTHON
|
|
235
|
+
import sys
|
|
236
|
+
import gc
|
|
237
|
+
from gi.repository import Gimp, Gio, Gegl
|
|
238
|
+
|
|
239
|
+
img = None
|
|
240
|
+
layer = None
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
print("Loading image...")
|
|
244
|
+
img = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, Gio.File.new_for_path(r'#{input_path}'))
|
|
245
|
+
|
|
246
|
+
w = img.get_width()
|
|
247
|
+
h = img.get_height()
|
|
248
|
+
print(f"Image size: {w}x{h}")
|
|
249
|
+
|
|
250
|
+
layers = img.get_layers()
|
|
251
|
+
if not layers or len(layers) == 0:
|
|
252
|
+
raise Exception("No layers found")
|
|
253
|
+
layer = layers[0]
|
|
254
|
+
|
|
255
|
+
# Add alpha channel if needed
|
|
256
|
+
if not layer.has_alpha():
|
|
257
|
+
layer.add_alpha()
|
|
258
|
+
print("Added alpha channel")
|
|
259
|
+
|
|
260
|
+
pdb = Gimp.get_pdb()
|
|
261
|
+
|
|
262
|
+
# Sample all four corners
|
|
263
|
+
corners = [
|
|
264
|
+
(0, 0), # Top-left
|
|
265
|
+
(w-1, 0), # Top-right
|
|
266
|
+
(0, h-1), # Bottom-left
|
|
267
|
+
(w-1, h-1) # Bottom-right
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
print(f"Sampling {len(corners)} corners...")
|
|
271
|
+
|
|
272
|
+
#{selection_code.split("\n").map { |line| " " + line }.join("\n")}
|
|
273
|
+
|
|
274
|
+
print("Selection complete")
|
|
275
|
+
|
|
276
|
+
#{grow_code.split("\n").map { |line| " " + line }.join("\n")}
|
|
277
|
+
|
|
278
|
+
#{feather_code.split("\n").map { |line| " " + line }.join("\n")}
|
|
279
|
+
|
|
280
|
+
# Delete selection (clear background)
|
|
281
|
+
print("Removing background...")
|
|
282
|
+
edit_clear = pdb.lookup_procedure('gimp-drawable-edit-clear')
|
|
283
|
+
if edit_clear:
|
|
284
|
+
config = edit_clear.create_config()
|
|
285
|
+
config.set_property('drawable', layer)
|
|
286
|
+
edit_clear.run(config)
|
|
287
|
+
print("Background removed")
|
|
288
|
+
|
|
289
|
+
# Deselect
|
|
290
|
+
print("Deselecting...")
|
|
291
|
+
select_none = pdb.lookup_procedure('gimp-selection-none')
|
|
292
|
+
if select_none:
|
|
293
|
+
config = select_none.create_config()
|
|
294
|
+
config.set_property('image', img)
|
|
295
|
+
select_none.run(config)
|
|
296
|
+
|
|
297
|
+
# Export
|
|
298
|
+
print("Exporting...")
|
|
299
|
+
export_proc = pdb.lookup_procedure('file-png-export')
|
|
300
|
+
if export_proc:
|
|
301
|
+
config = export_proc.create_config()
|
|
302
|
+
config.set_property('image', img)
|
|
303
|
+
config.set_property('file', Gio.File.new_for_path(r'#{output_path}'))
|
|
304
|
+
export_proc.run(config)
|
|
305
|
+
|
|
306
|
+
print("SUCCESS - Background removed!")
|
|
307
|
+
|
|
308
|
+
except Exception as e:
|
|
309
|
+
print(f"ERROR: {e}")
|
|
310
|
+
import traceback
|
|
311
|
+
traceback.print_exc()
|
|
312
|
+
sys.exit(1)
|
|
313
|
+
finally:
|
|
314
|
+
# Explicit cleanup to minimize GEGL warnings
|
|
315
|
+
try:
|
|
316
|
+
if layer is not None:
|
|
317
|
+
layer = None
|
|
318
|
+
if img is not None:
|
|
319
|
+
gc.collect() # Force garbage collection
|
|
320
|
+
img.delete()
|
|
321
|
+
img = None
|
|
322
|
+
gc.collect() # Force again after deletion
|
|
323
|
+
except Exception as cleanup_error:
|
|
324
|
+
print(f"Cleanup warning: {cleanup_error}")
|
|
325
|
+
pass
|
|
326
|
+
PYTHON
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def generate_fuzzy_select_code
|
|
330
|
+
<<~PYTHON.chomp
|
|
331
|
+
# Fuzzy select (contiguous regions only)
|
|
332
|
+
print("Using FUZZY SELECT (contiguous regions only)")
|
|
333
|
+
select_proc = pdb.lookup_procedure('gimp-image-select-contiguous-color')
|
|
334
|
+
|
|
335
|
+
if not select_proc:
|
|
336
|
+
raise Exception("Could not find gimp-image-select-contiguous-color procedure")
|
|
337
|
+
|
|
338
|
+
for i, (x, y) in enumerate(corners):
|
|
339
|
+
print(f" Corner {i+1} at ({x}, {y})")
|
|
340
|
+
|
|
341
|
+
config = select_proc.create_config()
|
|
342
|
+
config.set_property('image', img)
|
|
343
|
+
config.set_property('operation', Gimp.ChannelOps.REPLACE if i == 0 else Gimp.ChannelOps.ADD)
|
|
344
|
+
config.set_property('drawable', layer)
|
|
345
|
+
config.set_property('x', float(x))
|
|
346
|
+
config.set_property('y', float(y))
|
|
347
|
+
select_proc.run(config)
|
|
348
|
+
PYTHON
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def generate_global_select_code
|
|
352
|
+
<<~PYTHON.chomp
|
|
353
|
+
# Global color select (all matching pixels)
|
|
354
|
+
print("Using GLOBAL COLOR SELECT (all matching pixels)")
|
|
355
|
+
select_proc = pdb.lookup_procedure('gimp-image-select-color')
|
|
356
|
+
|
|
357
|
+
if not select_proc:
|
|
358
|
+
raise Exception("Could not find gimp-image-select-color procedure")
|
|
359
|
+
|
|
360
|
+
for i, (x, y) in enumerate(corners):
|
|
361
|
+
print(f" Corner {i+1} at ({x}, {y})")
|
|
362
|
+
color = layer.get_pixel(x, y)
|
|
363
|
+
|
|
364
|
+
config = select_proc.create_config()
|
|
365
|
+
config.set_property('image', img)
|
|
366
|
+
config.set_property('operation', Gimp.ChannelOps.REPLACE if i == 0 else Gimp.ChannelOps.ADD)
|
|
367
|
+
config.set_property('drawable', layer)
|
|
368
|
+
config.set_property('color', color)
|
|
369
|
+
select_proc.run(config)
|
|
370
|
+
PYTHON
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def generate_grow_selection_code
|
|
374
|
+
grow = options[:grow_selection] || 1
|
|
375
|
+
return "# No selection growth" if grow <= 0
|
|
376
|
+
|
|
377
|
+
<<~PYTHON.chomp
|
|
378
|
+
# Grow selection
|
|
379
|
+
print(f"Growing selection by #{grow} pixels...")
|
|
380
|
+
grow_proc = pdb.lookup_procedure('gimp-selection-grow')
|
|
381
|
+
if grow_proc:
|
|
382
|
+
config = grow_proc.create_config()
|
|
383
|
+
config.set_property('image', img)
|
|
384
|
+
config.set_property('steps', #{grow})
|
|
385
|
+
grow_proc.run(config)
|
|
386
|
+
print("Selection grown")
|
|
387
|
+
PYTHON
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def generate_feather_selection_code
|
|
391
|
+
threshold = options[:bg_threshold] || 0.0
|
|
392
|
+
return "# No feathering" if threshold <= 0
|
|
393
|
+
|
|
394
|
+
<<~PYTHON.chomp
|
|
395
|
+
# Feather selection
|
|
396
|
+
print(f"Feathering selection by #{threshold} pixels...")
|
|
397
|
+
feather_proc = pdb.lookup_procedure('gimp-selection-feather')
|
|
398
|
+
if feather_proc:
|
|
399
|
+
config = feather_proc.create_config()
|
|
400
|
+
config.set_property('image', img)
|
|
401
|
+
config.set_property('radius', #{threshold})
|
|
402
|
+
feather_proc.run(config)
|
|
403
|
+
print("Selection feathered")
|
|
404
|
+
PYTHON
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def execute_gimp_script(script_content, expected_output, operation_name)
|
|
408
|
+
script_file = File.join(Dir.tmpdir, "gimp_script_#{Time.now.to_i}_#{rand(10000)}.py")
|
|
409
|
+
log_file = File.join(Dir.tmpdir, "gimp_log_#{Time.now.to_i}_#{rand(10000)}.txt")
|
|
410
|
+
|
|
411
|
+
begin
|
|
412
|
+
File.write(script_file, script_content)
|
|
413
|
+
|
|
414
|
+
if options[:debug]
|
|
415
|
+
Utils::OutputFormatter.indent("DEBUG: Script file: #{script_file}")
|
|
416
|
+
Utils::OutputFormatter.indent("DEBUG: Log file: #{log_file}")
|
|
417
|
+
Utils::OutputFormatter.indent("DEBUG: Expected output: #{expected_output}")
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Build GIMP command based on platform
|
|
421
|
+
if Platform.windows?
|
|
422
|
+
execute_gimp_windows(script_file, log_file)
|
|
423
|
+
else
|
|
424
|
+
execute_gimp_unix(script_file, log_file)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
gimp_output = ""
|
|
428
|
+
if File.exist?(log_file)
|
|
429
|
+
gimp_output = File.read(log_file)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Filter GEGL warnings but keep actual errors and success messages
|
|
433
|
+
filtered_output = filter_gimp_output(gimp_output)
|
|
434
|
+
|
|
435
|
+
# Only show output if debug mode OR if there are actual messages (not just warnings)
|
|
436
|
+
if options[:debug] && !filtered_output.strip.empty?
|
|
437
|
+
Utils::OutputFormatter.indent("=== GIMP Output ===")
|
|
438
|
+
filtered_output.lines.each do |line|
|
|
439
|
+
Utils::OutputFormatter.indent(line.chomp)
|
|
440
|
+
end
|
|
441
|
+
Utils::OutputFormatter.indent("==================\n")
|
|
442
|
+
elsif !options[:debug] && has_important_messages?(gimp_output)
|
|
443
|
+
# Show important messages even without debug mode
|
|
444
|
+
Utils::OutputFormatter.indent("=== GIMP Messages ===")
|
|
445
|
+
filtered_output.lines.each do |line|
|
|
446
|
+
Utils::OutputFormatter.indent(line.chomp)
|
|
447
|
+
end
|
|
448
|
+
Utils::OutputFormatter.indent("====================\n")
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
Utils::FileHelper.validate_exists!(expected_output)
|
|
452
|
+
|
|
453
|
+
size = Utils::FileHelper.format_size(File.size(expected_output))
|
|
454
|
+
Utils::OutputFormatter.success("#{operation_name} complete (#{size})\n")
|
|
455
|
+
|
|
456
|
+
ensure
|
|
457
|
+
cleanup_temp_files(script_file, log_file) unless options[:keep_temp]
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Windows execution with GEGL warning suppression
|
|
462
|
+
def execute_gimp_windows(script_file, log_file)
|
|
463
|
+
batch_file = File.join(Dir.tmpdir, "gimp_run_#{Time.now.to_i}_#{rand(10000)}.bat")
|
|
464
|
+
|
|
465
|
+
batch_content = <<~BATCH
|
|
466
|
+
@echo off
|
|
467
|
+
REM Suppress GEGL debug output (known GIMP 3.x batch mode issue)
|
|
468
|
+
set GEGL_DEBUG=
|
|
469
|
+
"#{gimp_path}" --quit --batch-interpreter=python-fu-eval -b "exec(open(r'#{script_file}').read())" > "#{log_file}" 2>&1
|
|
470
|
+
exit /b %errorlevel%
|
|
471
|
+
BATCH
|
|
472
|
+
|
|
473
|
+
File.write(batch_file, batch_content)
|
|
474
|
+
|
|
475
|
+
if options[:debug]
|
|
476
|
+
Utils::OutputFormatter.indent("DEBUG: Batch file: #{batch_file}")
|
|
477
|
+
Utils::OutputFormatter.indent("DEBUG: Batch content:")
|
|
478
|
+
batch_content.lines.each do |line|
|
|
479
|
+
Utils::OutputFormatter.indent(" #{line.chomp}")
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Use Open3.capture3 with cmd.exe wrapper - this is the v0.6 approach that works
|
|
484
|
+
stdout, stderr, status = Open3.capture3("cmd.exe /c \"#{batch_file}\"")
|
|
485
|
+
|
|
486
|
+
if options[:debug]
|
|
487
|
+
Utils::OutputFormatter.indent("DEBUG: Command exit status: #{status.exitstatus}")
|
|
488
|
+
Utils::OutputFormatter.indent("DEBUG: stdout: #{stdout}") unless stdout.strip.empty?
|
|
489
|
+
Utils::OutputFormatter.indent("DEBUG: stderr: #{stderr}") unless stderr.strip.empty?
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
unless status.success?
|
|
493
|
+
log_content = File.exist?(log_file) ? File.read(log_file) : "No log file created"
|
|
494
|
+
raise ProcessingError, "GIMP processing failed (exit code: #{status.exitstatus})\n#{log_content}"
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Clean up batch file
|
|
498
|
+
File.delete(batch_file) if File.exist?(batch_file) && !options[:keep_temp]
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Unix execution (Linux/macOS)
|
|
502
|
+
def execute_gimp_unix(script_file, log_file)
|
|
503
|
+
cmd = "#{Utils::PathHelper.quote_path(gimp_path)} --quit --batch-interpreter=python-fu-eval -b \"exec(open(r'#{script_file}').read())\" > #{Utils::PathHelper.quote_path(log_file)} 2>&1"
|
|
504
|
+
|
|
505
|
+
if options[:debug]
|
|
506
|
+
Utils::OutputFormatter.indent("DEBUG: GIMP command: #{cmd}")
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
510
|
+
|
|
511
|
+
if options[:debug]
|
|
512
|
+
Utils::OutputFormatter.indent("DEBUG: Command exit status: #{status.exitstatus}")
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
unless status.success?
|
|
516
|
+
log_content = File.exist?(log_file) ? File.read(log_file) : "No log file created"
|
|
517
|
+
raise ProcessingError, "GIMP processing failed (exit code: #{status.exitstatus})\n#{log_content}"
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Filter out known GEGL/GIMP warnings that are cosmetic
|
|
522
|
+
def filter_gimp_output(output)
|
|
523
|
+
lines = output.lines.reject do |line|
|
|
524
|
+
# Filter known GEGL buffer leak warnings (cosmetic in GIMP 3.x batch mode)
|
|
525
|
+
line.match?(/GEGL-WARNING/) ||
|
|
526
|
+
line.match?(/gegl_tile_cache_destroy/) ||
|
|
527
|
+
line.match?(/runtime check failed/) ||
|
|
528
|
+
line.match?(/To debug GeglBuffer leaks/) ||
|
|
529
|
+
line.match?(/GEGL_DEBUG.*buffer-alloc/) ||
|
|
530
|
+
line.match?(/GeglBuffers leaked/) ||
|
|
531
|
+
line.match?(/EEEEeEeek!/) ||
|
|
532
|
+
line.match?(/batch command executed successfully/) ||
|
|
533
|
+
line.strip.empty?
|
|
534
|
+
end
|
|
535
|
+
lines.join
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Check if output has important messages beyond warnings
|
|
539
|
+
def has_important_messages?(output)
|
|
540
|
+
filtered = filter_gimp_output(output)
|
|
541
|
+
# Has content other than SUCCESS messages
|
|
542
|
+
filtered.strip.split("\n").any? { |line| !line.match?(/SUCCESS/) && !line.strip.empty? }
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Preserve metadata from input file to output file
|
|
546
|
+
# GIMP strips metadata during export, so we need to copy it
|
|
547
|
+
def preserve_metadata(input_file, output_file)
|
|
548
|
+
# Read metadata from input file
|
|
549
|
+
input_metadata = MetadataManager.read(input_file)
|
|
550
|
+
|
|
551
|
+
return unless input_metadata # No metadata to preserve
|
|
552
|
+
|
|
553
|
+
if options[:debug]
|
|
554
|
+
Utils::OutputFormatter.indent("DEBUG: Preserving metadata from input file")
|
|
555
|
+
Utils::OutputFormatter.indent(" Columns: #{input_metadata[:columns]}")
|
|
556
|
+
Utils::OutputFormatter.indent(" Rows: #{input_metadata[:rows]}")
|
|
557
|
+
Utils::OutputFormatter.indent(" Frames: #{input_metadata[:frames]}")
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# Create temporary file for re-embedding metadata
|
|
561
|
+
temp_file = output_file.sub('.png', '_temp_meta.png')
|
|
562
|
+
File.rename(output_file, temp_file)
|
|
563
|
+
|
|
564
|
+
# Re-embed metadata
|
|
565
|
+
MetadataManager.embed(
|
|
566
|
+
temp_file,
|
|
567
|
+
output_file,
|
|
568
|
+
columns: input_metadata[:columns],
|
|
569
|
+
rows: input_metadata[:rows],
|
|
570
|
+
frames: input_metadata[:frames],
|
|
571
|
+
debug: options[:debug]
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
# Clean up temp file
|
|
575
|
+
File.delete(temp_file) if File.exist?(temp_file)
|
|
576
|
+
|
|
577
|
+
if options[:debug]
|
|
578
|
+
Utils::OutputFormatter.indent("DEBUG: Metadata preserved in output file")
|
|
579
|
+
end
|
|
580
|
+
rescue StandardError => e
|
|
581
|
+
# If metadata preservation fails, keep the file but warn
|
|
582
|
+
if options[:debug]
|
|
583
|
+
Utils::OutputFormatter.warning("Could not preserve metadata: #{e.message}")
|
|
584
|
+
end
|
|
585
|
+
# Restore original file if temp exists
|
|
586
|
+
File.rename(temp_file, output_file) if defined?(temp_file) && File.exist?(temp_file) && !File.exist?(output_file)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def cleanup_temp_files(script_file, log_file)
|
|
590
|
+
batch_file = script_file.sub('.py', '.bat').sub('gimp_script', 'gimp_run')
|
|
591
|
+
|
|
592
|
+
[script_file, log_file, batch_file].each do |file|
|
|
593
|
+
File.delete(file) if File.exist?(file)
|
|
594
|
+
rescue StandardError => e
|
|
595
|
+
puts "Warning: Could not delete temp file #{file}: #{e.message}" if options[:debug]
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Map interpolation method names to GIMP interpolation type enum values
|
|
600
|
+
def map_interpolation_method(method)
|
|
601
|
+
# GIMP 3.x GimpInterpolationType enum values
|
|
602
|
+
case method.to_s.downcase
|
|
603
|
+
when 'none'
|
|
604
|
+
'Gimp.InterpolationType.NONE'
|
|
605
|
+
when 'linear'
|
|
606
|
+
'Gimp.InterpolationType.LINEAR'
|
|
607
|
+
when 'cubic'
|
|
608
|
+
'Gimp.InterpolationType.CUBIC'
|
|
609
|
+
when 'nohalo'
|
|
610
|
+
'Gimp.InterpolationType.NOHALO'
|
|
611
|
+
when 'lohalo'
|
|
612
|
+
'Gimp.InterpolationType.LOHALO'
|
|
613
|
+
else
|
|
614
|
+
'Gimp.InterpolationType.NOHALO' # Default to NoHalo for quality
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Apply unsharp mask using ImageMagick
|
|
619
|
+
def apply_sharpen_imagemagick(input_file)
|
|
620
|
+
radius = options[:sharpen_radius] || 2.0
|
|
621
|
+
gain = options[:sharpen_gain] || 0.5
|
|
622
|
+
threshold = options[:sharpen_threshold] || 0.03
|
|
623
|
+
|
|
624
|
+
output_file = Utils::FileHelper.output_filename(input_file, "sharpened")
|
|
625
|
+
|
|
626
|
+
Utils::OutputFormatter.indent("Applying unsharp mask (ImageMagick)...")
|
|
627
|
+
Utils::OutputFormatter.indent(" radius=#{radius}, gain=#{gain}, threshold=#{threshold}")
|
|
628
|
+
|
|
629
|
+
magick_cmd = Platform.imagemagick_convert_cmd
|
|
630
|
+
|
|
631
|
+
# Build ImageMagick unsharp command
|
|
632
|
+
# Format: -unsharp {radius}x{sigma}+{gain}+{threshold}
|
|
633
|
+
# sigma is typically radius * 0.5 for good results
|
|
634
|
+
sigma = radius * 0.5
|
|
635
|
+
unsharp_params = "#{radius}x#{sigma}+#{gain}+#{threshold}"
|
|
636
|
+
|
|
637
|
+
cmd = [
|
|
638
|
+
magick_cmd,
|
|
639
|
+
Utils::PathHelper.quote_path(input_file),
|
|
640
|
+
'-unsharp', unsharp_params,
|
|
641
|
+
Utils::PathHelper.quote_path(output_file)
|
|
642
|
+
].join(' ')
|
|
643
|
+
|
|
644
|
+
if options[:debug]
|
|
645
|
+
Utils::OutputFormatter.indent("DEBUG: ImageMagick command: #{cmd}")
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
649
|
+
|
|
650
|
+
unless status.success?
|
|
651
|
+
raise ProcessingError, "ImageMagick sharpen failed: #{stderr}"
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
Utils::FileHelper.validate_exists!(output_file)
|
|
655
|
+
|
|
656
|
+
# Preserve metadata
|
|
657
|
+
preserve_metadata(input_file, output_file)
|
|
658
|
+
|
|
659
|
+
Utils::OutputFormatter.success("Sharpening complete")
|
|
660
|
+
|
|
661
|
+
output_file
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
end
|