ruby_spriter 0.6.6 → 0.6.7.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/CHANGELOG.md +257 -0
- data/README.md +384 -33
- data/lib/ruby_spriter/batch_processor.rb +214 -0
- data/lib/ruby_spriter/cli.rb +355 -8
- data/lib/ruby_spriter/compression_manager.rb +101 -0
- data/lib/ruby_spriter/consolidator.rb +33 -0
- data/lib/ruby_spriter/dependency_checker.rb +65 -15
- data/lib/ruby_spriter/gimp_processor.rb +395 -4
- data/lib/ruby_spriter/platform.rb +56 -1
- data/lib/ruby_spriter/processor.rb +419 -9
- 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/platform_spec.rb +11 -1
- data/spec/ruby_spriter/processor_spec.rb +350 -0
- metadata +6 -2
|
@@ -6,11 +6,12 @@ require 'tmpdir'
|
|
|
6
6
|
module RubySpriter
|
|
7
7
|
# Processes images with GIMP
|
|
8
8
|
class GimpProcessor
|
|
9
|
-
attr_reader :options, :gimp_path
|
|
9
|
+
attr_reader :options, :gimp_path, :gimp_version
|
|
10
10
|
|
|
11
11
|
def initialize(gimp_path, options = {})
|
|
12
12
|
@gimp_path = gimp_path
|
|
13
13
|
@options = options
|
|
14
|
+
@gimp_version = options[:gimp_version] || { major: 3, minor: 0 } # Default to GIMP 3
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
# Process image with GIMP operations
|
|
@@ -21,6 +22,11 @@ module RubySpriter
|
|
|
21
22
|
|
|
22
23
|
Utils::OutputFormatter.header("GIMP Processing")
|
|
23
24
|
|
|
25
|
+
# Inform about Xvfb usage on Linux
|
|
26
|
+
if Platform.linux? && gimp_path.start_with?('flatpak:')
|
|
27
|
+
Utils::OutputFormatter.note("Using GIMP via Xvfb (virtual display)")
|
|
28
|
+
end
|
|
29
|
+
|
|
24
30
|
# Inform user if automatic operation order optimization is applied
|
|
25
31
|
if options[:scale_percent] && options[:remove_bg] &&
|
|
26
32
|
options[:operation_order] == :scale_then_remove_bg
|
|
@@ -45,6 +51,18 @@ module RubySpriter
|
|
|
45
51
|
|
|
46
52
|
private
|
|
47
53
|
|
|
54
|
+
def gimp_major_version
|
|
55
|
+
@gimp_version[:major]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def gimp2?
|
|
59
|
+
gimp_major_version == 2
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def gimp3?
|
|
63
|
+
gimp_major_version == 3
|
|
64
|
+
end
|
|
65
|
+
|
|
48
66
|
def determine_operations
|
|
49
67
|
ops = []
|
|
50
68
|
|
|
@@ -101,6 +119,14 @@ module RubySpriter
|
|
|
101
119
|
end
|
|
102
120
|
|
|
103
121
|
def generate_scale_script(input_file, output_file, percent)
|
|
122
|
+
if gimp2?
|
|
123
|
+
generate_scale_script_gimp2(input_file, output_file, percent)
|
|
124
|
+
else
|
|
125
|
+
generate_scale_script_gimp3(input_file, output_file, percent)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def generate_scale_script_gimp3(input_file, output_file, percent)
|
|
104
130
|
input_path = Utils::PathHelper.normalize_for_python(input_file)
|
|
105
131
|
output_path = Utils::PathHelper.normalize_for_python(output_file)
|
|
106
132
|
interpolation = map_interpolation_method(options[:scale_interpolation] || 'nohalo')
|
|
@@ -216,7 +242,75 @@ module RubySpriter
|
|
|
216
242
|
PYTHON
|
|
217
243
|
end
|
|
218
244
|
|
|
245
|
+
def generate_scale_script_gimp2(input_file, output_file, percent)
|
|
246
|
+
input_path = Utils::PathHelper.normalize_for_python(input_file)
|
|
247
|
+
output_path = Utils::PathHelper.normalize_for_python(output_file)
|
|
248
|
+
interpolation = map_interpolation_method_gimp2(options[:scale_interpolation] || 'nohalo')
|
|
249
|
+
|
|
250
|
+
<<~PYTHON
|
|
251
|
+
from gimpfu import *
|
|
252
|
+
import sys
|
|
253
|
+
|
|
254
|
+
def scale_image():
|
|
255
|
+
try:
|
|
256
|
+
print "Loading image..."
|
|
257
|
+
img = pdb.gimp_file_load(r'#{input_path}', r'#{input_path}')
|
|
258
|
+
|
|
259
|
+
w = img.width
|
|
260
|
+
h = img.height
|
|
261
|
+
print "Image size: %dx%d" % (w, h)
|
|
262
|
+
|
|
263
|
+
if len(img.layers) == 0:
|
|
264
|
+
raise Exception("No layers found")
|
|
265
|
+
layer = img.layers[0]
|
|
266
|
+
|
|
267
|
+
# Calculate new dimensions
|
|
268
|
+
new_width = int(w * #{percent} / 100.0)
|
|
269
|
+
new_height = int(h * #{percent} / 100.0)
|
|
270
|
+
print "Scaling to: %dx%d" % (new_width, new_height)
|
|
271
|
+
print "Interpolation: #{interpolation}"
|
|
272
|
+
|
|
273
|
+
# Scale layer with interpolation
|
|
274
|
+
pdb.gimp_layer_scale(layer, new_width, new_height, False, #{interpolation})
|
|
275
|
+
print "Layer scaled with interpolation"
|
|
276
|
+
|
|
277
|
+
# Resize canvas to match layer
|
|
278
|
+
pdb.gimp_image_resize(img, new_width, new_height, 0, 0)
|
|
279
|
+
print "Canvas resized"
|
|
280
|
+
|
|
281
|
+
# Handle multiple layers while preserving alpha
|
|
282
|
+
if len(img.layers) > 1:
|
|
283
|
+
pdb.gimp_image_merge_visible_layers(img, EXPAND_AS_NECESSARY)
|
|
284
|
+
print "Multiple layers merged (alpha preserved)"
|
|
285
|
+
else:
|
|
286
|
+
print "Single layer - no merge needed, alpha preserved"
|
|
287
|
+
|
|
288
|
+
# Export with alpha channel intact
|
|
289
|
+
print "Exporting with alpha channel..."
|
|
290
|
+
pdb.file_png_save(img, img.layers[0], r'#{output_path}', r'#{output_path}',
|
|
291
|
+
0, 9, 0, 0, 0, 0, 0)
|
|
292
|
+
|
|
293
|
+
print "SUCCESS - Image scaled!"
|
|
294
|
+
|
|
295
|
+
except Exception as e:
|
|
296
|
+
print "ERROR: %s" % str(e)
|
|
297
|
+
import traceback
|
|
298
|
+
traceback.print_exc()
|
|
299
|
+
sys.exit(1)
|
|
300
|
+
|
|
301
|
+
scale_image()
|
|
302
|
+
PYTHON
|
|
303
|
+
end
|
|
304
|
+
|
|
219
305
|
def generate_remove_bg_script(input_file, output_file)
|
|
306
|
+
if gimp2?
|
|
307
|
+
generate_remove_bg_script_gimp2(input_file, output_file)
|
|
308
|
+
else
|
|
309
|
+
generate_remove_bg_script_gimp3(input_file, output_file)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def generate_remove_bg_script_gimp3(input_file, output_file)
|
|
220
314
|
input_path = Utils::PathHelper.normalize_for_python(input_file)
|
|
221
315
|
output_path = Utils::PathHelper.normalize_for_python(output_file)
|
|
222
316
|
|
|
@@ -328,6 +422,98 @@ module RubySpriter
|
|
|
328
422
|
PYTHON
|
|
329
423
|
end
|
|
330
424
|
|
|
425
|
+
def generate_remove_bg_script_gimp2(input_file, output_file)
|
|
426
|
+
input_path = Utils::PathHelper.normalize_for_python(input_file)
|
|
427
|
+
output_path = Utils::PathHelper.normalize_for_python(output_file)
|
|
428
|
+
|
|
429
|
+
use_fuzzy = options[:fuzzy_select]
|
|
430
|
+
grow = options[:grow_selection] || 1
|
|
431
|
+
feather = options[:bg_threshold] || 0.0
|
|
432
|
+
|
|
433
|
+
# Build selection method
|
|
434
|
+
if use_fuzzy
|
|
435
|
+
select_method = "CHANNEL_OP_REPLACE" # First corner
|
|
436
|
+
select_add = "CHANNEL_OP_ADD" # Additional corners
|
|
437
|
+
select_call = "pdb.gimp_image_select_contiguous_color(img, select_op, layer, x, y)"
|
|
438
|
+
else
|
|
439
|
+
select_method = "CHANNEL_OP_REPLACE"
|
|
440
|
+
select_add = "CHANNEL_OP_ADD"
|
|
441
|
+
select_call = "pdb.gimp_image_select_color(img, select_op, layer, color)"
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
<<~PYTHON
|
|
445
|
+
from gimpfu import *
|
|
446
|
+
import sys
|
|
447
|
+
|
|
448
|
+
def remove_background():
|
|
449
|
+
try:
|
|
450
|
+
print "Loading image..."
|
|
451
|
+
img = pdb.gimp_file_load(r'#{input_path}', r'#{input_path}')
|
|
452
|
+
|
|
453
|
+
w = img.width
|
|
454
|
+
h = img.height
|
|
455
|
+
print "Image size: %dx%d" % (w, h)
|
|
456
|
+
|
|
457
|
+
if len(img.layers) == 0:
|
|
458
|
+
raise Exception("No layers found")
|
|
459
|
+
layer = img.layers[0]
|
|
460
|
+
|
|
461
|
+
# Add alpha channel if needed
|
|
462
|
+
if not pdb.gimp_layer_has_alpha(layer):
|
|
463
|
+
pdb.gimp_layer_add_alpha(layer)
|
|
464
|
+
print "Added alpha channel"
|
|
465
|
+
|
|
466
|
+
# Sample all four corners
|
|
467
|
+
corners = [
|
|
468
|
+
(0, 0), # Top-left
|
|
469
|
+
(w-1, 0), # Top-right
|
|
470
|
+
(0, h-1), # Bottom-left
|
|
471
|
+
(w-1, h-1) # Bottom-right
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
print "Sampling %d corners..." % len(corners)
|
|
475
|
+
#{"print \"Using FUZZY SELECT (contiguous regions only)\"" if use_fuzzy}
|
|
476
|
+
#{"print \"Using GLOBAL COLOR SELECT (all matching pixels)\"" unless use_fuzzy}
|
|
477
|
+
|
|
478
|
+
for i, (x, y) in enumerate(corners):
|
|
479
|
+
print " Corner %d at (%d, %d)" % (i+1, x, y)
|
|
480
|
+
select_op = CHANNEL_OP_REPLACE if i == 0 else CHANNEL_OP_ADD
|
|
481
|
+
#{use_fuzzy ? "pdb.gimp_image_select_contiguous_color(img, select_op, layer, x, y)" : "color = pdb.gimp_image_get_pixel_color(img, layer, x, y)[1]\n pdb.gimp_image_select_color(img, select_op, layer, color)"}
|
|
482
|
+
|
|
483
|
+
print "Selection complete"
|
|
484
|
+
|
|
485
|
+
# Grow selection if configured
|
|
486
|
+
#{grow > 0 ? "print \"Growing selection by #{grow} pixels...\"\n pdb.gimp_selection_grow(img, #{grow})\n print \"Selection grown\"" : "# No selection growth"}
|
|
487
|
+
|
|
488
|
+
# Feather selection if configured
|
|
489
|
+
#{feather > 0 ? "print \"Feathering selection by #{feather} pixels...\"\n pdb.gimp_selection_feather(img, #{feather})\n print \"Selection feathered\"" : "# No feathering"}
|
|
490
|
+
|
|
491
|
+
# Delete selection (clear background)
|
|
492
|
+
print "Removing background..."
|
|
493
|
+
pdb.gimp_edit_clear(layer)
|
|
494
|
+
print "Background removed"
|
|
495
|
+
|
|
496
|
+
# Deselect
|
|
497
|
+
print "Deselecting..."
|
|
498
|
+
pdb.gimp_selection_none(img)
|
|
499
|
+
|
|
500
|
+
# Export
|
|
501
|
+
print "Exporting..."
|
|
502
|
+
pdb.file_png_save(img, layer, r'#{output_path}', r'#{output_path}',
|
|
503
|
+
0, 9, 0, 0, 0, 0, 0)
|
|
504
|
+
|
|
505
|
+
print "SUCCESS - Background removed!"
|
|
506
|
+
|
|
507
|
+
except Exception as e:
|
|
508
|
+
print "ERROR: %s" % str(e)
|
|
509
|
+
import traceback
|
|
510
|
+
traceback.print_exc()
|
|
511
|
+
sys.exit(1)
|
|
512
|
+
|
|
513
|
+
remove_background()
|
|
514
|
+
PYTHON
|
|
515
|
+
end
|
|
516
|
+
|
|
331
517
|
def generate_fuzzy_select_code
|
|
332
518
|
<<~PYTHON.chomp
|
|
333
519
|
# Fuzzy select (contiguous regions only)
|
|
@@ -468,7 +654,7 @@ module RubySpriter
|
|
|
468
654
|
@echo off
|
|
469
655
|
REM Suppress GEGL debug output (known GIMP 3.x batch mode issue)
|
|
470
656
|
set GEGL_DEBUG=
|
|
471
|
-
"#{gimp_path}" --quit --batch-interpreter=python-fu-eval -b "exec(open(r'#{script_file}').read())" > "#{log_file}" 2>&1
|
|
657
|
+
"#{gimp_path}" --no-splash --quit --batch-interpreter=python-fu-eval -b "exec(open(r'#{script_file}').read())" > "#{log_file}" 2>&1
|
|
472
658
|
exit /b %errorlevel%
|
|
473
659
|
BATCH
|
|
474
660
|
|
|
@@ -502,7 +688,25 @@ module RubySpriter
|
|
|
502
688
|
|
|
503
689
|
# Unix execution (Linux/macOS)
|
|
504
690
|
def execute_gimp_unix(script_file, log_file)
|
|
505
|
-
|
|
691
|
+
# Check if we're using Flatpak GIMP (needs xvfb-run)
|
|
692
|
+
use_xvfb = gimp_path.start_with?('flatpak:')
|
|
693
|
+
|
|
694
|
+
if gimp2?
|
|
695
|
+
# GIMP 2.x: Use gimp-console for batch processing
|
|
696
|
+
gimp_console_path = gimp_path.sub('/gimp', '/gimp-console')
|
|
697
|
+
cmd = "#{Utils::PathHelper.quote_path(gimp_console_path)} -i --no-splash --batch-interpreter python-fu-eval -b 'exec(open(\"#{script_file}\").read())' -b '(gimp-quit 0)' > #{Utils::PathHelper.quote_path(log_file)} 2>&1"
|
|
698
|
+
else
|
|
699
|
+
# GIMP 3.x command
|
|
700
|
+
if use_xvfb
|
|
701
|
+
# Flatpak GIMP needs xvfb-run to provide virtual display
|
|
702
|
+
# Use --nosocket options to prevent Flatpak from accessing host display
|
|
703
|
+
flatpak_app = gimp_path.sub('flatpak:', '')
|
|
704
|
+
cmd = "xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' flatpak run --nosocket=x11 --nosocket=wayland #{flatpak_app} --no-splash --quit --batch-interpreter=python-fu-eval -b \"exec(open(r'#{script_file}').read())\" > #{Utils::PathHelper.quote_path(log_file)} 2>&1"
|
|
705
|
+
else
|
|
706
|
+
# Regular GIMP 3.x installation
|
|
707
|
+
cmd = "#{Utils::PathHelper.quote_path(gimp_path)} --no-splash --quit --batch-interpreter=python-fu-eval -b \"exec(open(r'#{script_file}').read())\" > #{Utils::PathHelper.quote_path(log_file)} 2>&1"
|
|
708
|
+
end
|
|
709
|
+
end
|
|
506
710
|
|
|
507
711
|
if options[:debug]
|
|
508
712
|
Utils::OutputFormatter.indent("DEBUG: GIMP command: #{cmd}")
|
|
@@ -532,6 +736,16 @@ module RubySpriter
|
|
|
532
736
|
line.match?(/GeglBuffers leaked/) ||
|
|
533
737
|
line.match?(/EEEEeEeek!/) ||
|
|
534
738
|
line.match?(/batch command executed successfully/) ||
|
|
739
|
+
# Filter Linux/Wayland/Flatpak cosmetic warnings
|
|
740
|
+
line.match?(/Gdk-WARNING.*Failed to read portal settings/) ||
|
|
741
|
+
line.match?(/set device.*to mode: disabled/) ||
|
|
742
|
+
line.match?(/Gdk-WARNING.*Server is missing xdg_foreign support/) ||
|
|
743
|
+
line.match?(/gimp_widget_set_handle_on_mapped.*gdk_wayland_window_export_handle/) ||
|
|
744
|
+
line.match?(/It will not be possible to set windows in other processes/) ||
|
|
745
|
+
line.match?(/LibGimp-WARNING.*gimp_flush.*Broken pipe/) ||
|
|
746
|
+
line.match?(/Gimp-Core-WARNING.*gimp_finalize.*list of contexts not empty/) ||
|
|
747
|
+
line.match?(/stale context:/) ||
|
|
748
|
+
line.match?(/F: X11 socket.*does not exist in filesystem/) ||
|
|
535
749
|
line.strip.empty?
|
|
536
750
|
end
|
|
537
751
|
lines.join
|
|
@@ -598,7 +812,7 @@ module RubySpriter
|
|
|
598
812
|
end
|
|
599
813
|
end
|
|
600
814
|
|
|
601
|
-
# Map interpolation method names to GIMP interpolation type enum values
|
|
815
|
+
# Map interpolation method names to GIMP 3.x interpolation type enum values
|
|
602
816
|
def map_interpolation_method(method)
|
|
603
817
|
# GIMP 3.x GimpInterpolationType enum values
|
|
604
818
|
case method.to_s.downcase
|
|
@@ -617,6 +831,183 @@ module RubySpriter
|
|
|
617
831
|
end
|
|
618
832
|
end
|
|
619
833
|
|
|
834
|
+
# Map interpolation method names to GIMP 2.x interpolation type constants
|
|
835
|
+
def map_interpolation_method_gimp2(method)
|
|
836
|
+
# GIMP 2.x interpolation constants
|
|
837
|
+
case method.to_s.downcase
|
|
838
|
+
when 'none'
|
|
839
|
+
'INTERPOLATION_NONE'
|
|
840
|
+
when 'linear'
|
|
841
|
+
'INTERPOLATION_LINEAR'
|
|
842
|
+
when 'cubic'
|
|
843
|
+
'INTERPOLATION_CUBIC'
|
|
844
|
+
when 'nohalo'
|
|
845
|
+
'INTERPOLATION_NOHALO'
|
|
846
|
+
when 'lohalo'
|
|
847
|
+
'INTERPOLATION_LOHALO'
|
|
848
|
+
else
|
|
849
|
+
'INTERPOLATION_NOHALO' # Default to NoHalo for quality
|
|
850
|
+
end
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
# Remove background using ImageMagick (fallback for Linux)
|
|
854
|
+
# Uses edge detection and multiple techniques for better results
|
|
855
|
+
def remove_background_imagemagick(input_file, output_file)
|
|
856
|
+
magick_cmd = Platform.imagemagick_convert_cmd
|
|
857
|
+
identify_cmd = Platform.imagemagick_identify_cmd
|
|
858
|
+
|
|
859
|
+
# Get options
|
|
860
|
+
use_fuzzy = options[:fuzzy_select]
|
|
861
|
+
fuzz_percent = options[:bg_threshold] || 15.0
|
|
862
|
+
grow = options[:grow_selection] || 1
|
|
863
|
+
|
|
864
|
+
# Get image dimensions
|
|
865
|
+
stdout, _stderr, status = Open3.capture3("#{identify_cmd} -format '%w %h' #{Utils::PathHelper.quote_path(input_file)}")
|
|
866
|
+
unless status.success?
|
|
867
|
+
raise ProcessingError, "Could not get image dimensions"
|
|
868
|
+
end
|
|
869
|
+
width, height = stdout.strip.split.map(&:to_i)
|
|
870
|
+
|
|
871
|
+
# Sample more points around the border (not just corners)
|
|
872
|
+
sample_points = [
|
|
873
|
+
# Corners
|
|
874
|
+
[0, 0], [width - 1, 0], [0, height - 1], [width - 1, height - 1],
|
|
875
|
+
# Mid-edges
|
|
876
|
+
[width / 2, 0], [width / 2, height - 1],
|
|
877
|
+
[0, height / 2], [width - 1, height / 2]
|
|
878
|
+
]
|
|
879
|
+
|
|
880
|
+
# Get colors from all sample points
|
|
881
|
+
sampled_colors = []
|
|
882
|
+
sample_points.each do |x, y|
|
|
883
|
+
color_stdout, _stderr, status = Open3.capture3("#{identify_cmd} -format '%[pixel:p{#{x},#{y}}]' #{Utils::PathHelper.quote_path(input_file)}")
|
|
884
|
+
sampled_colors << color_stdout.strip if status.success? && !color_stdout.strip.empty?
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
# Find the most common color (likely the background)
|
|
888
|
+
bg_color = sampled_colors.group_by(&:itself).max_by { |_, v| v.size }.first
|
|
889
|
+
|
|
890
|
+
if options[:debug]
|
|
891
|
+
Utils::OutputFormatter.indent("DEBUG: Image size: #{width}x#{height}")
|
|
892
|
+
Utils::OutputFormatter.indent("DEBUG: Sampled #{sampled_colors.size} border colors")
|
|
893
|
+
Utils::OutputFormatter.indent("DEBUG: Unique colors: #{sampled_colors.uniq.size}")
|
|
894
|
+
Utils::OutputFormatter.indent("DEBUG: Using background color: #{bg_color}")
|
|
895
|
+
Utils::OutputFormatter.indent("DEBUG: Fuzz: #{fuzz_percent}%")
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
# Create a multi-pass approach for better results
|
|
899
|
+
# Pass 1: Use floodfill from edges with fuzz tolerance
|
|
900
|
+
temp_file1 = File.join(Dir.tmpdir, "bg_removal_pass1_#{Time.now.to_i}.png")
|
|
901
|
+
|
|
902
|
+
draw_commands = sample_points.map { |x, y| "'color #{x},#{y} floodfill'" }.join(' -draw ')
|
|
903
|
+
|
|
904
|
+
cmd1 = "#{magick_cmd} #{Utils::PathHelper.quote_path(input_file)} -alpha set -fuzz #{fuzz_percent}% -fill none -draw #{draw_commands} #{temp_file1}"
|
|
905
|
+
|
|
906
|
+
if options[:debug]
|
|
907
|
+
Utils::OutputFormatter.indent("DEBUG: Pass 1 - Floodfill from #{sample_points.size} points")
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
stdout, stderr, status = Open3.capture3(cmd1)
|
|
911
|
+
unless status.success?
|
|
912
|
+
File.delete(temp_file1) if File.exist?(temp_file1)
|
|
913
|
+
raise ProcessingError, "Background removal pass 1 failed: #{stderr}"
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
# Pass 2: Remove the detected background color globally with fuzz
|
|
917
|
+
temp_file2 = File.join(Dir.tmpdir, "bg_removal_pass2_#{Time.now.to_i}.png")
|
|
918
|
+
|
|
919
|
+
cmd2 = "#{magick_cmd} #{temp_file1} -fuzz #{fuzz_percent}% -transparent '#{bg_color}' #{temp_file2}"
|
|
920
|
+
|
|
921
|
+
if options[:debug]
|
|
922
|
+
Utils::OutputFormatter.indent("DEBUG: Pass 2 - Remove color #{bg_color} globally")
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
stdout, stderr, status = Open3.capture3(cmd2)
|
|
926
|
+
unless status.success?
|
|
927
|
+
File.delete(temp_file1) if File.exist?(temp_file1)
|
|
928
|
+
File.delete(temp_file2) if File.exist?(temp_file2)
|
|
929
|
+
raise ProcessingError, "Background removal pass 2 failed: #{stderr}"
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
# Pass 3: Minimal cleanup - preserve quality
|
|
933
|
+
cmd3_parts = [
|
|
934
|
+
magick_cmd,
|
|
935
|
+
temp_file2
|
|
936
|
+
]
|
|
937
|
+
|
|
938
|
+
# Only clean up the alpha channel, don't touch the RGB data
|
|
939
|
+
# This preserves sprite quality while cleaning edges
|
|
940
|
+
cmd3_parts += [
|
|
941
|
+
'-channel', 'A',
|
|
942
|
+
# Very gentle cleanup - only remove nearly-transparent pixels
|
|
943
|
+
'-threshold', '5%', # Anything less than 5% alpha becomes fully transparent
|
|
944
|
+
'+channel'
|
|
945
|
+
]
|
|
946
|
+
|
|
947
|
+
# Optionally grow the transparent areas
|
|
948
|
+
if grow > 0
|
|
949
|
+
cmd3_parts += ['-morphology', 'Dilate', "Disk:#{grow}"]
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
cmd3_parts << Utils::PathHelper.quote_path(output_file)
|
|
953
|
+
cmd3 = cmd3_parts.join(' ')
|
|
954
|
+
|
|
955
|
+
if options[:debug]
|
|
956
|
+
Utils::OutputFormatter.indent("DEBUG: Pass 3 - Minimal alpha cleanup (quality-preserving)")
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
stdout, stderr, status = Open3.capture3(cmd3)
|
|
960
|
+
|
|
961
|
+
# Cleanup temp files
|
|
962
|
+
File.delete(temp_file1) if File.exist?(temp_file1)
|
|
963
|
+
File.delete(temp_file2) if File.exist?(temp_file2)
|
|
964
|
+
|
|
965
|
+
unless status.success?
|
|
966
|
+
raise ProcessingError, "Background removal pass 3 failed: #{stderr}"
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
Utils::FileHelper.validate_exists!(output_file)
|
|
970
|
+
size = Utils::FileHelper.format_size(File.size(output_file))
|
|
971
|
+
Utils::OutputFormatter.success("Background removal complete (#{size})\n")
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
# Scale image using ImageMagick (fallback for GIMP 2.x)
|
|
975
|
+
def scale_with_imagemagick(input_file, output_file, percent)
|
|
976
|
+
magick_cmd = Platform.imagemagick_convert_cmd
|
|
977
|
+
|
|
978
|
+
# Map interpolation to ImageMagick filters
|
|
979
|
+
interpolation = options[:scale_interpolation] || 'nohalo'
|
|
980
|
+
filter = case interpolation.to_s.downcase
|
|
981
|
+
when 'none' then 'Point'
|
|
982
|
+
when 'linear' then 'Triangle'
|
|
983
|
+
when 'cubic' then 'Catrom'
|
|
984
|
+
when 'nohalo', 'lohalo' then 'Lanczos' # Best available quality
|
|
985
|
+
else 'Lanczos'
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
cmd = [
|
|
989
|
+
magick_cmd,
|
|
990
|
+
Utils::PathHelper.quote_path(input_file),
|
|
991
|
+
'-filter', filter,
|
|
992
|
+
'-resize', "#{percent}%",
|
|
993
|
+
Utils::PathHelper.quote_path(output_file)
|
|
994
|
+
].join(' ')
|
|
995
|
+
|
|
996
|
+
if options[:debug]
|
|
997
|
+
Utils::OutputFormatter.indent("DEBUG: ImageMagick command: #{cmd}")
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
1001
|
+
|
|
1002
|
+
unless status.success?
|
|
1003
|
+
raise ProcessingError, "ImageMagick scaling failed: #{stderr}"
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
Utils::FileHelper.validate_exists!(output_file)
|
|
1007
|
+
size = Utils::FileHelper.format_size(File.size(output_file))
|
|
1008
|
+
Utils::OutputFormatter.success("Scale complete (#{size})\n")
|
|
1009
|
+
end
|
|
1010
|
+
|
|
620
1011
|
# Apply unsharp mask using ImageMagick
|
|
621
1012
|
def apply_sharpen_imagemagick(input_file)
|
|
622
1013
|
radius = options[:sharpen_radius] || 2.0
|
|
@@ -29,10 +29,13 @@ module RubySpriter
|
|
|
29
29
|
'/usr/bin/gimp',
|
|
30
30
|
'/usr/local/bin/gimp',
|
|
31
31
|
'/snap/bin/gimp',
|
|
32
|
-
'/opt/gimp/bin/gimp'
|
|
32
|
+
'/opt/gimp/bin/gimp',
|
|
33
|
+
'flatpak:org.gimp.GIMP' # Flatpak GIMP
|
|
33
34
|
].freeze,
|
|
34
35
|
macos: [
|
|
35
36
|
'/Applications/GIMP.app/Contents/MacOS/gimp',
|
|
37
|
+
'/Applications/GIMP-2.99.app/Contents/MacOS/gimp', # GIMP 3.x dev
|
|
38
|
+
'/Applications/GIMP-3.0.app/Contents/MacOS/gimp', # GIMP 3.x release
|
|
36
39
|
'/Applications/GIMP-2.10.app/Contents/MacOS/gimp'
|
|
37
40
|
].freeze
|
|
38
41
|
}.freeze
|
|
@@ -77,6 +80,58 @@ module RubySpriter
|
|
|
77
80
|
def imagemagick_identify_cmd
|
|
78
81
|
windows? ? 'magick identify' : 'identify'
|
|
79
82
|
end
|
|
83
|
+
|
|
84
|
+
# Detect GIMP version from version string output
|
|
85
|
+
# @param version_output [String] Output from gimp --version command
|
|
86
|
+
# @return [Hash] Version information with :major, :minor, :patch, :full keys, or nil if parse fails
|
|
87
|
+
def detect_gimp_version(version_output)
|
|
88
|
+
return nil if version_output.nil? || version_output.empty?
|
|
89
|
+
|
|
90
|
+
# Match version pattern: "version X.Y.Z" or "version X.Y"
|
|
91
|
+
match = version_output.match(/version\s+(\d+)\.(\d+)(?:\.(\d+))?/i)
|
|
92
|
+
return nil unless match
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
major: match[1].to_i,
|
|
96
|
+
minor: match[2].to_i,
|
|
97
|
+
patch: match[3]&.to_i || 0,
|
|
98
|
+
full: match[1..3].compact.join('.')
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Get GIMP version from executable path
|
|
103
|
+
# @param gimp_path [String] Path to GIMP executable or flatpak:app.id
|
|
104
|
+
# @return [Hash] Version information, or nil if detection fails
|
|
105
|
+
def get_gimp_version(gimp_path)
|
|
106
|
+
return nil if gimp_path.nil? || gimp_path.empty?
|
|
107
|
+
|
|
108
|
+
require 'open3'
|
|
109
|
+
|
|
110
|
+
# Handle Flatpak GIMP
|
|
111
|
+
if gimp_path.start_with?('flatpak:')
|
|
112
|
+
flatpak_app = gimp_path.sub('flatpak:', '')
|
|
113
|
+
stdout, stderr, status = Open3.capture3("flatpak run #{flatpak_app} --version")
|
|
114
|
+
return nil unless status.success?
|
|
115
|
+
return detect_gimp_version(stdout + stderr)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
return nil unless File.exist?(gimp_path)
|
|
119
|
+
|
|
120
|
+
stdout, stderr, status = Open3.capture3("#{quote_path_simple(gimp_path)} --version")
|
|
121
|
+
return nil unless status.success?
|
|
122
|
+
|
|
123
|
+
detect_gimp_version(stdout + stderr)
|
|
124
|
+
rescue StandardError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
# Simple path quoting helper for Platform module
|
|
131
|
+
def quote_path_simple(path)
|
|
132
|
+
return path unless path.include?(' ')
|
|
133
|
+
windows? ? "\"#{path}\"" : "'#{path}'"
|
|
134
|
+
end
|
|
80
135
|
end
|
|
81
136
|
end
|
|
82
137
|
end
|