ruby_spriter 0.6.7 → 0.7.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +3 -3
  3. data/CHANGELOG.md +1035 -405
  4. data/Gemfile +17 -17
  5. data/LICENSE +21 -21
  6. data/README.md +183 -902
  7. data/bin/ruby_spriter +20 -20
  8. data/lib/ruby_spriter/background_sampler.rb +140 -0
  9. data/lib/ruby_spriter/batch_processor.rb +268 -212
  10. data/lib/ruby_spriter/cell_cleanup_config.rb +21 -0
  11. data/lib/ruby_spriter/cell_cleanup_gimp_script.rb +123 -0
  12. data/lib/ruby_spriter/cell_cleanup_processor.rb +230 -0
  13. data/lib/ruby_spriter/cli.rb +676 -612
  14. data/lib/ruby_spriter/compression_manager.rb +101 -101
  15. data/lib/ruby_spriter/consolidator.rb +179 -179
  16. data/lib/ruby_spriter/dependency_checker.rb +224 -174
  17. data/lib/ruby_spriter/ghost_edge_cleaner.rb +164 -0
  18. data/lib/ruby_spriter/gimp_processor.rb +1188 -667
  19. data/lib/ruby_spriter/metadata_manager.rb +117 -116
  20. data/lib/ruby_spriter/platform.rb +137 -82
  21. data/lib/ruby_spriter/processor.rb +1230 -886
  22. data/lib/ruby_spriter/smoke_detector.rb +223 -0
  23. data/lib/ruby_spriter/threshold_stepper.rb +227 -0
  24. data/lib/ruby_spriter/utils/file_helper.rb +82 -82
  25. data/lib/ruby_spriter/utils/image_helper.rb +16 -0
  26. data/lib/ruby_spriter/utils/output_formatter.rb +65 -65
  27. data/lib/ruby_spriter/utils/path_helper.rb +59 -59
  28. data/lib/ruby_spriter/utils/spritesheet_splitter.rb +86 -86
  29. data/lib/ruby_spriter/version.rb +6 -7
  30. data/lib/ruby_spriter/video_processor.rb +357 -139
  31. data/lib/ruby_spriter.rb +38 -34
  32. data/ruby_spriter.gemspec +44 -42
  33. data/spec/fixtures/centered_sprite_with_inner_bg.png +0 -0
  34. data/spec/fixtures/centered_sprite_with_inner_bg_inner_removed.png +0 -0
  35. data/spec/fixtures/centered_sprite_with_inner_bg_threshold_stepped.png +0 -0
  36. data/spec/fixtures/complex_background_sprite.png +0 -0
  37. data/spec/fixtures/death - bloodborne rekconing.mp4 +0 -0
  38. data/spec/fixtures/death - bloodborne rekconing_spritesheet-nobg-global.png +0 -0
  39. data/spec/fixtures/death - bloodborne rekconing_spritesheet.png +0 -0
  40. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431-nobg-global.png +0 -0
  41. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431.png +0 -0
  42. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738-nobg-global.png +0 -0
  43. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738.png +0 -0
  44. data/spec/fixtures/has_inner_bg.png +0 -0
  45. data/spec/fixtures/has_small_inner_bg.png +0 -0
  46. data/spec/fixtures/smoke_effect_sprite.png +0 -0
  47. data/spec/fixtures/spritesheet_with_metadata.png +0 -0
  48. data/spec/fixtures/test_sprite.png +0 -0
  49. data/spec/fixtures/test_sprite_smoke_processed.png +0 -0
  50. data/spec/fixtures/test_video_spritesheet.png +0 -0
  51. data/spec/fixtures/transparent_bg_sprite.png +0 -0
  52. data/spec/fixtures/walk_north_sprite-sheet.png +0 -0
  53. data/spec/integration/no_fuzzy_mode_spec.rb +100 -0
  54. data/spec/ruby_spriter/batch_processor_spec.rb +509 -200
  55. data/spec/ruby_spriter/cli_spec.rb +2026 -1892
  56. data/spec/ruby_spriter/compression_manager_spec.rb +157 -157
  57. data/spec/ruby_spriter/consolidator_spec.rb +538 -538
  58. data/spec/ruby_spriter/gimp_processor_spec.rb +523 -425
  59. data/spec/ruby_spriter/platform_spec.rb +92 -82
  60. data/spec/ruby_spriter/processor_spec.rb +911 -735
  61. data/spec/ruby_spriter/utils/file_helper_spec.rb +150 -150
  62. data/spec/ruby_spriter/utils/path_helper_spec.rb +78 -78
  63. data/spec/ruby_spriter/utils/spritesheet_splitter_spec.rb +104 -104
  64. data/spec/ruby_spriter/video_processor_spec.rb +346 -29
  65. data/spec/spec_helper.rb +41 -41
  66. data/spec/tmp/cli_test_output.png +0 -0
  67. data/spec/tmp/cli_test_output_20251105_114647_395.png +0 -0
  68. data/spec/tmp/combined_test.png +0 -0
  69. data/spec/tmp/compat_test.png +0 -0
  70. data/spec/tmp/compat_test_20251030_174233_361.png +0 -0
  71. data/spec/tmp/final_all_features.png +0 -0
  72. data/spec/tmp/final_test_all_features.png +0 -0
  73. data/spec/tmp/full_pipeline_test.png +0 -0
  74. data/spec/tmp/inner_test.png +0 -0
  75. data/spec/tmp/integration_test.png +0 -0
  76. data/spec/tmp/validation_test.png +0 -0
  77. data/spec/unit/background_sampler_spec.rb +132 -0
  78. data/spec/unit/cell_cleanup_config_spec.rb +32 -0
  79. data/spec/unit/cell_cleanup_gimp_script_spec.rb +59 -0
  80. data/spec/unit/cell_cleanup_processor_spec.rb +261 -0
  81. data/spec/unit/ghost_edge_cleaner_spec.rb +223 -0
  82. data/spec/unit/gimp_processor_single_point_selection_spec.rb +81 -0
  83. data/spec/unit/smoke_detector_spec.rb +246 -0
  84. data/spec/unit/threshold_stepper_spec.rb +195 -0
  85. metadata +56 -10
@@ -1,174 +1,224 @@
1
- # frozen_string_literal: true
2
-
3
- require 'open3'
4
-
5
- module RubySpriter
6
- # Checks for required external dependencies
7
- class DependencyChecker
8
- REQUIRED_TOOLS = {
9
- ffmpeg: {
10
- command: 'ffmpeg -version',
11
- pattern: /ffmpeg version/i,
12
- install: {
13
- windows: 'choco install ffmpeg',
14
- linux: 'sudo apt install ffmpeg',
15
- macos: 'brew install ffmpeg'
16
- }
17
- },
18
- ffprobe: {
19
- command: 'ffprobe -version',
20
- pattern: /ffprobe version/i,
21
- install: {
22
- windows: 'Included with ffmpeg',
23
- linux: 'Included with ffmpeg',
24
- macos: 'Included with ffmpeg'
25
- }
26
- },
27
- imagemagick: {
28
- command: Platform.imagemagick_identify_cmd + ' -version',
29
- pattern: /ImageMagick/i,
30
- install: {
31
- windows: 'choco install imagemagick',
32
- linux: 'sudo apt install imagemagick',
33
- macos: 'brew install imagemagick'
34
- }
35
- }
36
- }.freeze
37
-
38
- def initialize(verbose: false)
39
- @verbose = verbose
40
- @gimp_path = nil
41
- end
42
-
43
- # Check all dependencies
44
- # @return [Hash] Results of dependency checks
45
- def check_all
46
- results = {}
47
-
48
- REQUIRED_TOOLS.each do |tool, config|
49
- results[tool] = check_tool(tool, config)
50
- end
51
-
52
- results[:gimp] = check_gimp
53
-
54
- results
55
- end
56
-
57
- # Check if all dependencies are satisfied
58
- # @return [Boolean] true if all dependencies are available
59
- def all_satisfied?
60
- results = check_all
61
- results.all? { |_tool, status| status[:available] }
62
- end
63
-
64
- # Print dependency status report
65
- def print_report
66
- results = check_all
67
-
68
- puts "\n" + "=" * 60
69
- puts "Dependency Check"
70
- puts "=" * 60
71
-
72
- results.each do |tool, status|
73
- icon = status[:available] ? "✅" : "❌"
74
- puts "\n#{icon} #{tool.to_s.upcase}"
75
-
76
- if status[:available]
77
- puts " Found: #{status[:path] || status[:version]}"
78
- else
79
- puts " Status: NOT FOUND"
80
- puts " Install: #{status[:install_cmd]}"
81
- end
82
- end
83
-
84
- puts "\n" + "=" * 60 + "\n"
85
- end
86
-
87
- # Get the found GIMP executable path
88
- attr_reader :gimp_path
89
-
90
- private
91
-
92
- def check_tool(tool, config)
93
- stdout, stderr, status = Open3.capture3(config[:command])
94
- output = stdout + stderr
95
-
96
- if status.success? && output.match?(config[:pattern])
97
- version = extract_version(output)
98
- {
99
- available: true,
100
- version: version,
101
- install_cmd: nil
102
- }
103
- else
104
- {
105
- available: false,
106
- version: nil,
107
- install_cmd: config[:install][Platform.current]
108
- }
109
- end
110
- rescue StandardError => e
111
- puts "DEBUG: Error checking #{tool}: #{e.message}" if @verbose
112
- {
113
- available: false,
114
- version: nil,
115
- install_cmd: config[:install][Platform.current]
116
- }
117
- end
118
-
119
- def check_gimp
120
- # Try default path first
121
- default_path = Platform.default_gimp_path
122
- if gimp_exists?(default_path)
123
- @gimp_path = default_path
124
- return gimp_status(true, default_path)
125
- end
126
-
127
- # Try alternative paths
128
- Platform.alternative_gimp_paths.each do |path|
129
- if gimp_exists?(path)
130
- @gimp_path = path
131
- return gimp_status(true, path)
132
- end
133
- end
134
-
135
- # Not found
136
- gimp_status(false, nil)
137
- end
138
-
139
- def gimp_exists?(path)
140
- return false if path.nil? || path.empty?
141
- File.exist?(path)
142
- end
143
-
144
- def gimp_status(available, path)
145
- {
146
- available: available,
147
- path: path,
148
- install_cmd: gimp_install_command
149
- }
150
- end
151
-
152
- def gimp_install_command
153
- case Platform.current
154
- when :windows
155
- 'Download from https://www.gimp.org/downloads/ or use: choco install gimp'
156
- when :linux
157
- 'sudo apt install gimp'
158
- when :macos
159
- 'brew install gimp'
160
- else
161
- 'Visit https://www.gimp.org/downloads/'
162
- end
163
- end
164
-
165
- def extract_version(output)
166
- # Try to extract version number from output
167
- if output =~ /version\s+(\d+\.\d+[\.\d]*)/i
168
- $1
169
- else
170
- output.lines.first&.strip || 'Unknown'
171
- end
172
- end
173
- end
174
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module RubySpriter
6
+ # Checks for required external dependencies
7
+ class DependencyChecker
8
+ REQUIRED_TOOLS = {
9
+ ffmpeg: {
10
+ command: 'ffmpeg -version',
11
+ pattern: /ffmpeg version/i,
12
+ install: {
13
+ windows: 'choco install ffmpeg',
14
+ linux: 'sudo apt install ffmpeg',
15
+ macos: 'brew install ffmpeg'
16
+ }
17
+ },
18
+ ffprobe: {
19
+ command: 'ffprobe -version',
20
+ pattern: /ffprobe version/i,
21
+ install: {
22
+ windows: 'Included with ffmpeg',
23
+ linux: 'Included with ffmpeg',
24
+ macos: 'Included with ffmpeg'
25
+ }
26
+ },
27
+ imagemagick: {
28
+ command: Platform.imagemagick_identify_cmd + ' -version',
29
+ pattern: /ImageMagick/i,
30
+ install: {
31
+ windows: 'choco install imagemagick',
32
+ linux: 'sudo apt install imagemagick',
33
+ macos: 'brew install imagemagick'
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
45
+ }
46
+ }.freeze
47
+
48
+ def initialize(verbose: false)
49
+ @verbose = verbose
50
+ @gimp_path = nil
51
+ @gimp_version = nil
52
+ end
53
+
54
+ # Check all dependencies
55
+ # @return [Hash] Results of dependency checks
56
+ def check_all
57
+ results = {}
58
+
59
+ REQUIRED_TOOLS.each do |tool, config|
60
+ results[tool] = check_tool(tool, config)
61
+ end
62
+
63
+ results[:gimp] = check_gimp
64
+
65
+ results
66
+ end
67
+
68
+ # Check if all dependencies are satisfied
69
+ # @return [Boolean] true if all dependencies are available
70
+ def all_satisfied?
71
+ results = check_all
72
+ results.all? { |tool, status| status[:available] || status[:optional] }
73
+ end
74
+
75
+ # Print dependency status report
76
+ def print_report
77
+ results = check_all
78
+
79
+ puts "\n" + "=" * 60
80
+ puts "Dependency Check"
81
+ puts "=" * 60
82
+
83
+ results.each do |tool, status|
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
+
94
+ if status[:available]
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
102
+ else
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
109
+ end
110
+ end
111
+
112
+ puts "\n" + "=" * 60 + "\n"
113
+ end
114
+
115
+ # Get the found GIMP executable path
116
+ attr_reader :gimp_path
117
+
118
+ # Get the detected GIMP version info
119
+ attr_reader :gimp_version
120
+
121
+ private
122
+
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
+
128
+ stdout, stderr, status = Open3.capture3(config[:command])
129
+ output = stdout + stderr
130
+
131
+ if status.success? && output.match?(config[:pattern])
132
+ version = extract_version(output)
133
+ {
134
+ available: true,
135
+ version: version,
136
+ install_cmd: nil,
137
+ optional: is_optional
138
+ }
139
+ else
140
+ {
141
+ available: false,
142
+ version: nil,
143
+ install_cmd: config[:install][Platform.current],
144
+ optional: is_optional
145
+ }
146
+ end
147
+ rescue StandardError => e
148
+ puts "DEBUG: Error checking #{tool}: #{e.message}" if @verbose
149
+ {
150
+ available: false,
151
+ version: nil,
152
+ install_cmd: config[:install][Platform.current],
153
+ optional: is_optional
154
+ }
155
+ end
156
+
157
+ def check_gimp
158
+ # Try default path first
159
+ default_path = Platform.default_gimp_path
160
+ if gimp_exists?(default_path)
161
+ @gimp_path = default_path
162
+ @gimp_version = Platform.get_gimp_version(default_path)
163
+ return gimp_status(true, default_path, @gimp_version)
164
+ end
165
+
166
+ # Try alternative paths
167
+ Platform.alternative_gimp_paths.each do |path|
168
+ if gimp_exists?(path)
169
+ @gimp_path = path
170
+ @gimp_version = Platform.get_gimp_version(path)
171
+ return gimp_status(true, path, @gimp_version)
172
+ end
173
+ end
174
+
175
+ # Not found
176
+ gimp_status(false, nil, nil)
177
+ end
178
+
179
+ def gimp_exists?(path)
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
+
189
+ File.exist?(path)
190
+ end
191
+
192
+ def gimp_status(available, path, version)
193
+ status = {
194
+ available: available,
195
+ path: path,
196
+ install_cmd: gimp_install_command
197
+ }
198
+ status[:version] = version if version
199
+ status
200
+ end
201
+
202
+ def gimp_install_command
203
+ case Platform.current
204
+ when :windows
205
+ 'Download from https://www.gimp.org/downloads/ or use: choco install gimp'
206
+ when :linux
207
+ 'sudo apt install gimp'
208
+ when :macos
209
+ 'brew install gimp'
210
+ else
211
+ 'Visit https://www.gimp.org/downloads/'
212
+ end
213
+ end
214
+
215
+ def extract_version(output)
216
+ # Try to extract version number from output
217
+ if output =~ /version\s+(\d+\.\d+[\.\d]*)/i
218
+ $1
219
+ else
220
+ output.lines.first&.strip || 'Unknown'
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+
6
+ module RubySpriter
7
+ # GhostEdgeCleaner removes semi-transparent "ghost" pixels around sprite edges
8
+ # using multi-pass alpha channel cleanup while preserving anti-aliasing
9
+ class GhostEdgeCleaner
10
+ attr_reader :input_image, :output_image, :config
11
+ attr_reader :ghost_pixels_detected, :passes_performed, :processing_time
12
+
13
+ MAX_PASSES = 3 # Maximum cleanup passes to prevent infinite loops
14
+
15
+ def initialize(input_image, output_image, config)
16
+ @input_image = input_image
17
+ @output_image = output_image
18
+ @config = config
19
+ @ghost_pixels_detected = 0
20
+ @passes_performed = 0
21
+ @processing_time = 0
22
+ end
23
+
24
+ # Main processing method
25
+ def process
26
+ start_time = Time.now
27
+
28
+ # Copy input to output as starting point
29
+ FileUtils.cp(@input_image, @output_image)
30
+
31
+ # Detect ghost pixels before cleanup
32
+ @ghost_pixels_detected = detect_ghost_pixels
33
+
34
+ # Perform multi-pass cleanup if multi_pass enabled
35
+ if @config.multi_pass
36
+ @passes_performed = multi_pass_cleanup
37
+ else
38
+ # Single pass cleanup
39
+ clean_edges
40
+ @passes_performed = 1
41
+ end
42
+
43
+ @processing_time = Time.now - start_time
44
+
45
+ true
46
+ end
47
+
48
+ # Detect pixels with alpha below threshold
49
+ def detect_ghost_pixels
50
+ threshold = @config.ghost_threshold
51
+
52
+ # Use ImageMagick to count pixels with alpha below threshold
53
+ # Alpha values are 0-255, threshold is 0-255
54
+ threshold_fraction = threshold / 255.0
55
+
56
+ convert_cmd = Platform.imagemagick_convert_cmd
57
+ cmd = "#{convert_cmd} #{Utils::PathHelper.quote_path(@input_image)} " \
58
+ "-channel A " \
59
+ "-separate " \
60
+ "-threshold #{(threshold_fraction * 100).to_i}% " \
61
+ "-format '%[fx:w*h*(1-mean)]' " \
62
+ "info:"
63
+
64
+ stdout, stderr, status = Open3.capture3(cmd)
65
+
66
+ if status.success?
67
+ stdout.strip.gsub("'", '').to_f.to_i
68
+ else
69
+ 0
70
+ end
71
+ end
72
+
73
+ # Clean edges by removing low-alpha pixels
74
+ def clean_edges
75
+ threshold = @config.ghost_threshold
76
+
77
+ # Use ImageMagick to selectively remove pixels below alpha threshold
78
+ # This preserves RGB data and anti-aliasing for pixels above threshold
79
+ threshold_fraction = threshold / 255.0
80
+
81
+ # Use direct -fx operation on alpha channel with explicit RGB preservation
82
+ # Use png:color-type=6 to force RGBA output (handles grayscale inputs)
83
+ # Use 'u' to refer to current channel value in -channel context
84
+ convert_cmd = Platform.imagemagick_convert_cmd
85
+ cmd = "#{convert_cmd} #{Utils::PathHelper.quote_path(@output_image)} " \
86
+ "-define png:color-type=6 " \
87
+ "-alpha set " \
88
+ "-channel A " \
89
+ "-fx \"u < #{threshold_fraction} ? 0 : u\" " \
90
+ "+channel " \
91
+ "#{Utils::PathHelper.quote_path(@output_image)}"
92
+
93
+ stdout, stderr, status = Open3.capture3(cmd)
94
+
95
+ unless status.success?
96
+ warn "Failed to clean edges: #{stderr}"
97
+ return false
98
+ end
99
+
100
+ true
101
+ end
102
+
103
+ # Perform multiple cleanup passes
104
+ def multi_pass_cleanup
105
+ passes = 0
106
+ previous_ghost_count = @ghost_pixels_detected
107
+
108
+ MAX_PASSES.times do
109
+ passes += 1
110
+
111
+ # Perform cleanup pass
112
+ clean_edges
113
+
114
+ # Check if we still have ghost pixels
115
+ current_ghost_count = detect_ghost_pixels_from_file(@output_image)
116
+
117
+ # Stop if no improvement or no ghost pixels remain
118
+ break if current_ghost_count == 0
119
+ break if current_ghost_count >= previous_ghost_count
120
+
121
+ previous_ghost_count = current_ghost_count
122
+ end
123
+
124
+ # Update instance variable for reporting
125
+ @passes_performed = passes
126
+
127
+ passes
128
+ end
129
+
130
+ # Generate processing report
131
+ def report
132
+ {
133
+ ghost_pixels_detected: @ghost_pixels_detected,
134
+ threshold_used: @config.ghost_threshold,
135
+ passes_performed: @passes_performed,
136
+ processing_time: @processing_time.round(3),
137
+ max_passes: MAX_PASSES
138
+ }
139
+ end
140
+
141
+ private
142
+
143
+ def detect_ghost_pixels_from_file(file_path)
144
+ threshold = @config.ghost_threshold
145
+ threshold_fraction = threshold / 255.0
146
+
147
+ convert_cmd = Platform.imagemagick_convert_cmd
148
+ cmd = "#{convert_cmd} #{Utils::PathHelper.quote_path(file_path)} " \
149
+ "-channel A " \
150
+ "-separate " \
151
+ "-threshold #{(threshold_fraction * 100).to_i}% " \
152
+ "-format '%[fx:w*h*(1-mean)]' " \
153
+ "info:"
154
+
155
+ stdout, stderr, status = Open3.capture3(cmd)
156
+
157
+ if status.success?
158
+ stdout.strip.gsub("'", '').to_f.to_i
159
+ else
160
+ 0
161
+ end
162
+ end
163
+ end
164
+ end