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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/CHANGELOG.md +217 -0
  4. data/Gemfile +17 -0
  5. data/LICENSE +21 -0
  6. data/README.md +561 -0
  7. data/bin/ruby_spriter +20 -0
  8. data/lib/ruby_spriter/cli.rb +249 -0
  9. data/lib/ruby_spriter/consolidator.rb +146 -0
  10. data/lib/ruby_spriter/dependency_checker.rb +174 -0
  11. data/lib/ruby_spriter/gimp_processor.rb +664 -0
  12. data/lib/ruby_spriter/metadata_manager.rb +116 -0
  13. data/lib/ruby_spriter/platform.rb +82 -0
  14. data/lib/ruby_spriter/processor.rb +251 -0
  15. data/lib/ruby_spriter/utils/file_helper.rb +57 -0
  16. data/lib/ruby_spriter/utils/output_formatter.rb +65 -0
  17. data/lib/ruby_spriter/utils/path_helper.rb +59 -0
  18. data/lib/ruby_spriter/version.rb +7 -0
  19. data/lib/ruby_spriter/video_processor.rb +139 -0
  20. data/lib/ruby_spriter.rb +31 -0
  21. data/ruby_spriter.gemspec +42 -0
  22. data/spec/fixtures/image_without_metadata.png +0 -0
  23. data/spec/fixtures/spritesheet_4x2.png +0 -0
  24. data/spec/fixtures/spritesheet_4x4.png +0 -0
  25. data/spec/fixtures/spritesheet_6x2.png +0 -0
  26. data/spec/fixtures/spritesheet_with_metadata.png +0 -0
  27. data/spec/fixtures/test_video.mp4 +0 -0
  28. data/spec/ruby_spriter/cli_spec.rb +1142 -0
  29. data/spec/ruby_spriter/consolidator_spec.rb +375 -0
  30. data/spec/ruby_spriter/dependency_checker_spec.rb +0 -0
  31. data/spec/ruby_spriter/gimp_processor_spec.rb +425 -0
  32. data/spec/ruby_spriter/metadata_manager_spec.rb +0 -0
  33. data/spec/ruby_spriter/platform_spec.rb +82 -0
  34. data/spec/ruby_spriter/processor_spec.rb +0 -0
  35. data/spec/ruby_spriter/utils/file_helper_spec.rb +71 -0
  36. data/spec/ruby_spriter/utils/output_formatter_spec.rb +0 -0
  37. data/spec/ruby_spriter/utils/path_helper_spec.rb +78 -0
  38. data/spec/ruby_spriter/video_processor_spec.rb +0 -0
  39. data/spec/spec_helper.rb +41 -0
  40. metadata +88 -0
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module RubySpriter
6
+ # Command-line interface
7
+ class CLI
8
+ PRESETS = {
9
+ thumbnail: { columns: 3, frame_count: 9, max_width: 240 },
10
+ preview: { columns: 4, frame_count: 16, max_width: 400 },
11
+ detailed: { columns: 10, frame_count: 50, max_width: 320 },
12
+ contact: { columns: 8, frame_count: 64, max_width: 160 }
13
+ }.freeze
14
+
15
+ def self.start(args)
16
+ new.parse_and_run(args)
17
+ end
18
+
19
+ def parse_and_run(args)
20
+ options = {}
21
+ parser = build_option_parser(options)
22
+
23
+ parser.parse!(args)
24
+
25
+ # Handle special commands that don't need full processing
26
+ if options[:check_dependencies]
27
+ checker = DependencyChecker.new(verbose: true)
28
+ checker.print_report
29
+ exit(checker.all_satisfied? ? 0 : 1)
30
+ end
31
+
32
+ # Run processor
33
+ processor = Processor.new(options)
34
+ processor.run
35
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
36
+ puts "Error: #{e.message}"
37
+ puts "\nUse --help for usage information"
38
+ exit 1
39
+ end
40
+
41
+ private
42
+
43
+ def build_option_parser(options)
44
+ OptionParser.new do |opts|
45
+ opts.banner = "Usage: ruby_spriter [options]"
46
+
47
+ add_header(opts)
48
+ add_input_options(opts, options)
49
+ add_spritesheet_options(opts, options)
50
+ add_gimp_options(opts, options)
51
+ add_bg_removal_options(opts, options)
52
+ add_consolidation_options(opts, options)
53
+ add_preset_options(opts, options)
54
+ add_other_options(opts, options)
55
+ end
56
+ end
57
+
58
+ def add_header(opts)
59
+ opts.separator ""
60
+ opts.separator "Ruby Spriter v#{VERSION} - MP4 to Spritesheet + GIMP Processing"
61
+ opts.separator "Platform: #{Platform.current.to_s.capitalize}"
62
+ opts.separator ""
63
+ end
64
+
65
+ def add_input_options(opts, options)
66
+ opts.separator "Input Options:"
67
+
68
+ opts.on("-v", "--video FILE", "Input video file (MP4)") do |v|
69
+ options[:video] = v
70
+ end
71
+
72
+ opts.on("-i", "--image FILE", "Input image file (PNG) for direct processing") do |i|
73
+ options[:image] = i
74
+ end
75
+
76
+ opts.on("--consolidate FILES", Array, "Consolidate multiple spritesheets (comma-separated)") do |c|
77
+ options[:consolidate] = c
78
+ end
79
+
80
+ opts.on("--verify FILE", "Verify spritesheet metadata") do |v|
81
+ options[:verify] = v
82
+ end
83
+
84
+ opts.separator ""
85
+ end
86
+
87
+ def add_spritesheet_options(opts, options)
88
+ opts.separator "Spritesheet Options:"
89
+
90
+ opts.on("-o", "--output FILE", "Output file path") do |o|
91
+ options[:output] = o
92
+ end
93
+
94
+ opts.on("-f", "--frames COUNT", Integer, "Number of frames to extract (default: 16)") do |f|
95
+ options[:frame_count] = f
96
+ end
97
+
98
+ opts.on("-c", "--columns COUNT", Integer, "Grid columns (default: 4)") do |c|
99
+ options[:columns] = c
100
+ end
101
+
102
+ opts.on("-w", "--width PIXELS", Integer, "Max frame width (default: 320)") do |w|
103
+ options[:max_width] = w
104
+ end
105
+
106
+ opts.on("-b", "--background COLOR", "Tile background: black, white (default: black)") do |b|
107
+ options[:bg_color] = b
108
+ end
109
+
110
+ opts.separator ""
111
+ end
112
+
113
+ def add_gimp_options(opts, options)
114
+ opts.separator "GIMP Processing Options:"
115
+
116
+ opts.on("-s", "--scale PERCENT", Integer, "Scale image by percentage") do |s|
117
+ options[:scale_percent] = s
118
+ end
119
+
120
+ opts.on("--interpolation METHOD", [:none, :linear, :cubic, :nohalo, :lohalo],
121
+ "Interpolation method: none, linear, cubic, nohalo, lohalo (default: nohalo)") do |i|
122
+ options[:scale_interpolation] = i.to_s
123
+ end
124
+
125
+ opts.on("--sharpen", "Apply unsharp mask after scaling (enhances edges)") do
126
+ options[:sharpen] = true
127
+ end
128
+
129
+ opts.on("--sharpen-radius VALUE", Float, "Sharpen radius in pixels (default: 2.0)") do |r|
130
+ options[:sharpen_radius] = r
131
+ end
132
+
133
+ opts.on("--sharpen-gain VALUE", Float, "Sharpen gain/strength (default: 0.5, range: 0.0-2.0+)") do |g|
134
+ options[:sharpen_gain] = g
135
+ end
136
+
137
+ opts.on("--sharpen-threshold VALUE", Float, "Sharpen threshold as fraction (default: 0.03, range: 0.0-1.0)") do |t|
138
+ options[:sharpen_threshold] = t
139
+ end
140
+
141
+ opts.on("-r", "--remove-bg", "Remove background from spritesheet using GIMP") do
142
+ options[:remove_bg] = true
143
+ end
144
+
145
+ opts.on("-t", "--threshold VALUE", Float, "Feather radius (default: 0.0 = no feathering)") do |t|
146
+ options[:bg_threshold] = t
147
+ end
148
+
149
+ opts.on("-g", "--grow PIXELS", Integer, "Pixels to grow selection (default: 1)") do |g|
150
+ options[:grow_selection] = g
151
+ end
152
+
153
+ opts.separator ""
154
+ end
155
+
156
+ def add_bg_removal_options(opts, options)
157
+ opts.separator "Background Removal Method:"
158
+
159
+ opts.on("--fuzzy", "Use fuzzy select (contiguous regions) - DEFAULT") do
160
+ options[:fuzzy_select] = true
161
+ end
162
+
163
+ opts.on("--no-fuzzy", "Use global color select (all matching pixels)") do
164
+ options[:fuzzy_select] = false
165
+ end
166
+
167
+ opts.separator ""
168
+ opts.separator "Operation Order:"
169
+
170
+ opts.on("--order ORDER", [:scale_first, :bg_first],
171
+ "Operation order: scale_first or bg_first (default: scale_first)") do |order|
172
+ options[:operation_order] = order == :scale_first ? :scale_then_remove_bg : :remove_bg_then_scale
173
+ end
174
+
175
+ opts.separator ""
176
+ end
177
+
178
+ def add_consolidation_options(opts, options)
179
+ opts.separator "Consolidation Options:"
180
+
181
+ opts.on("--[no-]validate-columns", "Abort if column counts don't match (default: true)") do |v|
182
+ options[:validate_columns] = v
183
+ end
184
+
185
+ opts.separator ""
186
+ end
187
+
188
+ def add_preset_options(opts, options)
189
+ opts.separator "Preset Configurations:"
190
+
191
+ preset_descriptions = PRESETS.map do |name, config|
192
+ " #{name}: #{config[:columns]}×? grid, #{config[:frame_count]} frames, #{config[:max_width]}px wide"
193
+ end.join("\n")
194
+
195
+ opts.on("--preset NAME", String, "Apply preset configuration:",
196
+ *preset_descriptions.split("\n")) do |preset_name|
197
+ preset_key = preset_name.to_sym
198
+ unless PRESETS.key?(preset_key)
199
+ valid_presets = PRESETS.keys.join(', ')
200
+ raise OptionParser::InvalidArgument, "Unknown preset: #{preset_name}. Valid options: #{valid_presets}"
201
+ end
202
+ options.merge!(PRESETS[preset_key])
203
+ end
204
+
205
+ opts.separator ""
206
+ end
207
+
208
+ def add_other_options(opts, options)
209
+ opts.separator "Other Options:"
210
+
211
+ opts.on("--keep-temp", "Keep temporary files for debugging") do
212
+ options[:keep_temp] = true
213
+ end
214
+
215
+ opts.on("--debug", "Enable debug mode (verbose output + keep temp files)") do
216
+ options[:debug] = true
217
+ options[:keep_temp] = true
218
+ end
219
+
220
+ opts.on("-h", "--help", "Show this help message") do
221
+ puts opts
222
+ exit
223
+ end
224
+
225
+ opts.on("--version", "Show version information") do
226
+ puts "Ruby Spriter v#{VERSION}"
227
+ puts "Platform: #{Platform.current.to_s.capitalize}"
228
+ puts "Date: #{VERSION_DATE}"
229
+ exit
230
+ end
231
+
232
+ opts.on("--check-dependencies", "Check if all required external tools are installed") do
233
+ options[:check_dependencies] = true
234
+ end
235
+
236
+ opts.separator ""
237
+ opts.separator "Examples:"
238
+ opts.separator " ruby_spriter --check-dependencies"
239
+ opts.separator " ruby_spriter --video input.mp4"
240
+ opts.separator " ruby_spriter --video input.mp4 --remove-bg --scale 50"
241
+ opts.separator " ruby_spriter --video input.mp4 --scale 50 --interpolation nohalo --sharpen"
242
+ opts.separator " ruby_spriter --image sprite.png --scale 50 --sharpen --sharpen-gain 1.5"
243
+ opts.separator " ruby_spriter --image sprite.png --remove-bg --fuzzy"
244
+ opts.separator " ruby_spriter --consolidate file1.png,file2.png,file3.png"
245
+ opts.separator " ruby_spriter --verify spritesheet.png"
246
+ opts.separator ""
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module RubySpriter
6
+ # Consolidates multiple spritesheets vertically
7
+ class Consolidator
8
+ attr_reader :options
9
+
10
+ def initialize(options = {})
11
+ @options = options
12
+ end
13
+
14
+ # Consolidate multiple spritesheets into one
15
+ # @param files [Array<String>] Array of spritesheet file paths
16
+ # @param output_file [String] Output consolidated file path
17
+ # @return [Hash] Processing results
18
+ def consolidate(files, output_file)
19
+ validate_files!(files)
20
+
21
+ Utils::OutputFormatter.header("Consolidating Spritesheets")
22
+
23
+ metadata_list = read_all_metadata(files)
24
+ validate_compatibility!(metadata_list) if options[:validate_columns]
25
+
26
+ create_consolidated_image(files, output_file)
27
+
28
+ total_frames = metadata_list.sum { |m| m[:frames] }
29
+ columns = metadata_list.first[:columns]
30
+ rows = (total_frames.to_f / columns).ceil
31
+
32
+ # Embed consolidated metadata
33
+ temp_file = output_file.sub('.png', '_temp.png')
34
+ File.rename(output_file, temp_file)
35
+
36
+ MetadataManager.embed(
37
+ temp_file,
38
+ output_file,
39
+ columns: columns,
40
+ rows: rows,
41
+ frames: total_frames,
42
+ debug: options[:debug]
43
+ )
44
+
45
+ File.delete(temp_file) if File.exist?(temp_file)
46
+
47
+ file_size = File.size(output_file)
48
+
49
+ # Display results with Godot instructions
50
+ display_consolidation_results(output_file, file_size, files, metadata_list, columns, rows, total_frames)
51
+
52
+ {
53
+ output_file: output_file,
54
+ columns: columns,
55
+ rows: rows,
56
+ frames: total_frames,
57
+ size: file_size
58
+ }
59
+ end
60
+
61
+ private
62
+
63
+ def validate_files!(files)
64
+ raise ValidationError, "Need at least 2 files to consolidate" if files.length < 2
65
+
66
+ files.each { |file| Utils::FileHelper.validate_readable!(file) }
67
+ end
68
+
69
+ def read_all_metadata(files)
70
+ metadata_list = files.map do |file|
71
+ metadata = MetadataManager.read(file)
72
+
73
+ unless metadata
74
+ raise ValidationError, "File missing metadata: #{file}\nAll files must be Ruby Spriter spritesheets."
75
+ end
76
+
77
+ metadata
78
+ end
79
+
80
+ metadata_list
81
+ end
82
+
83
+ def validate_compatibility!(metadata_list)
84
+ columns = metadata_list.first[:columns]
85
+
86
+ incompatible = metadata_list.find { |m| m[:columns] != columns }
87
+
88
+ if incompatible
89
+ raise ValidationError,
90
+ "Column count mismatch: Expected #{columns}, found #{incompatible[:columns]}\n" \
91
+ "Use --no-validate-columns to force consolidation."
92
+ end
93
+ end
94
+
95
+ def create_consolidated_image(files, output_file)
96
+ Utils::OutputFormatter.indent("Stacking spritesheets vertically...")
97
+
98
+ # Use ImageMagick to stack images vertically
99
+ magick_cmd = Platform.imagemagick_convert_cmd
100
+
101
+ cmd = [
102
+ magick_cmd,
103
+ *files.map { |f| Utils::PathHelper.quote_path(f) },
104
+ '-append',
105
+ Utils::PathHelper.quote_path(output_file)
106
+ ].join(' ')
107
+
108
+ if options[:debug]
109
+ Utils::OutputFormatter.indent("DEBUG: ImageMagick command: #{cmd}")
110
+ end
111
+
112
+ stdout, stderr, status = Open3.capture3(cmd)
113
+
114
+ unless status.success?
115
+ raise ProcessingError, "ImageMagick consolidation failed: #{stderr}"
116
+ end
117
+
118
+ Utils::FileHelper.validate_exists!(output_file)
119
+ end
120
+
121
+ def display_consolidation_results(output_file, file_size, files, metadata_list, columns, rows, total_frames)
122
+ Utils::OutputFormatter.success("Consolidated spritesheet created")
123
+ Utils::OutputFormatter.indent("Output: #{output_file}")
124
+ Utils::OutputFormatter.indent("Size: #{Utils::FileHelper.format_size(file_size)}")
125
+ Utils::OutputFormatter.note("Combined #{files.length} spritesheets (#{total_frames} total frames)")
126
+
127
+ puts "\n Grid Layout:"
128
+ Utils::OutputFormatter.indent("Columns: #{columns}")
129
+ Utils::OutputFormatter.indent("Rows: #{rows}")
130
+ Utils::OutputFormatter.indent("Total Frames: #{total_frames}")
131
+
132
+ puts "\n 📊 Godot AnimatedSprite2D Settings:"
133
+ Utils::OutputFormatter.indent("HFrames = #{columns}")
134
+ Utils::OutputFormatter.indent("VFrames = #{rows}")
135
+
136
+ puts "\n 📋 Source Breakdown:"
137
+ metadata_list.each_with_index do |meta, index|
138
+ file_basename = File.basename(files[index])
139
+ Utils::OutputFormatter.indent("#{index + 1}. #{file_basename}")
140
+ Utils::OutputFormatter.indent(" └─ #{meta[:columns]}×#{meta[:rows]} grid (#{meta[:frames]} frames)")
141
+ end
142
+
143
+ puts ""
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,174 @@
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