libav-ruby 0.1.0

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.
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+ require_relative "output_context"
5
+ require_relative "video_encoder"
6
+ require_relative "audio_encoder"
7
+
8
+ module LibAV
9
+ class VideoWriter
10
+ ASYNC_STOP = Object.new.freeze
11
+ QUEUE_DEPTH = 4
12
+
13
+ attr_reader :width, :height
14
+
15
+ def self.open(path, **options)
16
+ writer = new(path, **options)
17
+ return writer unless block_given?
18
+
19
+ begin
20
+ yield writer
21
+ ensure
22
+ writer.close
23
+ end
24
+ end
25
+
26
+ def initialize(path, width:, height:, fps:, async: false, audio: nil, **options)
27
+ @path = File.path(path)
28
+ @width = Integer(width)
29
+ @height = Integer(height)
30
+ @output = OutputContext.new(@path)
31
+ @encoder = VideoEncoder.new(
32
+ @output, width: @width, height: @height, fps:, **options
33
+ )
34
+ @audio_encoder = AudioEncoder.new(@output, **audio_options(audio)) if audio
35
+ @output.open!
36
+ @mutex = Mutex.new
37
+ self.async = async
38
+ rescue StandardError
39
+ close_resources
40
+ raise
41
+ end
42
+
43
+ def <<(frame)
44
+ push(frame)
45
+ end
46
+
47
+ def push(frame)
48
+ raise_if_unavailable!
49
+ rgba = normalize_frame(frame)
50
+ @async ? enqueue(rgba) : encode(rgba)
51
+ self
52
+ end
53
+
54
+ def async
55
+ @async == true
56
+ end
57
+
58
+ def async=(enabled)
59
+ enabled = !!enabled
60
+ return enabled if enabled == async
61
+ raise ClosedError, "video writer is closed" if @closed
62
+ raise ArgumentError, "async mode cannot be disabled after it has started" unless enabled
63
+
64
+ @queue = SizedQueue.new(QUEUE_DEPTH)
65
+ @async = true
66
+ @worker = Thread.new { worker_loop }
67
+ enabled
68
+ end
69
+
70
+ def push_audio(pcm_f32_interleaved)
71
+ raise_if_unavailable!
72
+ raise Error, "this writer has no audio track" unless @audio_encoder
73
+
74
+ @mutex.synchronize { @audio_encoder.push(pcm_f32_interleaved) }
75
+ self
76
+ end
77
+
78
+ def close
79
+ return if @closed
80
+
81
+ error = nil
82
+ begin
83
+ stop_worker if async
84
+ raise_worker_error!
85
+ @mutex.synchronize do
86
+ @encoder.flush
87
+ @audio_encoder&.flush
88
+ end
89
+ rescue StandardError => caught
90
+ error = caught
91
+ ensure
92
+ cleanup_error = close_resources
93
+ error ||= cleanup_error
94
+ @closed = true
95
+ end
96
+ raise error if error
97
+
98
+ nil
99
+ end
100
+
101
+ def closed?
102
+ @closed == true
103
+ end
104
+
105
+ private
106
+
107
+ def normalize_frame(frame)
108
+ frame_width, frame_height, data = frame_parts(frame)
109
+ frame_width ||= width
110
+ frame_height ||= height
111
+ raise ArgumentError, "frame dimensions must be #{width}x#{height}" unless
112
+ frame_width == width && frame_height == height
113
+ validate_pixel_metadata(frame)
114
+
115
+ data = data.to_str.b
116
+ expected = width * height * 4
117
+ raise ArgumentError, "RGBA frame must contain #{expected} bytes, got #{data.bytesize}" unless
118
+ data.bytesize == expected
119
+
120
+ data.dup
121
+ rescue NoMethodError, TypeError
122
+ raise ArgumentError, "frame must provide packed RGBA bytes"
123
+ end
124
+
125
+ def frame_parts(frame)
126
+ return [nil, nil, frame] if frame.respond_to?(:to_str)
127
+ if frame.respond_to?(:to_hash)
128
+ hash = frame.to_hash
129
+ return [
130
+ hash[:width] || hash["width"],
131
+ hash[:height] || hash["height"],
132
+ hash[:data] || hash["data"]
133
+ ]
134
+ end
135
+ return [frame.width, frame.height, frame.data] if
136
+ frame.respond_to?(:width) && frame.respond_to?(:height) && frame.respond_to?(:data)
137
+
138
+ [nil, nil, nil]
139
+ end
140
+
141
+ def validate_pixel_metadata(frame)
142
+ return unless frame.respond_to?(:channels) || frame.respond_to?(:dtype)
143
+
144
+ channels = frame.channels if frame.respond_to?(:channels)
145
+ dtype = frame.dtype if frame.respond_to?(:dtype)
146
+ raise ArgumentError, "image frames must have 4 RGBA channels" unless channels.nil? || channels == 4
147
+ raise ArgumentError, "image frames must use :u8 components" unless dtype.nil? || dtype.to_sym == :u8
148
+ end
149
+
150
+ def encode(rgba)
151
+ @mutex.synchronize { @encoder.encode(rgba) }
152
+ end
153
+
154
+ def enqueue(rgba)
155
+ loop do
156
+ raise_worker_error!
157
+ @queue.push(rgba, true)
158
+ break
159
+ rescue ThreadError
160
+ Thread.pass
161
+ end
162
+ end
163
+
164
+ def stop_worker
165
+ enqueue(ASYNC_STOP)
166
+ @worker.join
167
+ end
168
+
169
+ def worker_loop
170
+ loop do
171
+ frame = @queue.pop
172
+ break if frame.equal?(ASYNC_STOP)
173
+
174
+ encode(frame)
175
+ end
176
+ rescue StandardError => error
177
+ @worker_error = error
178
+ end
179
+
180
+ def raise_if_unavailable!
181
+ raise ClosedError, "video writer is closed" if @closed
182
+
183
+ raise_worker_error!
184
+ end
185
+
186
+ def raise_worker_error!
187
+ raise @worker_error if @worker_error
188
+ end
189
+
190
+ def close_resources
191
+ error = nil
192
+ begin
193
+ @output&.close
194
+ rescue StandardError => caught
195
+ error = caught
196
+ ensure
197
+ @encoder&.close
198
+ @audio_encoder&.close
199
+ end
200
+ error
201
+ end
202
+
203
+ def audio_options(audio)
204
+ if audio.respond_to?(:transform_keys)
205
+ options = audio.transform_keys(&:to_sym)
206
+ options[:codec] ||= :opus if File.extname(@path).downcase == ".webm"
207
+ return options
208
+ end
209
+
210
+ raise ArgumentError, "audio must be a Hash of encoder options"
211
+ end
212
+ end
213
+ end
data/lib/libav.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require_relative "libav/version"
5
+ require_relative "libav/errors"
6
+ require_relative "libav/native"
7
+ require_relative "libav/video_frame"
8
+ require_relative "libav/texel_support"
9
+ require_relative "libav/video_writer"
10
+ require_relative "libav/video_reader"
11
+ require_relative "libav/audio_frame"
12
+ require_relative "libav/audio_writer"
13
+ require_relative "libav/audio_reader"
14
+
15
+ module LibAV
16
+ class << self
17
+ attr_accessor :logger
18
+ attr_reader :log_level
19
+
20
+ def native_versions
21
+ Native.versions.dup
22
+ end
23
+
24
+ def log_level=(level)
25
+ Native.log_level = level
26
+ @log_level = level.respond_to?(:to_sym) ? level.to_sym : level
27
+ end
28
+ end
29
+ end
30
+
31
+ LibAV::Native.load!
32
+ LibAV.logger = Logger.new($stderr)
33
+ LibAV.logger.level = Logger::WARN
34
+ LibAV.log_level = :warn
35
+ LibAV::Native.install_log_callback!
@@ -0,0 +1,72 @@
1
+ #include <stddef.h>
2
+ #include <stdio.h>
3
+
4
+ #include <libavcodec/avcodec.h>
5
+ #include <libavcodec/packet.h>
6
+ #include <libavformat/avformat.h>
7
+ #include <libavutil/channel_layout.h>
8
+ #include <libavutil/frame.h>
9
+
10
+ #define FIELD(type, field) \
11
+ printf("%s.%s=%zu\n", #type, #field, offsetof(type, field))
12
+
13
+ int main(void)
14
+ {
15
+ printf("pointer_size=%zu\n", sizeof(void *));
16
+ printf("channel_layout_size=%zu\n", sizeof(AVChannelLayout));
17
+
18
+ FIELD(AVOutputFormat, flags);
19
+
20
+ FIELD(AVFormatContext, oformat);
21
+ FIELD(AVFormatContext, pb);
22
+ FIELD(AVFormatContext, nb_streams);
23
+ FIELD(AVFormatContext, streams);
24
+ FIELD(AVFormatContext, duration);
25
+
26
+ FIELD(AVStream, index);
27
+ FIELD(AVStream, time_base);
28
+ FIELD(AVStream, start_time);
29
+ FIELD(AVStream, duration);
30
+ FIELD(AVStream, nb_frames);
31
+ FIELD(AVStream, r_frame_rate);
32
+ FIELD(AVStream, avg_frame_rate);
33
+ FIELD(AVStream, codecpar);
34
+
35
+ FIELD(AVCodecContext, codec_type);
36
+ FIELD(AVCodecContext, priv_data);
37
+ FIELD(AVCodecContext, bit_rate);
38
+ FIELD(AVCodecContext, flags);
39
+ FIELD(AVCodecContext, time_base);
40
+ FIELD(AVCodecContext, width);
41
+ FIELD(AVCodecContext, height);
42
+ FIELD(AVCodecContext, gop_size);
43
+ FIELD(AVCodecContext, pix_fmt);
44
+ FIELD(AVCodecContext, sample_rate);
45
+ FIELD(AVCodecContext, sample_fmt);
46
+ FIELD(AVCodecContext, frame_size);
47
+ FIELD(AVCodecContext, framerate);
48
+ FIELD(AVCodecContext, ch_layout);
49
+
50
+ FIELD(AVFrame, data);
51
+ FIELD(AVFrame, linesize);
52
+ FIELD(AVFrame, extended_data);
53
+ FIELD(AVFrame, width);
54
+ FIELD(AVFrame, height);
55
+ FIELD(AVFrame, nb_samples);
56
+ FIELD(AVFrame, format);
57
+ FIELD(AVFrame, pts);
58
+ FIELD(AVFrame, best_effort_timestamp);
59
+ FIELD(AVFrame, duration);
60
+ FIELD(AVFrame, sample_rate);
61
+ FIELD(AVFrame, ch_layout);
62
+
63
+ FIELD(AVPacket, pts);
64
+ FIELD(AVPacket, dts);
65
+ FIELD(AVPacket, data);
66
+ FIELD(AVPacket, size);
67
+ FIELD(AVPacket, stream_index);
68
+ FIELD(AVPacket, duration);
69
+ FIELD(AVPacket, time_base);
70
+
71
+ return 0;
72
+ }
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "optparse"
6
+ require "rbconfig"
7
+ require "tmpdir"
8
+
9
+ TYPE_NAMES = {
10
+ "AVOutputFormat" => :output_format,
11
+ "AVFormatContext" => :format_context,
12
+ "AVStream" => :stream,
13
+ "AVCodecContext" => :codec_context,
14
+ "AVFrame" => :frame,
15
+ "AVPacket" => :packet
16
+ }.freeze
17
+
18
+ options = {}
19
+ OptionParser.new do |parser|
20
+ parser.banner = "Usage: ruby native/generate_layout.rb [options]"
21
+ parser.on("--codec-major MAJOR", Integer, "libavcodec major (60 or 61)") do |value|
22
+ options[:major] = value
23
+ end
24
+ parser.on("--include DIRECTORY", "FFmpeg include directory") do |value|
25
+ options[:include] = File.expand_path(value)
26
+ end
27
+ parser.on("--output FILE", "Ruby layout file to write") do |value|
28
+ options[:output] = File.expand_path(value)
29
+ end
30
+ parser.on("--verify", "verify that the existing layout matches the native headers") do
31
+ options[:verify] = true
32
+ end
33
+ end.parse!
34
+
35
+ major = options.fetch(:major) { abort "missing --codec-major" }
36
+ abort "codec major must be 60 or 61" unless [60, 61].include?(major)
37
+ include_directory = options.fetch(:include) { abort "missing --include" }
38
+ output = options.fetch(
39
+ :output,
40
+ File.expand_path("../lib/libav/native/v#{major}/layout.rb", __dir__)
41
+ )
42
+ probe = File.expand_path("abi_probe.c", __dir__)
43
+ compiler = ENV.fetch("CC", RbConfig::CONFIG.fetch("CC", "cc"))
44
+
45
+ raw = Dir.mktmpdir("libav-abi") do |directory|
46
+ binary = File.join(directory, "abi_probe")
47
+ command = [compiler, "-I#{include_directory}", probe, "-o", binary]
48
+ _stdout, stderr, status = Open3.capture3(*command)
49
+ abort "ABI probe compilation failed:\n#{stderr}" unless status.success?
50
+
51
+ stdout, run_stderr, run_status = Open3.capture3(binary)
52
+ abort "ABI probe failed:\n#{run_stderr}" unless run_status.success?
53
+
54
+ stdout
55
+ end
56
+
57
+ metadata = {}
58
+ values = Hash.new { |hash, key| hash[key] = {} }
59
+ raw.each_line do |line|
60
+ name, offset = line.strip.split("=", 2)
61
+ unless name.include?(".")
62
+ metadata[name] = Integer(offset)
63
+ next
64
+ end
65
+
66
+ native_type, field = name.split(".", 2)
67
+ type = TYPE_NAMES.fetch(native_type)
68
+ values[type][field.to_sym] = Integer(offset)
69
+ end
70
+ abort "ABI probe requires 64-bit pointers" unless metadata.fetch("pointer_size") == 8
71
+ abort "unexpected AVChannelLayout size" unless metadata.fetch("channel_layout_size") == 24
72
+
73
+ body = values.map do |type, fields|
74
+ entries = fields.map { |field, offset| "#{field}: #{offset}" }.join(", ")
75
+ " #{type}: {#{entries}}"
76
+ end.join(",\n")
77
+
78
+ source = <<~RUBY
79
+ # frozen_string_literal: true
80
+
81
+ module LibAV
82
+ module Native
83
+ module Layout
84
+ V#{major} = {
85
+ #{body}
86
+ }.freeze
87
+ end
88
+ end
89
+ end
90
+ RUBY
91
+
92
+ if options[:verify]
93
+ load output
94
+ generated = LibAV::Native::Layout.const_get(:"V#{major}")
95
+ expected = values.transform_values(&:to_h).to_h
96
+ abort "ABI layout mismatch: regenerate #{output}" unless generated == expected
97
+
98
+ puts "ABI layout verified: #{output}"
99
+ else
100
+ FileUtils.mkdir_p(File.dirname(output))
101
+ File.write(output, source)
102
+ puts output
103
+ end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: libav-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ffi
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.17'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.17'
26
+ description: Encode and decode video and audio frames through FFmpeg shared libraries
27
+ without spawning subprocesses.
28
+ email:
29
+ - t.yudai92@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - LICENSE.txt
35
+ - README.md
36
+ - Rakefile
37
+ - examples/frames_to_mp4.rb
38
+ - examples/stagecraft_offscreen_to_mp4.rb
39
+ - examples/vizcore_scene_to_mp4.rb
40
+ - lib/libav.rb
41
+ - lib/libav/audio_encoder.rb
42
+ - lib/libav/audio_frame.rb
43
+ - lib/libav/audio_reader.rb
44
+ - lib/libav/audio_writer.rb
45
+ - lib/libav/errors.rb
46
+ - lib/libav/native.rb
47
+ - lib/libav/native/layout.rb
48
+ - lib/libav/native/v60/layout.rb
49
+ - lib/libav/native/v61/layout.rb
50
+ - lib/libav/output_context.rb
51
+ - lib/libav/texel_support.rb
52
+ - lib/libav/version.rb
53
+ - lib/libav/video_encoder.rb
54
+ - lib/libav/video_frame.rb
55
+ - lib/libav/video_reader.rb
56
+ - lib/libav/video_writer.rb
57
+ - native/abi_probe.c
58
+ - native/generate_layout.rb
59
+ homepage: https://github.com/ydah/libav-ruby
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ homepage_uri: https://github.com/ydah/libav-ruby
64
+ rubygems_mfa_required: 'true'
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 3.2.0
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 4.0.6
80
+ specification_version: 4
81
+ summary: Frame-level Ruby bindings for FFmpeg
82
+ test_files: []