d-streamio-ffmpeg 3.0.3
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 +7 -0
- data/CHANGELOG +263 -0
- data/LICENSE +20 -0
- data/README.md +286 -0
- data/lib/ffmpeg/encoding_options.rb +200 -0
- data/lib/ffmpeg/errors.rb +6 -0
- data/lib/ffmpeg/io_monkey.rb +42 -0
- data/lib/ffmpeg/movie.rb +257 -0
- data/lib/ffmpeg/transcoder.rb +141 -0
- data/lib/ffmpeg/version.rb +3 -0
- data/lib/streamio-ffmpeg.rb +108 -0
- metadata +95 -0
@@ -0,0 +1,200 @@
|
|
1
|
+
module FFMPEG
|
2
|
+
class EncodingOptions < Hash
|
3
|
+
def initialize(options = {})
|
4
|
+
merge!(options)
|
5
|
+
end
|
6
|
+
|
7
|
+
def params_order(k)
|
8
|
+
if k =~ /watermark$/
|
9
|
+
0
|
10
|
+
elsif k =~ /watermark/
|
11
|
+
1
|
12
|
+
elsif k =~ /codec/
|
13
|
+
2
|
14
|
+
elsif k =~ /preset/
|
15
|
+
3
|
16
|
+
else
|
17
|
+
4
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_a
|
22
|
+
params = []
|
23
|
+
|
24
|
+
# codecs should go before the presets so that the files will be matched successfully
|
25
|
+
# all other parameters go after so that we can override whatever is in the preset
|
26
|
+
keys.sort_by{|k| params_order(k) }.each do |key|
|
27
|
+
|
28
|
+
value = self[key]
|
29
|
+
a = send("convert_#{key}", value) if value && supports_option?(key)
|
30
|
+
params += a unless a.nil?
|
31
|
+
end
|
32
|
+
|
33
|
+
params += convert_aspect(calculate_aspect) if calculate_aspect?
|
34
|
+
params.map(&:to_s)
|
35
|
+
end
|
36
|
+
|
37
|
+
def width
|
38
|
+
self[:resolution].split("x").first.to_i rescue nil
|
39
|
+
end
|
40
|
+
|
41
|
+
def height
|
42
|
+
self[:resolution].split("x").last.to_i rescue nil
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
def supports_option?(option)
|
47
|
+
option = RUBY_VERSION < "1.9" ? "convert_#{option}" : "convert_#{option}".to_sym
|
48
|
+
private_methods.include?(option)
|
49
|
+
end
|
50
|
+
|
51
|
+
def convert_aspect(value)
|
52
|
+
["-aspect", value]
|
53
|
+
end
|
54
|
+
|
55
|
+
def calculate_aspect
|
56
|
+
width, height = self[:resolution].split("x")
|
57
|
+
width.to_f / height.to_f
|
58
|
+
end
|
59
|
+
|
60
|
+
def calculate_aspect?
|
61
|
+
self[:aspect].nil? && self[:resolution]
|
62
|
+
end
|
63
|
+
|
64
|
+
def convert_video_codec(value)
|
65
|
+
["-vcodec", value]
|
66
|
+
end
|
67
|
+
|
68
|
+
def convert_frame_rate(value)
|
69
|
+
["-r", value]
|
70
|
+
end
|
71
|
+
|
72
|
+
def convert_resolution(value)
|
73
|
+
["-s", value]
|
74
|
+
end
|
75
|
+
|
76
|
+
def convert_video_bitrate(value)
|
77
|
+
["-b:v", k_format(value)]
|
78
|
+
end
|
79
|
+
|
80
|
+
def convert_audio_codec(value)
|
81
|
+
["-acodec", value]
|
82
|
+
end
|
83
|
+
|
84
|
+
def convert_audio_bitrate(value)
|
85
|
+
["-b:a", k_format(value)]
|
86
|
+
end
|
87
|
+
|
88
|
+
def convert_audio_sample_rate(value)
|
89
|
+
["-ar", value]
|
90
|
+
end
|
91
|
+
|
92
|
+
def convert_audio_channels(value)
|
93
|
+
["-ac", value]
|
94
|
+
end
|
95
|
+
|
96
|
+
def convert_video_max_bitrate(value)
|
97
|
+
["-maxrate", k_format(value)]
|
98
|
+
end
|
99
|
+
|
100
|
+
def convert_video_min_bitrate(value)
|
101
|
+
["-minrate", k_format(value)]
|
102
|
+
end
|
103
|
+
|
104
|
+
def convert_buffer_size(value)
|
105
|
+
["-bufsize", k_format(value)]
|
106
|
+
end
|
107
|
+
|
108
|
+
def convert_video_bitrate_tolerance(value)
|
109
|
+
["-bt", k_format(value)]
|
110
|
+
end
|
111
|
+
|
112
|
+
def convert_threads(value)
|
113
|
+
["-threads", value]
|
114
|
+
end
|
115
|
+
|
116
|
+
def convert_target(value)
|
117
|
+
['-target', value]
|
118
|
+
end
|
119
|
+
|
120
|
+
def convert_duration(value)
|
121
|
+
["-t", value]
|
122
|
+
end
|
123
|
+
|
124
|
+
def convert_video_preset(value)
|
125
|
+
["-vpre", value]
|
126
|
+
end
|
127
|
+
|
128
|
+
def convert_audio_preset(value)
|
129
|
+
["-apre", value]
|
130
|
+
end
|
131
|
+
|
132
|
+
def convert_file_preset(value)
|
133
|
+
["-fpre", value]
|
134
|
+
end
|
135
|
+
|
136
|
+
def convert_keyframe_interval(value)
|
137
|
+
["-g", value]
|
138
|
+
end
|
139
|
+
|
140
|
+
def convert_seek_time(value)
|
141
|
+
["-ss", value]
|
142
|
+
end
|
143
|
+
|
144
|
+
def convert_screenshot(value)
|
145
|
+
result = []
|
146
|
+
unless self[:vframes]
|
147
|
+
result << '-vframes'
|
148
|
+
result << 1
|
149
|
+
end
|
150
|
+
result << '-f'
|
151
|
+
result << 'image2'
|
152
|
+
value ? result : []
|
153
|
+
end
|
154
|
+
|
155
|
+
def convert_quality(value)
|
156
|
+
['-q:v', value]
|
157
|
+
end
|
158
|
+
|
159
|
+
def convert_vframes(value)
|
160
|
+
['-vframes', value]
|
161
|
+
end
|
162
|
+
|
163
|
+
def convert_x264_vprofile(value)
|
164
|
+
["-vprofile", value]
|
165
|
+
end
|
166
|
+
|
167
|
+
def convert_x264_preset(value)
|
168
|
+
["-preset", value]
|
169
|
+
end
|
170
|
+
|
171
|
+
def convert_watermark(value)
|
172
|
+
["-i", value]
|
173
|
+
end
|
174
|
+
|
175
|
+
def convert_watermark_filter(value)
|
176
|
+
position = value[:position]
|
177
|
+
padding_x = value[:padding_x] || 10
|
178
|
+
padding_y = value[:padding_y] || 10
|
179
|
+
case position.to_s
|
180
|
+
when "LT"
|
181
|
+
["-filter_complex", "scale=#{self[:resolution]},overlay=x=#{padding_x}:y=#{padding_y}"]
|
182
|
+
when "RT"
|
183
|
+
["-filter_complex", "scale=#{self[:resolution]},overlay=x=main_w-overlay_w-#{padding_x}:y=#{padding_y}"]
|
184
|
+
when "LB"
|
185
|
+
["-filter_complex", "scale=#{self[:resolution]},overlay=x=#{padding_x}:y=main_h-overlay_h-#{padding_y}"]
|
186
|
+
when "RB"
|
187
|
+
["-filter_complex", "scale=#{self[:resolution]},overlay=x=main_w-overlay_w-#{padding_x}:y=main_h-overlay_h-#{padding_y}"]
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
def convert_custom(value)
|
192
|
+
raise ArgumentError unless value.class <= Array
|
193
|
+
value
|
194
|
+
end
|
195
|
+
|
196
|
+
def k_format(value)
|
197
|
+
value.to_s.include?("k") ? value : "#{value}k"
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'timeout'
|
2
|
+
require 'thread'
|
3
|
+
if RUBY_PLATFORM =~ /(win|w)(32|64)$/
|
4
|
+
begin
|
5
|
+
require 'win32/process'
|
6
|
+
rescue LoadError
|
7
|
+
"Warning: streamio-ffmpeg is missing the win32-process gem to properly handle hung transcodings. Install the gem (in Gemfile if using bundler) to avoid errors."
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
#
|
12
|
+
# Monkey Patch timeout support into the IO class
|
13
|
+
#
|
14
|
+
class IO
|
15
|
+
def each_with_timeout(pid, seconds, sep_string=$/)
|
16
|
+
last_update = Time.now
|
17
|
+
|
18
|
+
current_thread = Thread.current
|
19
|
+
check_update_thread = Thread.new do
|
20
|
+
loop do
|
21
|
+
sleep 0.1
|
22
|
+
if last_update - Time.now < -seconds
|
23
|
+
current_thread.raise Timeout::Error.new('output wait time expired')
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
each(sep_string) do |buffer|
|
29
|
+
last_update = Time.now
|
30
|
+
yield buffer
|
31
|
+
end
|
32
|
+
rescue Timeout::Error
|
33
|
+
if RUBY_PLATFORM =~ /(win|w)(32|64)$/
|
34
|
+
Process.kill(1, pid)
|
35
|
+
else
|
36
|
+
Process.kill('SIGKILL', pid)
|
37
|
+
end
|
38
|
+
raise
|
39
|
+
ensure
|
40
|
+
check_update_thread.kill
|
41
|
+
end
|
42
|
+
end
|
data/lib/ffmpeg/movie.rb
ADDED
@@ -0,0 +1,257 @@
|
|
1
|
+
require 'time'
|
2
|
+
require 'multi_json'
|
3
|
+
require 'uri'
|
4
|
+
|
5
|
+
module FFMPEG
|
6
|
+
class Movie
|
7
|
+
attr_reader :path, :duration, :time, :bitrate, :rotation, :creation_time
|
8
|
+
attr_reader :video_stream, :video_codec, :video_bitrate, :colorspace, :width, :height, :sar, :dar, :frame_rate
|
9
|
+
attr_reader :audio_streams, :audio_stream, :audio_codec, :audio_bitrate, :audio_sample_rate, :audio_channels, :audio_tags, :audio_sample_fmt
|
10
|
+
attr_reader :container
|
11
|
+
attr_reader :metadata, :format_tags
|
12
|
+
|
13
|
+
UNSUPPORTED_CODEC_PATTERN = /^Unsupported codec with id (\d+) for input stream (\d+)$/
|
14
|
+
|
15
|
+
def initialize(path)
|
16
|
+
@path = path
|
17
|
+
|
18
|
+
if remote?
|
19
|
+
@head = head
|
20
|
+
raise Errno::ENOENT, "the URL '#{path}' does not exist" unless @head.is_a?(Net::HTTPSuccess)
|
21
|
+
else
|
22
|
+
raise Errno::ENOENT, "the file '#{path}' does not exist" unless File.exist?(path)
|
23
|
+
end
|
24
|
+
|
25
|
+
@path = path
|
26
|
+
|
27
|
+
# ffmpeg will output to stderr
|
28
|
+
command = [FFMPEG.ffprobe_binary, '-i', path, *%w(-print_format json -show_format -show_streams -show_error)]
|
29
|
+
std_output = ''
|
30
|
+
std_error = ''
|
31
|
+
|
32
|
+
Open3.popen3(*command) do |stdin, stdout, stderr|
|
33
|
+
std_output = stdout.read unless stdout.nil?
|
34
|
+
std_error = stderr.read unless stderr.nil?
|
35
|
+
end
|
36
|
+
|
37
|
+
fix_encoding(std_output)
|
38
|
+
fix_encoding(std_error)
|
39
|
+
|
40
|
+
begin
|
41
|
+
@metadata = MultiJson.load(std_output, symbolize_keys: true)
|
42
|
+
rescue MultiJson::ParseError
|
43
|
+
raise "Could not parse output from FFProbe:\n#{ std_output }"
|
44
|
+
end
|
45
|
+
|
46
|
+
if @metadata.key?(:error)
|
47
|
+
|
48
|
+
@duration = 0
|
49
|
+
|
50
|
+
else
|
51
|
+
video_streams = @metadata[:streams].select { |stream| stream.key?(:codec_type) and stream[:codec_type] === 'video' }
|
52
|
+
audio_streams = @metadata[:streams].select { |stream| stream.key?(:codec_type) and stream[:codec_type] === 'audio' }
|
53
|
+
|
54
|
+
@container = @metadata[:format][:format_name]
|
55
|
+
|
56
|
+
@duration = @metadata[:format][:duration].to_f
|
57
|
+
|
58
|
+
@time = @metadata[:format][:start_time].to_f
|
59
|
+
|
60
|
+
@format_tags = @metadata[:format][:tags]
|
61
|
+
|
62
|
+
@creation_time = if @format_tags and @format_tags.key?(:creation_time)
|
63
|
+
begin
|
64
|
+
Time.parse(@format_tags[:creation_time])
|
65
|
+
rescue ArgumentError
|
66
|
+
nil
|
67
|
+
end
|
68
|
+
else
|
69
|
+
nil
|
70
|
+
end
|
71
|
+
|
72
|
+
@bitrate = @metadata[:format][:bit_rate].to_i
|
73
|
+
|
74
|
+
# TODO: Handle multiple video codecs (is that possible?)
|
75
|
+
video_stream = video_streams.first
|
76
|
+
unless video_stream.nil?
|
77
|
+
@video_codec = video_stream[:codec_name]
|
78
|
+
@colorspace = video_stream[:pix_fmt]
|
79
|
+
@width = video_stream[:width]
|
80
|
+
@height = video_stream[:height]
|
81
|
+
@video_bitrate = video_stream[:bit_rate].to_i
|
82
|
+
@sar = video_stream[:sample_aspect_ratio]
|
83
|
+
@dar = video_stream[:display_aspect_ratio]
|
84
|
+
|
85
|
+
@frame_rate = unless video_stream[:avg_frame_rate] == '0/0'
|
86
|
+
Rational(video_stream[:avg_frame_rate])
|
87
|
+
else
|
88
|
+
nil
|
89
|
+
end
|
90
|
+
|
91
|
+
@video_stream = "#{video_stream[:codec_name]} (#{video_stream[:profile]}) (#{video_stream[:codec_tag_string]} / #{video_stream[:codec_tag]}), #{colorspace}, #{resolution} [SAR #{sar} DAR #{dar}]"
|
92
|
+
|
93
|
+
@rotation = if video_stream.key?(:tags) and video_stream[:tags].key?(:rotate)
|
94
|
+
video_stream[:tags][:rotate].to_i
|
95
|
+
else
|
96
|
+
nil
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
@audio_streams = audio_streams.map do |stream|
|
101
|
+
{
|
102
|
+
:index => stream[:index],
|
103
|
+
:channels => stream[:channels].to_i,
|
104
|
+
:codec_name => stream[:codec_name],
|
105
|
+
:sample_rate => stream[:sample_rate].to_i,
|
106
|
+
:bitrate => stream[:bit_rate].to_i,
|
107
|
+
:channel_layout => stream[:channel_layout],
|
108
|
+
:tags => stream[:streams],
|
109
|
+
:sample_fmt => stream[:sample_fmt],
|
110
|
+
:overview => "#{stream[:codec_name]} (#{stream[:codec_tag_string]} / #{stream[:codec_tag]}), #{stream[:sample_rate]} Hz, #{stream[:channel_layout]}, #{stream[:sample_fmt]}, #{stream[:bit_rate]} bit/s"
|
111
|
+
}
|
112
|
+
end
|
113
|
+
|
114
|
+
audio_stream = @audio_streams.first
|
115
|
+
unless audio_stream.nil?
|
116
|
+
@audio_channels = audio_stream[:channels]
|
117
|
+
@audio_codec = audio_stream[:codec_name]
|
118
|
+
@audio_sample_rate = audio_stream[:sample_rate]
|
119
|
+
@audio_bitrate = audio_stream[:bitrate]
|
120
|
+
@audio_channel_layout = audio_stream[:channel_layout]
|
121
|
+
@audio_tags = audio_stream[:audio_tags]
|
122
|
+
@audio_sample_fmt = audio_stream[:sample_fmt]
|
123
|
+
@audio_stream = audio_stream[:overview]
|
124
|
+
end
|
125
|
+
|
126
|
+
end
|
127
|
+
|
128
|
+
unsupported_stream_ids = unsupported_streams(std_error)
|
129
|
+
nil_or_unsupported = -> (stream) { stream.nil? || unsupported_stream_ids.include?(stream[:index]) }
|
130
|
+
|
131
|
+
@invalid = true if nil_or_unsupported.(video_stream) && nil_or_unsupported.(audio_stream)
|
132
|
+
@invalid = true if @metadata.key?(:error)
|
133
|
+
@invalid = true if std_error.include?("could not find codec parameters")
|
134
|
+
end
|
135
|
+
|
136
|
+
def unsupported_streams(std_error)
|
137
|
+
[].tap do |stream_indices|
|
138
|
+
std_error.each_line do |line|
|
139
|
+
match = line.match(UNSUPPORTED_CODEC_PATTERN)
|
140
|
+
stream_indices << match[2].to_i if match
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def valid?
|
146
|
+
not @invalid
|
147
|
+
end
|
148
|
+
|
149
|
+
def remote?
|
150
|
+
@path =~ URI::regexp(%w(http https))
|
151
|
+
end
|
152
|
+
|
153
|
+
def local?
|
154
|
+
not remote?
|
155
|
+
end
|
156
|
+
|
157
|
+
def width
|
158
|
+
rotation.nil? || rotation == 180 ? @width : @height;
|
159
|
+
end
|
160
|
+
|
161
|
+
def height
|
162
|
+
rotation.nil? || rotation == 180 ? @height : @width;
|
163
|
+
end
|
164
|
+
|
165
|
+
def resolution
|
166
|
+
unless width.nil? or height.nil?
|
167
|
+
"#{width}x#{height}"
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def calculated_aspect_ratio
|
172
|
+
aspect_from_dar || aspect_from_dimensions
|
173
|
+
end
|
174
|
+
|
175
|
+
def calculated_pixel_aspect_ratio
|
176
|
+
aspect_from_sar || 1
|
177
|
+
end
|
178
|
+
|
179
|
+
def size
|
180
|
+
if local?
|
181
|
+
File.size(@path)
|
182
|
+
else
|
183
|
+
@head.content_length
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
def audio_channel_layout
|
188
|
+
# TODO Whenever support for ffmpeg/ffprobe 1.2.1 is dropped this is no longer needed
|
189
|
+
@audio_channel_layout || case(audio_channels)
|
190
|
+
when 1
|
191
|
+
'stereo'
|
192
|
+
when 2
|
193
|
+
'stereo'
|
194
|
+
when 6
|
195
|
+
'5.1'
|
196
|
+
else
|
197
|
+
'unknown'
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
def transcode(output_file, options = EncodingOptions.new, transcoder_options = {}, &block)
|
202
|
+
Transcoder.new(self, output_file, options, transcoder_options).run &block
|
203
|
+
end
|
204
|
+
|
205
|
+
def screenshot(output_file, options = EncodingOptions.new, transcoder_options = {}, &block)
|
206
|
+
Transcoder.new(self, output_file, options.merge(screenshot: true), transcoder_options).run &block
|
207
|
+
end
|
208
|
+
|
209
|
+
protected
|
210
|
+
def aspect_from_dar
|
211
|
+
calculate_aspect(dar)
|
212
|
+
end
|
213
|
+
|
214
|
+
def aspect_from_sar
|
215
|
+
calculate_aspect(sar)
|
216
|
+
end
|
217
|
+
|
218
|
+
def calculate_aspect(ratio)
|
219
|
+
return nil unless ratio
|
220
|
+
w, h = ratio.split(':')
|
221
|
+
return nil if w == '0' || h == '0'
|
222
|
+
@rotation.nil? || (@rotation == 180) ? (w.to_f / h.to_f) : (h.to_f / w.to_f)
|
223
|
+
end
|
224
|
+
|
225
|
+
def aspect_from_dimensions
|
226
|
+
aspect = width.to_f / height.to_f
|
227
|
+
aspect.nan? ? nil : aspect
|
228
|
+
end
|
229
|
+
|
230
|
+
def fix_encoding(output)
|
231
|
+
output[/test/] # Running a regexp on the string throws error if it's not UTF-8
|
232
|
+
rescue ArgumentError
|
233
|
+
output.force_encoding("ISO-8859-1")
|
234
|
+
end
|
235
|
+
|
236
|
+
def head(location=@path, limit=FFMPEG.max_http_redirect_attempts)
|
237
|
+
url = URI(location)
|
238
|
+
return unless url.path
|
239
|
+
|
240
|
+
http = Net::HTTP.new(url.host, url.port)
|
241
|
+
http.use_ssl = url.port == 443
|
242
|
+
response = http.request_head(url.request_uri)
|
243
|
+
|
244
|
+
case response
|
245
|
+
when Net::HTTPRedirection then
|
246
|
+
raise FFMPEG::HTTPTooManyRequests if limit == 0
|
247
|
+
new_uri = url + URI(response['Location'])
|
248
|
+
|
249
|
+
head(new_uri, limit - 1)
|
250
|
+
else
|
251
|
+
response
|
252
|
+
end
|
253
|
+
rescue SocketError, Errno::ECONNREFUSED => e
|
254
|
+
nil
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|