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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +188 -0
- data/README.md +374 -33
- data/lib/ruby_spriter/batch_processor.rb +212 -0
- data/lib/ruby_spriter/cli.rb +369 -6
- data/lib/ruby_spriter/compression_manager.rb +101 -0
- data/lib/ruby_spriter/consolidator.rb +33 -0
- data/lib/ruby_spriter/gimp_processor.rb +6 -3
- data/lib/ruby_spriter/processor.rb +661 -26
- data/lib/ruby_spriter/utils/file_helper.rb +25 -0
- data/lib/ruby_spriter/utils/spritesheet_splitter.rb +86 -0
- data/lib/ruby_spriter/version.rb +2 -2
- data/lib/ruby_spriter/video_processor.rb +7 -7
- data/lib/ruby_spriter.rb +3 -0
- data/spec/ruby_spriter/batch_processor_spec.rb +200 -0
- data/spec/ruby_spriter/cli_spec.rb +750 -0
- data/spec/ruby_spriter/compression_manager_spec.rb +157 -0
- data/spec/ruby_spriter/consolidator_spec.rb +163 -0
- data/spec/ruby_spriter/processor_spec.rb +735 -0
- data/spec/ruby_spriter/utils/file_helper_spec.rb +80 -1
- data/spec/ruby_spriter/utils/spritesheet_splitter_spec.rb +104 -0
- data/spec/ruby_spriter/video_processor_spec.rb +29 -0
- metadata +8 -2
|
@@ -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
|
data/lib/ruby_spriter/version.rb
CHANGED
|
@@ -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
|