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
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+
6
+ module RubySpriter
7
+ # SmokeDetector identifies and optionally removes smoke-like transparency gradients
8
+ # Detects alpha values between 20-80% in contiguous regions
9
+ class SmokeDetector
10
+ attr_reader :input_image, :output_image, :config
11
+ attr_reader :smoke_regions, :processing_time
12
+
13
+ # Smoke detection thresholds
14
+ MIN_ALPHA = 0.2 # 20% - minimum alpha for smoke
15
+ MAX_ALPHA = 0.8 # 80% - maximum alpha for smoke
16
+ MIN_AREA = 50 # Minimum contiguous area in pixels
17
+
18
+ def initialize(input_image, output_image, config)
19
+ @input_image = input_image
20
+ @output_image = output_image
21
+ @config = config
22
+ @smoke_regions = []
23
+ @processing_time = 0
24
+ end
25
+
26
+ # Main processing method
27
+ def process
28
+ start_time = Time.now
29
+
30
+ # Validate input file exists
31
+ unless File.exist?(@input_image)
32
+ warn "SmokeDetector: Input image does not exist: #{@input_image}"
33
+ @processing_time = Time.now - start_time
34
+ return false
35
+ end
36
+
37
+ # Always detect smoke (for reporting)
38
+ @smoke_regions = detect
39
+
40
+ # Remove smoke if configured
41
+ if @config.remove_smoke
42
+ FileUtils.cp(@input_image, @output_image)
43
+ remove_smoke_regions(@smoke_regions)
44
+ else
45
+ # Just copy input to output
46
+ FileUtils.cp(@input_image, @output_image)
47
+ end
48
+
49
+ @processing_time = Time.now - start_time
50
+
51
+ true
52
+ end
53
+
54
+ # Detect smoke-like transparency gradients
55
+ def detect
56
+ regions = []
57
+
58
+ # Use ImageMagick to analyze alpha channel
59
+ # Find regions with alpha in the smoke range (20-80%)
60
+ regions = find_smoke_regions
61
+
62
+ # Filter by minimum area
63
+ regions.select { |r| r[:area] >= MIN_AREA }
64
+ end
65
+
66
+ # Remove detected smoke regions
67
+ def remove_smoke_regions(regions)
68
+ return true if regions.empty?
69
+
70
+ # Apply smoke removal to the entire image
71
+ # Remove all pixels with alpha in the smoke range
72
+ remove_smoke_pixels
73
+
74
+ true
75
+ end
76
+
77
+ # Check if alpha value is smoke-like
78
+ def is_smoke_like?(alpha)
79
+ alpha >= MIN_ALPHA && alpha <= MAX_ALPHA
80
+ end
81
+
82
+ # Generate detection report
83
+ def report
84
+ {
85
+ smoke_detected: @smoke_regions.length,
86
+ smoke_removed: @config.remove_smoke,
87
+ smoke_regions: @smoke_regions.map do |r|
88
+ {
89
+ x: r[:x],
90
+ y: r[:y],
91
+ area: r[:area],
92
+ alpha_range: r[:alpha_range]
93
+ }
94
+ end,
95
+ processing_time: @processing_time.round(3),
96
+ min_alpha_threshold: MIN_ALPHA,
97
+ max_alpha_threshold: MAX_ALPHA,
98
+ min_area_threshold: MIN_AREA
99
+ }
100
+ end
101
+
102
+ private
103
+
104
+ def find_smoke_regions
105
+ regions = []
106
+
107
+ # Get image dimensions
108
+ cmd = "magick identify -format \"%w %h\" #{Utils::PathHelper.quote_path(@input_image)}"
109
+ stdout, stderr, status = Open3.capture3(cmd)
110
+
111
+ return regions unless status.success?
112
+
113
+ width, height = stdout.strip.split.map(&:to_i)
114
+ return regions if width == 0 || height == 0
115
+
116
+ # Sample grid to find smoke-like regions
117
+ step = 20
118
+ visited = {}
119
+
120
+ (step...height).step(step) do |y|
121
+ (step...width).step(step) do |x|
122
+ next if visited["#{x},#{y}"]
123
+
124
+ alpha = get_alpha_at_point(x, y)
125
+
126
+ if alpha && is_smoke_like?(alpha)
127
+ # Found a smoke-like pixel, estimate region
128
+ area = estimate_smoke_area(x, y, visited)
129
+
130
+ if area >= MIN_AREA
131
+ regions << {
132
+ x: x,
133
+ y: y,
134
+ area: area,
135
+ alpha_range: [MIN_ALPHA, MAX_ALPHA]
136
+ }
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ regions
143
+ end
144
+
145
+ def get_alpha_at_point(x, y)
146
+ cmd = "magick #{Utils::PathHelper.quote_path(@input_image)} " \
147
+ "-format \"%[pixel:p{#{x},#{y}}]\" info:"
148
+ stdout, stderr, status = Open3.capture3(cmd)
149
+
150
+ return nil unless status.success?
151
+
152
+ # Parse alpha from output like "srgba(255,255,255,0.5)" or "gray(128,0.5)"
153
+ if stdout =~ /[a-z]+\([^)]+,([0-9.]+)\)/
154
+ $1.to_f
155
+ elsif stdout =~ /[a-z]+\([^)]+\)/ && stdout !~ /,/
156
+ # No alpha in output, fully opaque
157
+ 1.0
158
+ else
159
+ # Try to extract from different format
160
+ 1.0
161
+ end
162
+ end
163
+
164
+ def estimate_smoke_area(x, y, visited)
165
+ # Simple flood fill estimation for smoke region
166
+ queue = [[x, y]]
167
+ area = 0
168
+ max_checks = 100 # Limit to prevent long processing
169
+
170
+ while !queue.empty? && area < max_checks
171
+ cx, cy = queue.shift
172
+ key = "#{cx},#{cy}"
173
+
174
+ next if visited[key]
175
+
176
+ alpha = get_alpha_at_point(cx, cy)
177
+
178
+ if alpha && is_smoke_like?(alpha)
179
+ visited[key] = true
180
+ area += 1
181
+
182
+ # Add neighbors (simplified 4-directional)
183
+ [[cx + 20, cy], [cx - 20, cy], [cx, cy + 20], [cx, cy - 20]].each do |nx, ny|
184
+ queue << [nx, ny] unless visited["#{nx},#{ny}"]
185
+ end
186
+ else
187
+ visited[key] = true
188
+ end
189
+ end
190
+
191
+ # Approximate actual area (we sampled every 20 pixels)
192
+ area * 400 # 20x20 = 400 pixels per sample
193
+ end
194
+
195
+ def remove_smoke_pixels
196
+ # Use ImageMagick to remove pixels with alpha in smoke range
197
+ # Convert pixels with alpha 20-80% to fully transparent
198
+
199
+ min_threshold = (MIN_ALPHA * 100).to_i
200
+ max_threshold = (MAX_ALPHA * 100).to_i
201
+
202
+ # Use -fx to conditionally set alpha:
203
+ # if alpha is between MIN_ALPHA and MAX_ALPHA, set to 0 (transparent)
204
+ # otherwise keep original alpha
205
+ cmd = "magick #{Utils::PathHelper.quote_path(@output_image)} " \
206
+ "-define png:color-type=6 " \
207
+ "-alpha set " \
208
+ "-channel A " \
209
+ "-fx \"(u >= #{MIN_ALPHA} && u <= #{MAX_ALPHA}) ? 0 : u\" " \
210
+ "+channel " \
211
+ "#{Utils::PathHelper.quote_path(@output_image)}"
212
+
213
+ stdout, stderr, status = Open3.capture3(cmd)
214
+
215
+ unless status.success?
216
+ warn "Failed to remove smoke pixels: #{stderr}"
217
+ return false
218
+ end
219
+
220
+ true
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'timeout'
5
+ require 'tmpdir'
6
+
7
+ module RubySpriter
8
+ # ThresholdStepper applies multiple threshold-based background removal passes
9
+ # using GIMP Python-fu with edge-sampled background colors
10
+ class ThresholdStepper
11
+ attr_reader :input_file, :output_file, :background_palette, :gimp_processor, :options
12
+
13
+ def initialize(input_file, output_file, background_palette, gimp_processor, options = {})
14
+ @input_file = input_file
15
+ @output_file = output_file
16
+ @background_palette = background_palette
17
+ @gimp_processor = gimp_processor
18
+ @options = options
19
+ @threshold_values = parse_threshold_values(options[:threshold_values])
20
+ @threshold_timeout = options[:threshold_timeout] || 60
21
+ @total_timeout = options[:total_threshold_timeout] || 300
22
+ @thresholds_processed = 0
23
+ @skipped_thresholds = 0
24
+ @start_time = nil
25
+ @end_time = nil
26
+ end
27
+
28
+ def process
29
+ @start_time = Time.now
30
+ temp_results = []
31
+
32
+ begin
33
+ Timeout.timeout(@total_timeout) do
34
+ @threshold_values.each_with_index do |threshold, index|
35
+ temp_output = File.join(Dir.tmpdir, "threshold_#{threshold}_#{Time.now.to_i}_#{index}.png")
36
+
37
+ begin
38
+ Timeout.timeout(@threshold_timeout) do
39
+ script = generate_gimp_script(threshold, temp_output)
40
+
41
+ if @gimp_processor.execute_python_script(script, temp_output)
42
+ temp_results << temp_output
43
+ @thresholds_processed += 1
44
+ else
45
+ @skipped_thresholds += 1
46
+ log_debug "Threshold #{threshold} failed to process"
47
+ end
48
+ end
49
+ rescue Timeout::Error
50
+ @skipped_thresholds += 1
51
+ log_debug "Threshold #{threshold} timed out after #{@threshold_timeout}s"
52
+ rescue StandardError => e
53
+ @skipped_thresholds += 1
54
+ log_debug "Threshold #{threshold} error: #{e.message}"
55
+ end
56
+ end
57
+ end
58
+ rescue Timeout::Error
59
+ log_debug "Total threshold stepping timed out after #{@total_timeout}s"
60
+ end
61
+
62
+ @end_time = Time.now
63
+
64
+ # Composite all threshold results
65
+ if temp_results.any?
66
+ composite_results(temp_results)
67
+ else
68
+ # Fallback: copy input to output if no thresholds succeeded
69
+ FileUtils.cp(@input_file, @output_file)
70
+ end
71
+
72
+ # Cleanup temp files
73
+ temp_results.each { |f| File.delete(f) if File.exist?(f) }
74
+ end
75
+
76
+ def report
77
+ {
78
+ thresholds_processed: @thresholds_processed,
79
+ skipped_thresholds: @skipped_thresholds,
80
+ total_time: @end_time && @start_time ? (@end_time - @start_time).round(2) : 0
81
+ }
82
+ end
83
+
84
+ private
85
+
86
+ def parse_threshold_values(custom_values)
87
+ if custom_values.is_a?(String)
88
+ custom_values.split(',').map(&:strip).map(&:to_f)
89
+ elsif custom_values.is_a?(Array)
90
+ custom_values.map(&:to_f)
91
+ else
92
+ # Default threshold values
93
+ [0.0, 0.5, 1.0, 3.0, 5.0, 10.0]
94
+ end
95
+ end
96
+
97
+ def generate_gimp_script(threshold, output_path)
98
+ # Build color list for GIMP script
99
+ color_definitions = @background_palette.map.with_index do |color, idx|
100
+ " color#{idx} = Gegl.Color.new('rgb(#{color[:r] / 255.0}, #{color[:g] / 255.0}, #{color[:b] / 255.0})')"
101
+ end.join("\n")
102
+
103
+ color_selections = @background_palette.map.with_index do |color, idx|
104
+ operation = idx == 0 ? 'Gimp.ChannelOps.REPLACE' : 'Gimp.ChannelOps.ADD'
105
+ <<~PYTHON.chomp
106
+ # Select color #{idx + 1}
107
+ config = select_proc.create_config()
108
+ config.set_property('image', img)
109
+ config.set_property('operation', #{operation})
110
+ config.set_property('drawable', layer)
111
+ config.set_property('color', color#{idx})
112
+ config.set_property('threshold', #{threshold})
113
+ select_proc.run(config)
114
+ PYTHON
115
+ end.join("\n\n")
116
+
117
+ <<~PYTHON
118
+ #!/usr/bin/env python3
119
+ import gi
120
+ gi.require_version('Gimp', '3.0')
121
+ gi.require_version('Gegl', '0.4')
122
+ from gi.repository import Gimp, GLib, Gio, Gegl
123
+ import sys
124
+
125
+ def threshold_step():
126
+ try:
127
+ Gegl.init(None)
128
+
129
+ # Load image
130
+ img = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE,
131
+ Gio.File.new_for_path(r'#{@input_file}'))
132
+
133
+ if not img:
134
+ raise Exception("Failed to load image")
135
+
136
+ layers = img.get_layers()
137
+ if not layers:
138
+ raise Exception("No layers found in image")
139
+
140
+ layer = layers[0]
141
+
142
+ # Add alpha channel if needed
143
+ if not layer.has_alpha():
144
+ layer.add_alpha()
145
+
146
+ pdb = Gimp.get_pdb()
147
+
148
+ # Get select-color procedure
149
+ select_proc = pdb.lookup_procedure('gimp-image-select-color')
150
+ if not select_proc:
151
+ raise Exception("Could not find gimp-image-select-color procedure")
152
+
153
+ # Define background colors
154
+ #{color_definitions}
155
+
156
+ # Select all background colors with threshold
157
+ #{color_selections}
158
+
159
+ # Delete selection (make transparent)
160
+ edit_clear = pdb.lookup_procedure('gimp-drawable-edit-clear')
161
+ config = edit_clear.create_config()
162
+ config.set_property('drawable', layer)
163
+ edit_clear.run(config)
164
+
165
+ # Deselect
166
+ select_none = pdb.lookup_procedure('gimp-selection-none')
167
+ config = select_none.create_config()
168
+ config.set_property('image', img)
169
+ select_none.run(config)
170
+
171
+ # Export
172
+ export_proc = pdb.lookup_procedure('file-png-export')
173
+ config = export_proc.create_config()
174
+ config.set_property('image', img)
175
+ config.set_property('file', Gio.File.new_for_path(r'#{output_path}'))
176
+ export_proc.run(config)
177
+
178
+ print("SUCCESS - Threshold #{threshold} complete!")
179
+ return 0
180
+ except Exception as e:
181
+ print(f"ERROR: {e}")
182
+ import traceback
183
+ traceback.print_exc()
184
+ return 1
185
+
186
+ sys.exit(threshold_step())
187
+ PYTHON
188
+ end
189
+
190
+ def composite_results(temp_files)
191
+ # Use ImageMagick to composite all threshold results
192
+ # Layer them with DstOver mode (later layers go behind earlier ones)
193
+
194
+ if temp_files.length == 1
195
+ # Only one result, just copy it
196
+ FileUtils.cp(temp_files.first, @output_file)
197
+ return
198
+ end
199
+
200
+ # Build composite command
201
+ # Start with the first result as base
202
+ cmd_parts = ['magick', Utils::PathHelper.quote_path(temp_files.first)]
203
+
204
+ # Add each subsequent result as a layer
205
+ temp_files[1..-1].each do |temp_file|
206
+ cmd_parts << Utils::PathHelper.quote_path(temp_file)
207
+ cmd_parts << '-compose' << 'DstOver' << '-composite'
208
+ end
209
+
210
+ # Output final result
211
+ cmd_parts << Utils::PathHelper.quote_path(@output_file)
212
+
213
+ cmd = cmd_parts.join(' ')
214
+ stdout, stderr, status = Open3.capture3(cmd)
215
+
216
+ unless status.success?
217
+ raise ProcessingError, "Failed to composite threshold results: #{stderr}"
218
+ end
219
+ end
220
+
221
+ def log_debug(message)
222
+ return unless @options[:debug]
223
+
224
+ Utils::OutputFormatter.indent("DEBUG: #{message}")
225
+ end
226
+ end
227
+ end
@@ -1,82 +1,82 @@
1
- # frozen_string_literal: true
2
-
3
- module RubySpriter
4
- module Utils
5
- # File naming and size utilities
6
- class FileHelper
7
- class << self
8
- # Generate spritesheet filename from video file
9
- # @param video_file [String] Path to video file
10
- # @return [String] Generated spritesheet filename
11
- def spritesheet_filename(video_file)
12
- dir = File.dirname(video_file)
13
- basename = File.basename(video_file, '.*')
14
- File.join(dir, "#{basename}_spritesheet.png")
15
- end
16
-
17
- # Generate output filename with suffix
18
- # @param input_file [String] Original input file
19
- # @param suffix [String] Suffix to add to filename
20
- # @return [String] Generated output filename
21
- def output_filename(input_file, suffix)
22
- dir = File.dirname(input_file)
23
- basename = File.basename(input_file, '.*')
24
- File.join(dir, "#{basename}-#{suffix}.png")
25
- end
26
-
27
- # Format file size in human-readable format
28
- # @param bytes [Integer] File size in bytes
29
- # @return [String] Formatted file size
30
- def format_size(bytes)
31
- if bytes >= 1024 * 1024
32
- "#{(bytes / (1024.0 * 1024.0)).round(2)} MB"
33
- elsif bytes >= 1024
34
- "#{(bytes / 1024.0).round(2)} KB"
35
- else
36
- "#{bytes} bytes"
37
- end
38
- end
39
-
40
- # Validate file exists
41
- # @param path [String] File path to validate
42
- # @raise [ValidationError] if file doesn't exist
43
- def validate_exists!(path)
44
- raise ValidationError, "File not found: #{path}" unless File.exist?(path)
45
- end
46
-
47
- # Validate file is readable
48
- # @param path [String] File path to validate
49
- # @raise [ValidationError] if file isn't readable
50
- def validate_readable!(path)
51
- validate_exists!(path)
52
- raise ValidationError, "File not readable: #{path}" unless File.readable?(path)
53
- end
54
-
55
- # Generate unique filename by adding timestamp if file exists
56
- # @param path [String] Original file path
57
- # @return [String] Unique file path (adds timestamp if original exists)
58
- def unique_filename(path)
59
- return path unless File.exist?(path)
60
-
61
- dir = File.dirname(path)
62
- ext = File.extname(path)
63
- basename = File.basename(path, ext)
64
- timestamp = Time.now.strftime('%Y%m%d_%H%M%S_%3N') # Include milliseconds
65
-
66
- File.join(dir, "#{basename}_#{timestamp}#{ext}")
67
- end
68
-
69
- # Ensure output filename is unique based on overwrite option
70
- # @param path [String] Desired output path
71
- # @param overwrite [Boolean] If true, return original path; if false, make unique
72
- # @return [String] Output path (unique if overwrite is false and file exists)
73
- def ensure_unique_output(path, overwrite: false)
74
- return path if overwrite
75
- return path unless File.exist?(path)
76
-
77
- unique_filename(path)
78
- end
79
- end
80
- end
81
- end
82
- end
1
+ # frozen_string_literal: true
2
+
3
+ module RubySpriter
4
+ module Utils
5
+ # File naming and size utilities
6
+ class FileHelper
7
+ class << self
8
+ # Generate spritesheet filename from video file
9
+ # @param video_file [String] Path to video file
10
+ # @return [String] Generated spritesheet filename
11
+ def spritesheet_filename(video_file)
12
+ dir = File.dirname(video_file)
13
+ basename = File.basename(video_file, '.*')
14
+ File.join(dir, "#{basename}_spritesheet.png")
15
+ end
16
+
17
+ # Generate output filename with suffix
18
+ # @param input_file [String] Original input file
19
+ # @param suffix [String] Suffix to add to filename
20
+ # @return [String] Generated output filename
21
+ def output_filename(input_file, suffix)
22
+ dir = File.dirname(input_file)
23
+ basename = File.basename(input_file, '.*')
24
+ File.join(dir, "#{basename}-#{suffix}.png")
25
+ end
26
+
27
+ # Format file size in human-readable format
28
+ # @param bytes [Integer] File size in bytes
29
+ # @return [String] Formatted file size
30
+ def format_size(bytes)
31
+ if bytes >= 1024 * 1024
32
+ "#{(bytes / (1024.0 * 1024.0)).round(2)} MB"
33
+ elsif bytes >= 1024
34
+ "#{(bytes / 1024.0).round(2)} KB"
35
+ else
36
+ "#{bytes} bytes"
37
+ end
38
+ end
39
+
40
+ # Validate file exists
41
+ # @param path [String] File path to validate
42
+ # @raise [ValidationError] if file doesn't exist
43
+ def validate_exists!(path)
44
+ raise ValidationError, "File not found: #{path}" unless File.exist?(path)
45
+ end
46
+
47
+ # Validate file is readable
48
+ # @param path [String] File path to validate
49
+ # @raise [ValidationError] if file isn't readable
50
+ def validate_readable!(path)
51
+ validate_exists!(path)
52
+ raise ValidationError, "File not readable: #{path}" unless File.readable?(path)
53
+ end
54
+
55
+ # Generate unique filename by adding timestamp if file exists
56
+ # @param path [String] Original file path
57
+ # @return [String] Unique file path (adds timestamp if original exists)
58
+ def unique_filename(path)
59
+ return path unless File.exist?(path)
60
+
61
+ dir = File.dirname(path)
62
+ ext = File.extname(path)
63
+ basename = File.basename(path, ext)
64
+ timestamp = Time.now.strftime('%Y%m%d_%H%M%S_%3N') # Include milliseconds
65
+
66
+ File.join(dir, "#{basename}_#{timestamp}#{ext}")
67
+ end
68
+
69
+ # Ensure output filename is unique based on overwrite option
70
+ # @param path [String] Desired output path
71
+ # @param overwrite [Boolean] If true, return original path; if false, make unique
72
+ # @return [String] Output path (unique if overwrite is false and file exists)
73
+ def ensure_unique_output(path, overwrite: false)
74
+ return path if overwrite
75
+ return path unless File.exist?(path)
76
+
77
+ unique_filename(path)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,16 @@
1
+ module RubySpriter
2
+ module Utils
3
+ module ImageHelper
4
+ def self.get_dimensions(image_path)
5
+ # Execute ImageMagick to get image dimensions
6
+ cmd = "magick identify -format \"%wx%h\" #{PathHelper.quote_path(image_path)}"
7
+ stdout, _stderr, status = Open3.capture3(cmd)
8
+
9
+ raise ProcessingError, "Failed to get image dimensions: #{image_path}" unless status.success?
10
+
11
+ dimensions = stdout.strip.split('x')
12
+ { width: dimensions[0].to_i, height: dimensions[1].to_i }
13
+ end
14
+ end
15
+ end
16
+ end