ruby_spriter 0.6.7 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cec402c211c4204134f1a9cf6974093c3e36fd46a45301282260454c91ff33d
4
- data.tar.gz: 0de0714668911755bc80ac6f0841d4aa2034f03797dcf027ef6a61e495f46936
3
+ metadata.gz: 9b62fa9c591b35279199986edc2cf288249c74d80d3479eba177293c53b1dca1
4
+ data.tar.gz: 71603609b398ec1ac7d0caac7c7194ef0d5e09170a4841e6e3f2f2c0cec65ff7
5
5
  SHA512:
6
- metadata.gz: 71bbe470ac06aaa1acadf00fba48b911daa6f803010a0e88145a46847078e2db3db25cdeb4462ada7a74e9711b7b36c4b706a36bbd9f04ff53af2087a1882434
7
- data.tar.gz: f5f092588802ce4402fe952370f17ba76f59543cdd99b24e83f845e1ca2b2a5cc813481909180ac418d2f34f65dcb4ae5a828794e40abd3164a004ef6695235d
6
+ metadata.gz: 634d8516173fcb9461af2b457003e7661728e0dfbc7ac72904b0d771831e5bb730ccba867fd03fdae86acbde1e525300ad88a367c29c118b5dbb6002332a2e9f
7
+ data.tar.gz: 5330230b6690fd1aaed69f62bd31e901bb5119ed8fa0a2c07a8e3a6b164b190c230809688d5b5142a254bc231a0fd9ac556ed2a97230f807f061c5be5ba4175c
data/CHANGELOG.md CHANGED
@@ -12,6 +12,125 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
12
12
 
13
13
  ---
14
14
 
15
+ ## [0.6.7.1] - 2025-10-24
16
+
17
+ ### 🐧 Linux Support Enhancement Release
18
+
19
+ #### Added
20
+ - **Linux GIMP 3.x Support**: Full support for GIMP 3.x on Linux via Flatpak
21
+ - Automatic detection of Flatpak GIMP installation (`flatpak:org.gimp.GIMP`)
22
+ - Automatic Xvfb integration for completely headless GIMP operation
23
+ - Virtual display provided by Xvfb eliminates display connection requirement
24
+ - Flatpak socket isolation (`--nosocket=x11 --nosocket=wayland`) prevents GUI from appearing
25
+ - Python-fu batch mode works correctly with `python-fu-eval` interpreter
26
+ - Background removal, scaling, and all GIMP features fully functional
27
+ - Perfect for desktop use (no GUI distraction) and server environments (CI/CD, Docker, SSH)
28
+ - **GIMP Version Detection**: Detect and report GIMP version (2.x or 3.x)
29
+ - `Platform.detect_gimp_version(version_output)` - Parse version from `--version` output
30
+ - `Platform.get_gimp_version(gimp_path)` - Get version from executable or Flatpak
31
+ - Works with both regular executables and Flatpak installations
32
+ - **Xvfb Dependency Checking**: Added Xvfb to dependency checker (Linux only)
33
+ - Marked as required on Linux, optional on Windows/macOS
34
+ - Provides clear installation instructions if missing
35
+ - Validates availability before GIMP operations
36
+ - **DependencyChecker Version Tracking**: Store and report detected GIMP version
37
+ - **Xvfb Integration**: Transparent Xvfb usage when GIMP Flatpak detected
38
+ - Command format: `xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' flatpak run --nosocket=x11 --nosocket=wayland org.gimp.GIMP --no-splash --quit --batch-interpreter=python-fu-eval`
39
+ - No user configuration required - works automatically
40
+ - Completely headless - no GUI windows appear on screen
41
+
42
+ #### Changed
43
+ - **Platform Detection**: Enhanced to detect Flatpak GIMP alongside traditional installations
44
+ - **GimpProcessor**: Updated to support both GIMP 2.x and 3.x APIs (version-aware)
45
+ - **Unix GIMP Execution**: Automatically uses Xvfb with socket isolation for Flatpak installations
46
+ - **Alternative GIMP Paths**: Added `flatpak:org.gimp.GIMP` to Linux search paths
47
+ - **Warning Filters**: Enhanced to filter Xvfb, Wayland, and Flatpak cosmetic warnings
48
+ - **DependencyChecker**: Now supports platform-specific optional dependencies
49
+
50
+ #### Technical Details
51
+ - **GIMP Flatpak Detection**: Uses `flatpak list --app | grep` to verify installation
52
+ - **Version Parsing**: Regex-based parsing of `GIMP version X.Y.Z` output
53
+ - **Python Interpreter**: Correct name is `python-fu-eval` (not `python-eval`)
54
+ - **Xvfb Flags**:
55
+ - `--auto-servernum` - Automatically finds free display number
56
+ - `--server-args='-screen 0 1024x768x24'` - Configures virtual display
57
+ - **Flatpak Socket Isolation**:
58
+ - `--nosocket=x11` - Prevents access to host X11 display socket
59
+ - `--nosocket=wayland` - Prevents access to host Wayland display socket
60
+ - Ensures GIMP runs only in Xvfb virtual display
61
+ - **Platform Module**: New methods for version detection and Flatpak handling
62
+ - **Warning Filtering**: Filters Gdk-WARNING, LibGimp-WARNING, Gimp-Core-WARNING, X11 socket messages
63
+
64
+ #### Installation Requirements (Linux)
65
+ ```bash
66
+ # Ubuntu/Debian
67
+ sudo apt install flatpak xvfb -y
68
+ flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
69
+ flatpak install flathub org.gimp.GIMP -y
70
+
71
+ # Fedora/RHEL
72
+ sudo dnf install flatpak xorg-x11-server-Xvfb -y
73
+ flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
74
+ flatpak install flathub org.gimp.GIMP -y
75
+ ```
76
+
77
+ #### Example Output
78
+ ```bash
79
+ $ ruby_spriter --check-dependencies
80
+ ============================================================
81
+ Dependency Check
82
+ ============================================================
83
+
84
+ ✅ FFMPEG
85
+ Found: 6.1.1
86
+
87
+ ✅ FFPROBE
88
+ Found: 6.1.1
89
+
90
+ ✅ IMAGEMAGICK
91
+ Found: Version: ImageMagick 6.9.12-98 Q16 x86_64
92
+
93
+ ✅ XVFB
94
+ Found: Usage: xvfb-run [OPTION ...] COMMAND
95
+
96
+ ✅ GIMP
97
+ Found: flatpak:org.gimp.GIMP
98
+ Version: GIMP 3.0.6
99
+
100
+ ============================================================
101
+
102
+ $ ruby_spriter --image sprite.png --remove-bg
103
+ ============================================================
104
+ GIMP Processing
105
+ ============================================================
106
+ 📝 Using GIMP via Xvfb (virtual display)
107
+ Removing background (fuzzy select)...
108
+ === GIMP Messages ===
109
+ Loading image...
110
+ Image size: 1280x748
111
+ Added alpha channel
112
+ Sampling 4 corners...
113
+ Using FUZZY SELECT (contiguous regions only)
114
+ Corner 1 at (0, 0)
115
+ Corner 2 at (1279, 0)
116
+ Corner 3 at (0, 747)
117
+ Corner 4 at (1279, 747)
118
+ Selection complete
119
+ Growing selection by 1 pixels...
120
+ Selection grown
121
+ Removing background...
122
+ Background removed
123
+ Deselecting...
124
+ Exporting...
125
+ SUCCESS - Background removed!
126
+ ====================
127
+ ✅ Background Removal complete (142.15 KB)
128
+ ```
129
+
130
+ **Note**: No GIMP GUI window appears on screen - completely headless operation!
131
+
132
+ ---
133
+
15
134
  ## [0.6.7] - 2025-10-24
16
135
 
17
136
  ### 🚀 Batch Processing, Compression, Directory Consolidation & Frame Extraction Release
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Ruby Spriter v0.6.7
1
+ # Ruby Spriter v0.6.7.1
2
2
 
3
3
  [![Ruby](https://img.shields.io/badge/Ruby-2.7+-red.svg)](https://www.ruby-lang.org/)
4
4
  [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
@@ -68,6 +68,7 @@ A powerful cross-platform Ruby tool for creating high-quality spritesheets from
68
68
  | **FFprobe** | Latest | Video analysis (included with FFmpeg) |
69
69
  | **ImageMagick** | 7.x+ | Metadata and sharpening |
70
70
  | **GIMP** | 3.x (or 2.10) | Scaling and background removal |
71
+ | **Xvfb** | Latest (Linux only) | Virtual display for headless GIMP |
71
72
 
72
73
  ### Ruby Version
73
74
  - Ruby 2.7.0 or higher
@@ -90,6 +91,7 @@ Ruby Spriter requires these external tools for video and image processing:
90
91
  | **FFmpeg** | Video frame extraction | Any recent version |
91
92
  | **ImageMagick** | Image manipulation & metadata | 7.x or 6.9+ |
92
93
  | **GIMP** | Advanced image processing | 3.x (or 2.10) |
94
+ | **Xvfb** | Virtual display (Linux only) | Any recent version |
93
95
 
94
96
  #### Installing Prerequisites
95
97
 
@@ -158,9 +160,19 @@ brew install ffmpeg imagemagick gimp
158
160
  sudo apt update && sudo apt install ruby-full -y
159
161
 
160
162
  # Install Ruby Spriter dependencies
161
- sudo apt install ffmpeg imagemagick gimp -y
163
+ sudo apt install ffmpeg imagemagick -y
164
+
165
+ # Install GIMP 3.x via Flatpak (recommended)
166
+ sudo apt install flatpak -y
167
+ flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
168
+ flatpak install flathub org.gimp.GIMP -y
169
+
170
+ # Install Xvfb for headless GIMP operation
171
+ sudo apt install xvfb -y
162
172
  ```
163
173
 
174
+ **Note for Linux Users**: GIMP 3.x requires a display connection. Ruby Spriter automatically uses Xvfb (X Virtual Framebuffer) with Flatpak socket isolation to provide a completely headless virtual display. No GIMP GUI windows will appear on your screen - perfect for both desktop use (no distractions) and server environments (CI/CD, Docker, SSH sessions).
175
+
164
176
  **Linux (Fedora/RHEL)**
165
177
 
166
178
  ```bash
@@ -168,7 +180,15 @@ sudo apt install ffmpeg imagemagick gimp -y
168
180
  sudo dnf install ruby -y
169
181
 
170
182
  # Install Ruby Spriter dependencies
171
- sudo dnf install ffmpeg imagemagick gimp -y
183
+ sudo dnf install ffmpeg imagemagick -y
184
+
185
+ # Install GIMP 3.x via Flatpak (recommended)
186
+ sudo dnf install flatpak -y
187
+ flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
188
+ flatpak install flathub org.gimp.GIMP -y
189
+
190
+ # Install Xvfb for headless GIMP operation
191
+ sudo dnf install xorg-x11-server-Xvfb -y
172
192
  ```
173
193
 
174
194
  ---
@@ -685,6 +705,34 @@ ruby_spriter --video input.mp4 --scale 50 --sharpen --debug
685
705
  # - Processing timestamps
686
706
  ```
687
707
 
708
+ ### Headless Linux Operation (v0.6.7.1+)
709
+
710
+ Ruby Spriter provides completely headless GIMP operation on Linux via Xvfb and Flatpak socket isolation:
711
+
712
+ ```bash
713
+ # No GIMP GUI appears during processing
714
+ ruby_spriter --image sprite.png --remove-bg --scale 50
715
+
716
+ # Perfect for server environments
717
+ ruby_spriter --batch --dir "sprites/" --remove-bg --max-compress
718
+ ```
719
+
720
+ **How it Works:**
721
+ - Automatically detects GIMP 3.x Flatpak installation
722
+ - Uses Xvfb (X Virtual Framebuffer) to provide virtual display
723
+ - Flatpak socket isolation (`--nosocket=x11 --nosocket=wayland`) prevents GUI from appearing
724
+ - No configuration required - works automatically
725
+
726
+ **Use Cases:**
727
+ - **Desktop**: No GUI distractions during batch processing
728
+ - **Server**: Headless automation on Ubuntu Server, CI/CD pipelines
729
+ - **Docker**: Run in containers without display server
730
+ - **SSH**: Process sprites remotely without X forwarding
731
+
732
+ **Requirements:**
733
+ - GIMP 3.x via Flatpak (`flatpak install flathub org.gimp.GIMP`)
734
+ - Xvfb (`sudo apt install xvfb` on Ubuntu/Debian)
735
+
688
736
  ---
689
737
 
690
738
  ## 🏗️ Architecture
@@ -152,16 +152,18 @@ module RubySpriter
152
152
  end
153
153
 
154
154
  def process_with_gimp(input_file, video_result)
155
- # Get GIMP path from dependency checker
155
+ # Get GIMP path and version from dependency checker
156
156
  checker = DependencyChecker.new(verbose: false)
157
157
  results = checker.check_all
158
158
  gimp_path = checker.gimp_path
159
+ gimp_version = checker.gimp_version
159
160
 
160
161
  unless gimp_path
161
162
  raise DependencyError, "GIMP not found but required for processing"
162
163
  end
163
164
 
164
- gimp_processor = GimpProcessor.new(gimp_path, options)
165
+ gimp_options = options.merge(gimp_version: gimp_version)
166
+ gimp_processor = GimpProcessor.new(gimp_path, gimp_options)
165
167
  output_file = gimp_processor.process(input_file)
166
168
 
167
169
  # Clean up intermediate file if different
@@ -210,7 +210,7 @@ module RubySpriter
210
210
  options[:remove_bg] = true
211
211
  end
212
212
 
213
- opts.on("-t", "--threshold VALUE", Float, "Feather radius (default: 0.0 = no feathering)") do |t|
213
+ opts.on("-t", "--threshold VALUE", Float, "Background color tolerance % (default: 15.0, range: 0-100)") do |t|
214
214
  options[:bg_threshold] = t
215
215
  end
216
216
 
@@ -32,12 +32,23 @@ module RubySpriter
32
32
  linux: 'sudo apt install imagemagick',
33
33
  macos: 'brew install imagemagick'
34
34
  }
35
+ },
36
+ xvfb: {
37
+ command: 'xvfb-run --help',
38
+ pattern: /xvfb-run/i,
39
+ install: {
40
+ windows: 'Not required on Windows',
41
+ linux: 'sudo apt install xvfb',
42
+ macos: 'Not required on macOS'
43
+ },
44
+ optional_for: [:windows, :macos] # Only required on Linux
35
45
  }
36
46
  }.freeze
37
47
 
38
48
  def initialize(verbose: false)
39
49
  @verbose = verbose
40
50
  @gimp_path = nil
51
+ @gimp_version = nil
41
52
  end
42
53
 
43
54
  # Check all dependencies
@@ -58,7 +69,7 @@ module RubySpriter
58
69
  # @return [Boolean] true if all dependencies are available
59
70
  def all_satisfied?
60
71
  results = check_all
61
- results.all? { |_tool, status| status[:available] }
72
+ results.all? { |tool, status| status[:available] || status[:optional] }
62
73
  end
63
74
 
64
75
  # Print dependency status report
@@ -70,14 +81,31 @@ module RubySpriter
70
81
  puts "=" * 60
71
82
 
72
83
  results.each do |tool, status|
73
- icon = status[:available] ? "✅" : "❌"
74
- puts "\n#{icon} #{tool.to_s.upcase}"
75
-
84
+ if status[:optional] && !status[:available]
85
+ icon = "⚪"
86
+ optional_text = " (Optional for #{Platform.current})"
87
+ else
88
+ icon = status[:available] ? "✅" : "❌"
89
+ optional_text = ""
90
+ end
91
+
92
+ puts "\n#{icon} #{tool.to_s.upcase}#{optional_text}"
93
+
76
94
  if status[:available]
77
- puts " Found: #{status[:path] || status[:version]}"
95
+ if tool == :gimp && status[:version]
96
+ version_str = "GIMP #{status[:version][:full]}"
97
+ puts " Found: #{status[:path]}"
98
+ puts " Version: #{version_str}"
99
+ else
100
+ puts " Found: #{status[:path] || status[:version]}"
101
+ end
78
102
  else
79
- puts " Status: NOT FOUND"
80
- puts " Install: #{status[:install_cmd]}"
103
+ if status[:optional]
104
+ puts " Status: NOT FOUND (not required for this platform)"
105
+ else
106
+ puts " Status: NOT FOUND"
107
+ puts " Install: #{status[:install_cmd]}"
108
+ end
81
109
  end
82
110
  end
83
111
 
@@ -87,9 +115,16 @@ module RubySpriter
87
115
  # Get the found GIMP executable path
88
116
  attr_reader :gimp_path
89
117
 
118
+ # Get the detected GIMP version info
119
+ attr_reader :gimp_version
120
+
90
121
  private
91
122
 
92
123
  def check_tool(tool, config)
124
+ # Check if this tool is optional for current platform
125
+ optional_platforms = config[:optional_for] || []
126
+ is_optional = optional_platforms.include?(Platform.current)
127
+
93
128
  stdout, stderr, status = Open3.capture3(config[:command])
94
129
  output = stdout + stderr
95
130
 
@@ -98,13 +133,15 @@ module RubySpriter
98
133
  {
99
134
  available: true,
100
135
  version: version,
101
- install_cmd: nil
136
+ install_cmd: nil,
137
+ optional: is_optional
102
138
  }
103
139
  else
104
140
  {
105
141
  available: false,
106
142
  version: nil,
107
- install_cmd: config[:install][Platform.current]
143
+ install_cmd: config[:install][Platform.current],
144
+ optional: is_optional
108
145
  }
109
146
  end
110
147
  rescue StandardError => e
@@ -112,7 +149,8 @@ module RubySpriter
112
149
  {
113
150
  available: false,
114
151
  version: nil,
115
- install_cmd: config[:install][Platform.current]
152
+ install_cmd: config[:install][Platform.current],
153
+ optional: is_optional
116
154
  }
117
155
  end
118
156
 
@@ -121,32 +159,44 @@ module RubySpriter
121
159
  default_path = Platform.default_gimp_path
122
160
  if gimp_exists?(default_path)
123
161
  @gimp_path = default_path
124
- return gimp_status(true, default_path)
162
+ @gimp_version = Platform.get_gimp_version(default_path)
163
+ return gimp_status(true, default_path, @gimp_version)
125
164
  end
126
165
 
127
166
  # Try alternative paths
128
167
  Platform.alternative_gimp_paths.each do |path|
129
168
  if gimp_exists?(path)
130
169
  @gimp_path = path
131
- return gimp_status(true, path)
170
+ @gimp_version = Platform.get_gimp_version(path)
171
+ return gimp_status(true, path, @gimp_version)
132
172
  end
133
173
  end
134
174
 
135
175
  # Not found
136
- gimp_status(false, nil)
176
+ gimp_status(false, nil, nil)
137
177
  end
138
178
 
139
179
  def gimp_exists?(path)
140
180
  return false if path.nil? || path.empty?
181
+
182
+ # Handle Flatpak GIMP
183
+ if path.start_with?('flatpak:')
184
+ flatpak_app = path.sub('flatpak:', '')
185
+ stdout, _stderr, status = Open3.capture3("flatpak list --app | grep #{flatpak_app}")
186
+ return status.success? && !stdout.strip.empty?
187
+ end
188
+
141
189
  File.exist?(path)
142
190
  end
143
191
 
144
- def gimp_status(available, path)
145
- {
192
+ def gimp_status(available, path, version)
193
+ status = {
146
194
  available: available,
147
195
  path: path,
148
196
  install_cmd: gimp_install_command
149
197
  }
198
+ status[:version] = version if version
199
+ status
150
200
  end
151
201
 
152
202
  def gimp_install_command
@@ -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
- 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"
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
@@ -25,6 +25,7 @@ module RubySpriter
25
25
  def initialize(options = {})
26
26
  @options = default_options.merge(options)
27
27
  @gimp_path = nil
28
+ @gimp_version = nil
28
29
  validate_numeric_options!
29
30
  validate_split_option!
30
31
  validate_extract_option!
@@ -340,7 +341,10 @@ module RubySpriter
340
341
  raise DependencyError, "Missing required dependencies: #{missing.join(', ')}"
341
342
  end
342
343
 
343
- @gimp_path = checker.gimp_path if results[:gimp][:available]
344
+ if results[:gimp][:available]
345
+ @gimp_path = checker.gimp_path
346
+ @gimp_version = checker.gimp_version
347
+ end
344
348
 
345
349
  if options[:debug]
346
350
  checker.print_report
@@ -703,7 +707,8 @@ module RubySpriter
703
707
  end
704
708
 
705
709
  def process_with_gimp(input_file)
706
- gimp_processor = GimpProcessor.new(@gimp_path, options)
710
+ gimp_options = options.merge(gimp_version: @gimp_version)
711
+ gimp_processor = GimpProcessor.new(@gimp_path, gimp_options)
707
712
  gimp_processor.process(input_file)
708
713
  end
709
714
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubySpriter
4
- VERSION = '0.6.7'
4
+ VERSION = '0.6.7.1'
5
5
  VERSION_DATE = '2025-10-24'
6
6
  METADATA_VERSION = '0.6'
7
7
  end
@@ -71,7 +71,7 @@ RSpec.describe RubySpriter::Platform do
71
71
  describe '.imagemagick_identify_cmd' do
72
72
  it 'returns appropriate command for platform' do
73
73
  cmd = described_class.imagemagick_identify_cmd
74
-
74
+
75
75
  if described_class.windows?
76
76
  expect(cmd).to eq('magick identify')
77
77
  else
@@ -79,4 +79,14 @@ RSpec.describe RubySpriter::Platform do
79
79
  end
80
80
  end
81
81
  end
82
+
83
+ describe '.detect_gimp_version' do
84
+ it 'detects GIMP 3.x version from command output' do
85
+ gimp3_output = "GNU Image Manipulation Program version 3.0.0"
86
+ version = described_class.detect_gimp_version(gimp3_output)
87
+ expect(version[:major]).to eq(3)
88
+ expect(version[:minor]).to eq(0)
89
+ expect(version[:full]).to eq('3.0.0')
90
+ end
91
+ end
82
92
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_spriter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.7
4
+ version: 0.6.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - scooter-indie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-10-24 00:00:00.000000000 Z
11
+ date: 2025-10-25 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Ruby Spriter is a cross-platform tool for creating spritesheets from video files