ruby_spriter 0.6.5 → 0.6.7

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.
@@ -51,6 +51,31 @@ module RubySpriter
51
51
  validate_exists!(path)
52
52
  raise ValidationError, "File not readable: #{path}" unless File.readable?(path)
53
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
54
79
  end
55
80
  end
56
81
  end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+
6
+ module RubySpriter
7
+ module Utils
8
+ # Splits a spritesheet into individual frame images
9
+ class SpritesheetSplitter
10
+ # Split spritesheet into individual frames
11
+ # @param spritesheet_file [String] Path to spritesheet PNG
12
+ # @param output_dir [String] Directory to save individual frames
13
+ # @param columns [Integer] Number of columns in grid
14
+ # @param rows [Integer] Number of rows in grid
15
+ # @param frames [Integer] Total number of frames to extract
16
+ def split_into_frames(spritesheet_file, output_dir, columns, rows, frames)
17
+ FileUtils.mkdir_p(output_dir)
18
+
19
+ OutputFormatter.header("Extracting Frames")
20
+ OutputFormatter.indent("Splitting spritesheet into #{frames} frames to disk...")
21
+ OutputFormatter.indent("Output directory: #{output_dir}")
22
+
23
+ # Get spritesheet dimensions
24
+ dimensions = get_image_dimensions(spritesheet_file)
25
+ tile_width = dimensions[:width] / columns
26
+ tile_height = dimensions[:height] / rows
27
+
28
+ # Extract each frame
29
+ spritesheet_basename = File.basename(spritesheet_file, '.*')
30
+
31
+ frames.times do |i|
32
+ frame_number = i + 1
33
+ row = i / columns
34
+ col = i % columns
35
+
36
+ x_offset = col * tile_width
37
+ y_offset = row * tile_height
38
+
39
+ frame_filename = "FR#{format('%03d', frame_number)}_#{spritesheet_basename}.png"
40
+ frame_path = File.join(output_dir, frame_filename)
41
+
42
+ extract_tile(spritesheet_file, frame_path, tile_width, tile_height, x_offset, y_offset)
43
+ end
44
+
45
+ OutputFormatter.indent("✅ Frames extracted successfully\n")
46
+ end
47
+
48
+ private
49
+
50
+ def get_image_dimensions(image_file)
51
+ cmd = [
52
+ 'magick',
53
+ 'identify',
54
+ '-format', '%wx%h',
55
+ PathHelper.quote_path(image_file)
56
+ ].join(' ')
57
+
58
+ stdout, stderr, status = Open3.capture3(cmd)
59
+
60
+ unless status.success?
61
+ raise ProcessingError, "Could not get image dimensions: #{stderr}"
62
+ end
63
+
64
+ width, height = stdout.strip.split('x').map(&:to_i)
65
+ { width: width, height: height }
66
+ end
67
+
68
+ def extract_tile(source_file, output_file, width, height, x_offset, y_offset)
69
+ cmd = [
70
+ 'magick',
71
+ 'convert',
72
+ PathHelper.quote_path(source_file),
73
+ '-crop', "#{width}x#{height}+#{x_offset}+#{y_offset}",
74
+ '+repage',
75
+ PathHelper.quote_path(output_file)
76
+ ].join(' ')
77
+
78
+ stdout, stderr, status = Open3.capture3(cmd)
79
+
80
+ unless status.success?
81
+ raise ProcessingError, "Could not extract frame: #{stderr}"
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubySpriter
4
- VERSION = '0.6.5'
5
- VERSION_DATE = '2025-10-23'
4
+ VERSION = '0.6.7'
5
+ VERSION_DATE = '2025-10-24'
6
6
  METADATA_VERSION = '0.6'
7
7
  end
@@ -27,26 +27,26 @@ module RubySpriter
27
27
  rows = (frame_count.to_f / columns).ceil
28
28
 
29
29
  Utils::OutputFormatter.header("Creating Spritesheet")
30
-
30
+
31
31
  temp_file = output_file.sub('.png', '_temp.png')
32
-
32
+
33
33
  create_with_ffmpeg(video_file, temp_file, duration, columns, rows, frame_count)
34
-
34
+
35
35
  # Embed metadata
36
36
  MetadataManager.embed(
37
- temp_file,
37
+ temp_file,
38
38
  output_file,
39
39
  columns: columns,
40
40
  rows: rows,
41
41
  frames: frame_count,
42
42
  debug: options[:debug]
43
43
  )
44
-
44
+
45
45
  # Clean up temp file
46
46
  File.delete(temp_file) if File.exist?(temp_file)
47
-
47
+
48
48
  file_size = File.size(output_file)
49
-
49
+
50
50
  # Display results with Godot instructions
51
51
  display_spritesheet_results(output_file, file_size, columns, rows, frame_count)
52
52
 
data/lib/ruby_spriter.rb CHANGED
@@ -15,6 +15,7 @@ require_relative 'ruby_spriter/version'
15
15
  require_relative 'ruby_spriter/utils/path_helper'
16
16
  require_relative 'ruby_spriter/utils/file_helper'
17
17
  require_relative 'ruby_spriter/utils/output_formatter'
18
+ require_relative 'ruby_spriter/utils/spritesheet_splitter'
18
19
 
19
20
  # Load core components
20
21
  require_relative 'ruby_spriter/platform'
@@ -25,6 +26,8 @@ require_relative 'ruby_spriter/metadata_manager'
25
26
  require_relative 'ruby_spriter/video_processor'
26
27
  require_relative 'ruby_spriter/gimp_processor'
27
28
  require_relative 'ruby_spriter/consolidator'
29
+ require_relative 'ruby_spriter/compression_manager'
30
+ require_relative 'ruby_spriter/batch_processor'
28
31
 
29
32
  # Load orchestration
30
33
  require_relative 'ruby_spriter/processor'
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubySpriter::BatchProcessor do
6
+ let(:test_dir) { 'E:/test/videos' }
7
+ let(:output_dir) { 'E:/test/output' }
8
+ let(:video1) { File.join(test_dir, 'video1.mp4') }
9
+ let(:video2) { File.join(test_dir, 'video2.mp4') }
10
+ let(:video3) { File.join(test_dir, 'video3.mp4') }
11
+
12
+ let(:options) do
13
+ {
14
+ batch: true,
15
+ dir: test_dir,
16
+ columns: 4,
17
+ frame_count: 16,
18
+ max_width: 320,
19
+ overwrite: false,
20
+ debug: false
21
+ }
22
+ end
23
+
24
+ before do
25
+ # Mock file system
26
+ allow(Dir).to receive(:exist?).and_return(true)
27
+ allow(File).to receive(:exist?).and_return(true)
28
+ allow(File).to receive(:directory?).with(test_dir).and_return(true)
29
+ allow(Dir).to receive(:glob).with(File.join(test_dir, '*.mp4')).and_return([video1, video2, video3])
30
+ end
31
+
32
+ describe '#initialize' do
33
+ it 'raises error if directory does not exist' do
34
+ allow(File).to receive(:directory?).with(test_dir).and_return(false)
35
+
36
+ expect {
37
+ described_class.new(options)
38
+ }.to raise_error(RubySpriter::ValidationError, /Directory not found/)
39
+ end
40
+
41
+ it 'initializes with valid options' do
42
+ processor = described_class.new(options)
43
+ expect(processor.options).to eq(options)
44
+ end
45
+ end
46
+
47
+ describe '#find_videos' do
48
+ it 'finds all MP4 files in directory' do
49
+ processor = described_class.new(options)
50
+ videos = processor.find_videos
51
+
52
+ expect(videos).to eq([video1, video2, video3])
53
+ end
54
+
55
+ it 'raises error if no videos found' do
56
+ allow(Dir).to receive(:glob).with(File.join(test_dir, '*.mp4')).and_return([])
57
+
58
+ processor = described_class.new(options)
59
+
60
+ expect {
61
+ processor.find_videos
62
+ }.to raise_error(RubySpriter::ValidationError, /No MP4 files found/)
63
+ end
64
+ end
65
+
66
+ describe '#process' do
67
+ let(:video_processor_mock) { instance_double(RubySpriter::VideoProcessor) }
68
+
69
+ before do
70
+ allow(RubySpriter::VideoProcessor).to receive(:new).and_return(video_processor_mock)
71
+ allow(video_processor_mock).to receive(:create_spritesheet).and_return(
72
+ { output_file: 'output.png', columns: 4, rows: 4, frames: 16 }
73
+ )
74
+ allow(File).to receive(:exist?).and_return(false) # No existing files
75
+ allow(RubySpriter::Utils::FileHelper).to receive(:spritesheet_filename) do |video|
76
+ video.gsub('.mp4', '_spritesheet.png')
77
+ end
78
+ end
79
+
80
+ it 'processes all videos in directory' do
81
+ processor = described_class.new(options)
82
+
83
+ expect(video_processor_mock).to receive(:create_spritesheet).exactly(3).times
84
+
85
+ results = processor.process
86
+
87
+ expect(results[:processed_count]).to eq(3)
88
+ expect(results[:outputs].length).to eq(3)
89
+ end
90
+
91
+ it 'outputs to same directory by default' do
92
+ processor = described_class.new(options)
93
+
94
+ expected_output1 = File.join(test_dir, 'video1_spritesheet.png')
95
+ expect(video_processor_mock).to receive(:create_spritesheet).with(video1, expected_output1)
96
+
97
+ processor.process
98
+ end
99
+
100
+ it 'outputs to specified outputdir when provided' do
101
+ options_with_output = options.merge(outputdir: output_dir)
102
+ processor = described_class.new(options_with_output)
103
+
104
+ allow(File).to receive(:directory?).with(output_dir).and_return(true)
105
+
106
+ expected_output1 = File.join(output_dir, 'video1_spritesheet.png')
107
+ expect(video_processor_mock).to receive(:create_spritesheet).with(video1, expected_output1)
108
+
109
+ processor.process
110
+ end
111
+
112
+ it 'creates output directory if it does not exist' do
113
+ options_with_output = options.merge(outputdir: output_dir)
114
+ processor = described_class.new(options_with_output)
115
+
116
+ allow(File).to receive(:directory?).with(output_dir).and_return(false)
117
+ expect(FileUtils).to receive(:mkdir_p).with(output_dir)
118
+
119
+ processor.process
120
+ end
121
+
122
+ it 'enforces unique filenames unless overwrite is true' do
123
+ allow(File).to receive(:exist?).with(/video1_spritesheet\.png/).and_return(true)
124
+ allow(RubySpriter::Utils::FileHelper).to receive(:ensure_unique_output).and_call_original
125
+ allow(RubySpriter::Utils::FileHelper).to receive(:unique_filename).and_return('video1_spritesheet_20251024_123456_789.png')
126
+
127
+ processor = described_class.new(options)
128
+
129
+ expect(RubySpriter::Utils::FileHelper).to receive(:ensure_unique_output).at_least(:once)
130
+
131
+ processor.process
132
+ end
133
+
134
+ it 'continues processing after error in one video' do
135
+ processor = described_class.new(options)
136
+
137
+ allow(video_processor_mock).to receive(:create_spritesheet).and_raise(RubySpriter::ProcessingError, 'Test error')
138
+
139
+ results = processor.process
140
+
141
+ expect(results[:processed_count]).to eq(0)
142
+ expect(results[:errors].length).to eq(3)
143
+ end
144
+ end
145
+
146
+ describe '#consolidate_results' do
147
+ let(:spritesheet1) { File.join(test_dir, 'video1_spritesheet.png') }
148
+ let(:spritesheet2) { File.join(test_dir, 'video2_spritesheet.png') }
149
+ let(:spritesheet3) { File.join(test_dir, 'video3_spritesheet.png') }
150
+ let(:consolidator_mock) { instance_double(RubySpriter::Consolidator) }
151
+
152
+ before do
153
+ allow(RubySpriter::Consolidator).to receive(:new).and_return(consolidator_mock)
154
+ allow(consolidator_mock).to receive(:consolidate).and_return(
155
+ { output_file: 'consolidated.png', columns: 4, rows: 12, frames: 48 }
156
+ )
157
+ end
158
+
159
+ it 'consolidates all spritesheets when batch_consolidate is true' do
160
+ options_with_consolidate = options.merge(batch_consolidate: true)
161
+ processor = described_class.new(options_with_consolidate)
162
+
163
+ outputs = [spritesheet1, spritesheet2, spritesheet3]
164
+
165
+ expect(consolidator_mock).to receive(:consolidate) do |files, output|
166
+ expect(files).to eq(outputs)
167
+ expect(output).to include('batch_consolidated_spritesheet')
168
+ expect(output).to end_with('.png')
169
+ end
170
+
171
+ processor.consolidate_results(outputs)
172
+ end
173
+
174
+ it 'uses outputdir for consolidated file if specified' do
175
+ options_with_consolidate = options.merge(batch_consolidate: true, outputdir: output_dir)
176
+ processor = described_class.new(options_with_consolidate)
177
+
178
+ allow(File).to receive(:directory?).with(output_dir).and_return(true)
179
+
180
+ outputs = [spritesheet1, spritesheet2, spritesheet3]
181
+
182
+ expect(consolidator_mock).to receive(:consolidate) do |files, output|
183
+ expect(output).to start_with(output_dir)
184
+ end
185
+
186
+ processor.consolidate_results(outputs)
187
+ end
188
+
189
+ it 'does not consolidate when batch_consolidate is false' do
190
+ processor = described_class.new(options)
191
+
192
+ outputs = [spritesheet1, spritesheet2, spritesheet3]
193
+
194
+ expect(consolidator_mock).not_to receive(:consolidate)
195
+
196
+ result = processor.consolidate_results(outputs)
197
+ expect(result).to be_nil
198
+ end
199
+ end
200
+ end