video_converter 0.4.0 → 0.5.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.
- data/.gitignore +2 -0
- data/lib/video_converter/base.rb +19 -16
- data/lib/video_converter/command.rb +9 -0
- data/lib/video_converter/faststart.rb +35 -0
- data/lib/video_converter/ffmpeg.rb +56 -38
- data/lib/video_converter/hash.rb +2 -2
- data/lib/video_converter/hds.rb +42 -0
- data/lib/video_converter/input.rb +68 -31
- data/lib/video_converter/live_segmenter.rb +15 -20
- data/lib/video_converter/output.rb +30 -9
- data/lib/video_converter/version.rb +1 -1
- data/lib/video_converter.rb +4 -2
- data/test/hls_test.rb +1 -1
- data/test/test_helper.rb +3 -0
- data/test/video_converter_test.rb +83 -27
- data/video_converter.gemspec +1 -1
- metadata +8 -6
data/.gitignore
CHANGED
data/lib/video_converter/base.rb
CHANGED
@@ -13,33 +13,36 @@ module VideoConverter
|
|
13
13
|
Input.new(input, outputs)
|
14
14
|
end
|
15
15
|
self.clear_tmp = params[:clear_tmp].nil? ? true : params[:clear_tmp]
|
16
|
-
FileUtils.mkdir_p(File.dirname(VideoConverter.log))
|
17
16
|
end
|
18
17
|
|
19
18
|
def run
|
20
19
|
success = true
|
21
|
-
|
20
|
+
inputs.each do |input|
|
21
|
+
input.output_groups.each do |group|
|
22
|
+
success &&= Ffmpeg.new(input, group).run
|
23
|
+
group.each do |output|
|
24
|
+
success &&= Faststart.new(output).run if output.faststart
|
25
|
+
success &&= VideoScreenshoter.new(output.thumbnails.merge(:input => output.ffmpeg_output, :output_dir => File.join(output.work_dir, 'thumbnails'))).run if output.thumbnails
|
26
|
+
end
|
27
|
+
if playlist = group.detect { |output| output.type == 'playlist' }
|
28
|
+
success &&= if playlist.format == 'm3u8'
|
29
|
+
LiveSegmenter.new(input, group).run
|
30
|
+
else
|
31
|
+
Hds.new(input, group).run
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
22
36
|
clear if clear_tmp && success
|
23
37
|
success
|
24
38
|
end
|
25
39
|
|
26
40
|
private
|
27
41
|
|
28
|
-
def convert
|
29
|
-
Ffmpeg.new(inputs, outputs).run
|
30
|
-
end
|
31
|
-
|
32
|
-
def segment
|
33
|
-
LiveSegmenter.new(inputs, outputs).run
|
34
|
-
end
|
35
|
-
|
36
|
-
def screenshot
|
37
|
-
# TODO use VideoScreenshoter
|
38
|
-
true
|
39
|
-
end
|
40
|
-
|
41
42
|
def clear
|
42
|
-
#
|
43
|
+
`cat #{outputs.first.log} >> #{VideoConverter.log} && rm #{outputs.first.log}`
|
44
|
+
outputs.map { |output| output.passlogfile }.uniq.compact.each { |passlogfile| `rm #{passlogfile}*` }
|
45
|
+
outputs.select { |output| output.type == 'segmented' }.each { |output| `rm #{output.ffmpeg_output}` }
|
43
46
|
end
|
44
47
|
end
|
45
48
|
end
|
@@ -8,6 +8,10 @@ module VideoConverter
|
|
8
8
|
self.dry_run = false
|
9
9
|
self.verbose = false
|
10
10
|
|
11
|
+
def self.chain(*commands)
|
12
|
+
commands.map { |c| "(#{c})" }.join(' && ')
|
13
|
+
end
|
14
|
+
|
11
15
|
attr_accessor :command
|
12
16
|
|
13
17
|
def initialize command, params = {}
|
@@ -24,6 +28,11 @@ module VideoConverter
|
|
24
28
|
end
|
25
29
|
end
|
26
30
|
|
31
|
+
def capture params = {}
|
32
|
+
puts command if params[:verbose] || self.class.verbose
|
33
|
+
`#{command}`.encode!('UTF-8', 'UTF-8', :invalid => :replace)
|
34
|
+
end
|
35
|
+
|
27
36
|
def to_s
|
28
37
|
command
|
29
38
|
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module VideoConverter
|
4
|
+
class Faststart
|
5
|
+
class << self
|
6
|
+
attr_accessor :bin, :command
|
7
|
+
end
|
8
|
+
self.bin = '/usr/local/bin/qt-faststart'
|
9
|
+
self.command = '%{bin} %{input} %{moov_atom} && mv %{moov_atom} %{input} 1>>%{log} 2>&1 || exit 1'
|
10
|
+
|
11
|
+
attr_accessor :output
|
12
|
+
|
13
|
+
def initialize output
|
14
|
+
self.output = output
|
15
|
+
end
|
16
|
+
|
17
|
+
def run
|
18
|
+
success = true
|
19
|
+
success &&= Command.new(self.class.command, prepare_params(output)).execute
|
20
|
+
success
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def prepare_params(output)
|
26
|
+
{
|
27
|
+
:bin => self.class.bin,
|
28
|
+
:log => output.log,
|
29
|
+
:input => output.ffmpeg_output,
|
30
|
+
:moov_atom => "#{output.ffmpeg_output}.mov"
|
31
|
+
}
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
end
|
@@ -3,7 +3,7 @@
|
|
3
3
|
module VideoConverter
|
4
4
|
class Ffmpeg
|
5
5
|
class << self
|
6
|
-
attr_accessor :bin, :ffprobe_bin, :options, :one_pass_command, :first_pass_command, :second_pass_command
|
6
|
+
attr_accessor :bin, :ffprobe_bin, :options, :one_pass_command, :first_pass_command, :second_pass_command, :keyframes_command
|
7
7
|
end
|
8
8
|
|
9
9
|
self.bin = '/usr/local/bin/ffmpeg'
|
@@ -11,48 +11,66 @@ module VideoConverter
|
|
11
11
|
self.options = {
|
12
12
|
:video_codec => '-c:v',
|
13
13
|
:audio_codec => '-c:a',
|
14
|
+
:frame_rate => '-r',
|
15
|
+
:keyint_min => '-keyint_min',
|
14
16
|
:keyframe_interval => '-g',
|
17
|
+
:force_keyframes => '-force_key_frames',
|
15
18
|
:passlogfile => '-passlogfile',
|
16
19
|
:video_bitrate => '-b:v',
|
17
20
|
:audio_bitrate => '-b:a',
|
21
|
+
:size => '-s',
|
18
22
|
:video_filter => '-vf',
|
19
|
-
:frame_rate => '-r',
|
20
23
|
:threads => '-threads',
|
21
24
|
:format => '-f',
|
22
|
-
:bitstream_format => '-bsf'
|
25
|
+
:bitstream_format => '-bsf',
|
26
|
+
:pixel_format => '-pix_fmt',
|
27
|
+
:deinterlace => '-deinterlace'
|
23
28
|
}
|
24
29
|
self.one_pass_command = '%{bin} -i %{input} -y %{options} %{output} 1>>%{log} 2>&1 || exit 1'
|
25
|
-
self.first_pass_command = '%{bin} -i %{input} -y -pass 1 -an
|
26
|
-
self.second_pass_command = '%{bin} -i %{input} -y -pass 2
|
30
|
+
self.first_pass_command = '%{bin} -i %{input} -y -pass 1 -an %{options} /dev/null 1>>%{log} 2>&1 || exit 1'
|
31
|
+
self.second_pass_command = '%{bin} -i %{input} -y -pass 2 %{options} %{output} 1>>%{log} 2>&1 || exit 1'
|
32
|
+
self.keyframes_command = '%{ffprobe_bin} -show_frames -select_streams v:0 -print_format csv %{input} | grep frame,video,1 | cut -d\',\' -f5 | tr "\n" "," | sed \'s/,$//\''
|
27
33
|
|
28
|
-
attr_accessor :
|
34
|
+
attr_accessor :input, :group
|
29
35
|
|
30
|
-
def initialize
|
31
|
-
self.
|
32
|
-
self.
|
36
|
+
def initialize input, group
|
37
|
+
self.input = input
|
38
|
+
self.group = group
|
33
39
|
end
|
34
40
|
|
35
|
-
# NOTE outputs of one group must have common first pass
|
36
41
|
def run
|
37
42
|
success = true
|
38
43
|
threads = []
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
if
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
44
|
+
common_first_pass = false
|
45
|
+
|
46
|
+
qualities = group.select { |output| output.type != 'playlist' }
|
47
|
+
# if all qualities in group contain one_pass, use one_pass_command for ffmpeg
|
48
|
+
unless one_pass = qualities.inject { |r, q| r && q.one_pass }
|
49
|
+
# if group qualities have different sizes use force_keyframes and separate first passes
|
50
|
+
if common_first_pass = qualities.map { |quality| quality.height }.uniq.count == 1
|
51
|
+
# first pass is executed with the best group quality, defined by bitrate or size
|
52
|
+
best_quality = qualities.sort do |q1, q2|
|
53
|
+
res = q1.video_bitrate.to_i <=> q2.video_bitrate.to_i
|
54
|
+
res = q1.height.to_i <=> q2.height.to_i if res == 0
|
55
|
+
res = q1.width.to_i <=> q2.width.to_i if res == 0
|
56
|
+
res
|
57
|
+
end.last
|
58
|
+
success &&= Command.new(self.class.first_pass_command, prepare_params(input, best_quality)).execute
|
59
|
+
end
|
60
|
+
end
|
61
|
+
qualities.each do |output|
|
62
|
+
command = if one_pass
|
63
|
+
self.class.one_pass_command
|
64
|
+
elsif !common_first_pass
|
65
|
+
Command.chain(self.class.first_pass_command, self.class.second_pass_command)
|
66
|
+
else
|
67
|
+
self.class.second_pass_command
|
68
|
+
end
|
69
|
+
command = Command.new(command, prepare_params(input, output))
|
70
|
+
if VideoConverter.paral
|
71
|
+
threads << Thread.new { success &&= command.execute }
|
72
|
+
else
|
73
|
+
success &&= command.execute
|
56
74
|
end
|
57
75
|
end
|
58
76
|
threads.each { |t| t.join } if VideoConverter.paral
|
@@ -62,24 +80,24 @@ module VideoConverter
|
|
62
80
|
private
|
63
81
|
|
64
82
|
def prepare_params input, output
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
output.video_filter = "scale=#{output.width}:trunc\\(ow/a/2\\)*2"
|
71
|
-
elsif output.height
|
72
|
-
output.video_filter = "scale=trunc\\(oh*a/2\\)*2:#{output.height}"
|
73
|
-
end
|
83
|
+
output.video_filter = []
|
84
|
+
output.video_filter << "scale=#{output.width}:trunc\\(ow/a/2\\)*2" if output.width && !output.height
|
85
|
+
output.video_filter << "scale=trunc\\(oh*a/2\\)*2:#{output.height}" if output.height && !output.width
|
86
|
+
output.video_filter << { 90 => 'transpose=2', 180 => 'transpose=2,transpose=2', 270 => 'transpose=1' }[output.rotate] if output.rotate
|
87
|
+
output.video_filter = output.video_filter.join(',')
|
74
88
|
|
75
89
|
{
|
76
90
|
:bin => self.class.bin,
|
77
91
|
:input => input.to_s,
|
78
|
-
:log =>
|
92
|
+
:log => output.log,
|
79
93
|
:output => output.ffmpeg_output,
|
80
94
|
:options => self.class.options.map do |output_option, ffmpeg_option|
|
81
95
|
if output.send(output_option).present?
|
82
|
-
|
96
|
+
if output.send(output_option) == true
|
97
|
+
ffmpeg_option
|
98
|
+
else
|
99
|
+
ffmpeg_option + ' ' + output.send(output_option).to_s
|
100
|
+
end
|
83
101
|
end
|
84
102
|
end.join(' ')
|
85
103
|
}
|
data/lib/video_converter/hash.rb
CHANGED
@@ -18,8 +18,8 @@ class Hash
|
|
18
18
|
value = value.map { |v| v.is_a?(Hash) ? v.deep_shellescape_values : v.shellescape }
|
19
19
|
elsif value.is_a? Hash
|
20
20
|
value = value.deep_shellescape_values
|
21
|
-
|
22
|
-
value = value.
|
21
|
+
elsif value.is_a? String
|
22
|
+
value = value.shellescape
|
23
23
|
end
|
24
24
|
options[key] = value
|
25
25
|
options
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module VideoConverter
|
4
|
+
class Hds
|
5
|
+
class << self
|
6
|
+
attr_accessor :bin, :command
|
7
|
+
end
|
8
|
+
self.bin = '/usr/local/bin/mp4frag'
|
9
|
+
self.command = '%{bin} %{inputs} --manifest %{manifest} --index 1>>%{log} 2>&1 || exit 1'
|
10
|
+
|
11
|
+
attr_accessor :input, :group
|
12
|
+
|
13
|
+
def initialize input, group
|
14
|
+
self.input = input
|
15
|
+
self.group = group
|
16
|
+
end
|
17
|
+
|
18
|
+
def run
|
19
|
+
success = true
|
20
|
+
threads = []
|
21
|
+
command = Command.new(self.class.command, prepare_params(group))
|
22
|
+
if VideoConverter.paral
|
23
|
+
threads << Thread.new { success &&= command.execute }
|
24
|
+
else
|
25
|
+
success &&= command.execute
|
26
|
+
end
|
27
|
+
threads.each { |t| t.join } if VideoConverter.paral
|
28
|
+
success
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def prepare_params(group)
|
34
|
+
{
|
35
|
+
:bin => self.class.bin,
|
36
|
+
:inputs => group.select { |output| output.type != 'playlist' }.map { |input| "--src #{input.ffmpeg_output}" }.join(' '),
|
37
|
+
:manifest => group.detect { |output| output.type == 'playlist' }.ffmpeg_output,
|
38
|
+
:log => group.first.log
|
39
|
+
}
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -3,33 +3,65 @@
|
|
3
3
|
module VideoConverter
|
4
4
|
class Input
|
5
5
|
class << self
|
6
|
-
attr_accessor :metadata_command
|
6
|
+
attr_accessor :metadata_command, :show_frame_command
|
7
7
|
end
|
8
8
|
|
9
9
|
self.metadata_command = "%{ffprobe_bin} %{input} 2>&1"
|
10
|
+
self.show_frame_command = "%{ffprobe_bin} -show_frames -select_streams v %{input} 2>/dev/null | head -n 24"
|
10
11
|
|
11
|
-
attr_accessor :input, :output_groups
|
12
|
+
attr_accessor :input, :output_groups, :metadata
|
12
13
|
|
13
14
|
def initialize input, outputs = []
|
14
15
|
raise ArgumentError.new('input is needed') if input.blank?
|
15
16
|
self.input = input
|
16
17
|
raise ArgumentError.new("#{input} does not exist") unless exists?
|
18
|
+
self.metadata = get_metadata
|
17
19
|
|
20
|
+
# for many inputs case take outputs for this input
|
21
|
+
outputs = outputs.select { |output| !output.path || output.path == input.to_s }
|
18
22
|
self.output_groups = []
|
19
|
-
|
23
|
+
|
24
|
+
# qualities with the same playlist is a one group
|
25
|
+
outputs.select { |output| output.type == 'playlist' }.each_with_index do |playlist, group_index|
|
20
26
|
paths = playlist.streams.map { |stream| stream[:path] }
|
21
|
-
output_group = outputs.select { |output| paths.include?(output.filename)
|
27
|
+
output_group = outputs.select { |output| paths.include?(output.filename) }
|
22
28
|
if output_group.any?
|
23
|
-
|
29
|
+
# if group qualities have different sizes use force_keyframes and separate first passes
|
30
|
+
common_first_pass = output_group.map { |output| output.height }.uniq.count == 1
|
31
|
+
output_group.each_with_index do |output, output_index|
|
32
|
+
unless output.one_pass
|
33
|
+
if common_first_pass
|
34
|
+
output.passlogfile = File.join(output.work_dir, "group#{group_index}.log")
|
35
|
+
else
|
36
|
+
output.passlogfile = File.join(output.work_dir, "group#{group_index}_#{output_index}.log")
|
37
|
+
output.force_keyframes = (metadata[:duration_in_ms] / 1000 / Output.keyframe_interval_in_seconds).times.to_a.map { |t| t * Output.keyframe_interval_in_seconds }.join(',')
|
38
|
+
output.frame_rate = output.keyint_min = output.keyframe_interval = nil
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
24
42
|
self.output_groups << output_group.unshift(playlist)
|
25
43
|
end
|
26
44
|
end
|
45
|
+
# qualities without playlist are separate groups
|
46
|
+
(outputs - output_groups.flatten).each { |output| self.output_groups << [output] }
|
47
|
+
|
48
|
+
# set output parameters depending of input
|
49
|
+
outputs.each do |output|
|
50
|
+
# autorotate
|
51
|
+
if output.type != 'playlist' && [nil, true].include?(output.rotate) && metadata[:rotate]
|
52
|
+
output.rotate = 360 - metadata[:rotate]
|
53
|
+
end
|
54
|
+
# autodeinterlace
|
55
|
+
output.deinterlace = metadata[:interlaced] if output.deinterlace.nil?
|
56
|
+
end
|
27
57
|
end
|
28
58
|
|
29
59
|
def to_s
|
30
60
|
input
|
31
61
|
end
|
32
62
|
|
63
|
+
private
|
64
|
+
|
33
65
|
def exists?
|
34
66
|
if is_http?
|
35
67
|
url = URI.parse(input)
|
@@ -42,50 +74,55 @@ module VideoConverter
|
|
42
74
|
end
|
43
75
|
end
|
44
76
|
|
45
|
-
def
|
77
|
+
def is_http?
|
78
|
+
!!input.match(/^http:\/\//)
|
79
|
+
end
|
80
|
+
|
81
|
+
def is_local?
|
82
|
+
# NOTE method file escapes himself
|
83
|
+
File.file?(input.gsub('\\', ''))
|
84
|
+
end
|
85
|
+
|
86
|
+
def get_metadata
|
46
87
|
metadata = {}
|
47
|
-
|
48
|
-
|
88
|
+
# common metadata
|
89
|
+
s = Command.new(self.class.metadata_command, :ffprobe_bin => Ffmpeg.ffprobe_bin, :input => input).capture
|
90
|
+
if m = s.match(/Stream.*?Audio:\s*(\w+).*?(\d+)\s*Hz.*?(\d+)\s*kb\/s.*?$/)
|
49
91
|
metadata[:audio_codec] = m[1]
|
50
92
|
metadata[:audio_sample_rate] = m[2].to_i
|
51
93
|
metadata[:audio_bitrate_in_kbps] = m[3].to_i
|
52
94
|
end
|
53
|
-
|
54
|
-
|
55
|
-
end
|
56
|
-
if (m = s.match(/Duration:\s+(\d+):(\d+):([\d.]+).*?bitrate:\s*(\d+)\s*kb\/s/).to_a).any?
|
95
|
+
metadata[:channels] = s.scan(/Stream #\d+:\d+/).count
|
96
|
+
if m = s.match(/Duration:\s+(\d+):(\d+):([\d.]+).*?bitrate:\s*(\d+)\s*kb\/s/)
|
57
97
|
metadata[:duration_in_ms] = ((m[1].to_i * 3600 + m[2].to_i * 60 + m[3].to_f) * 1000).to_i
|
58
98
|
metadata[:total_bitrate_in_kbps] = m[4].to_i
|
59
99
|
end
|
60
|
-
if
|
100
|
+
if m = s.match(/Stream.*?Video:\s(\S+).*?,\s*((\d+)x(\d+)).*?(\d+)\s*kb\/s.*?([\d.]+)\s*fps/)
|
61
101
|
metadata[:video_codec] = m[1]
|
62
102
|
metadata[:width] = m[3].to_i
|
63
103
|
metadata[:height] = m[4].to_i
|
64
104
|
metadata[:video_bitrate_in_kbps] = m[5].to_i
|
65
105
|
metadata[:frame_rate] = m[6].to_f
|
66
106
|
end
|
67
|
-
if
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
metadata[:file_size_in_bytes] = File.size(input.gsub('\\', ''))
|
107
|
+
if m = s.match(/rotate\s*\:\s*(\d+)/)
|
108
|
+
metadata[:rotate] = m[1].to_i
|
109
|
+
end
|
110
|
+
if is_http?
|
111
|
+
url = URI.parse(input)
|
112
|
+
Net::HTTP.start(url.host) do |http|
|
113
|
+
response = http.request_head url.path
|
114
|
+
metadata[:file_size_in_bytes] = response['content-length'].to_i
|
76
115
|
end
|
77
|
-
|
116
|
+
elsif is_local?
|
117
|
+
metadata[:file_size_in_bytes] = File.size(input.gsub('\\', ''))
|
78
118
|
end
|
79
|
-
metadata
|
80
|
-
end
|
119
|
+
metadata[:format] = File.extname(input).delete('.')
|
81
120
|
|
82
|
-
|
83
|
-
|
84
|
-
|
121
|
+
# frame metadata
|
122
|
+
s = Command.new(self.class.show_frame_command, :ffprobe_bin => Ffmpeg.ffprobe_bin, :input => input).capture
|
123
|
+
metadata[:interlaced] = true if s.include?('interlaced_frame=1')
|
85
124
|
|
86
|
-
|
87
|
-
# NOTE method file escapes himself
|
88
|
-
File.file?(input.gsub('\\', ''))
|
125
|
+
metadata
|
89
126
|
end
|
90
127
|
end
|
91
128
|
end
|
@@ -3,22 +3,21 @@
|
|
3
3
|
module VideoConverter
|
4
4
|
class LiveSegmenter
|
5
5
|
class << self
|
6
|
-
attr_accessor :bin, :
|
6
|
+
attr_accessor :bin, :chunk_prefix, :encoding_profile, :select_streams, :command
|
7
7
|
end
|
8
8
|
|
9
9
|
self.bin = '/usr/local/bin/live_segmenter'
|
10
|
-
self.segment_seconds = 4
|
11
10
|
self.chunk_prefix = 's'
|
12
11
|
self.encoding_profile = 's'
|
13
12
|
self.select_streams = 'v'
|
14
13
|
|
15
|
-
self.command = '%{ffmpeg_bin} -i %{ffmpeg_output} -
|
14
|
+
self.command = '%{ffmpeg_bin} -i %{ffmpeg_output} -c:v copy -c:a copy -f mpegts pipe:1 2>>/dev/null | %{bin} %{keyframe_interval_in_seconds} %{chunks_dir} %{chunk_prefix} %{encoding_profile} 1>>%{log} 2>&1'
|
16
15
|
|
17
|
-
attr_accessor :
|
16
|
+
attr_accessor :input, :group
|
18
17
|
|
19
|
-
def initialize
|
20
|
-
self.
|
21
|
-
self.
|
18
|
+
def initialize input, group
|
19
|
+
self.input = input
|
20
|
+
self.group = group
|
22
21
|
end
|
23
22
|
|
24
23
|
def run
|
@@ -27,18 +26,14 @@ module VideoConverter
|
|
27
26
|
p = Proc.new do |output|
|
28
27
|
make_chunks(output) && gen_quality_playlist(output)
|
29
28
|
end
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
else
|
36
|
-
success &&= p.call(output)
|
37
|
-
end
|
38
|
-
end
|
39
|
-
success &&= gen_group_playlist(group.detect { |output| output.type == 'playlist' })
|
29
|
+
group.select { |output| output.type != 'playlist' }.each do |output|
|
30
|
+
if VideoConverter.paral
|
31
|
+
threads << Thread.new { success &&= p.call(output) }
|
32
|
+
else
|
33
|
+
success &&= p.call(output)
|
40
34
|
end
|
41
35
|
end
|
36
|
+
success &&= gen_group_playlist(group.detect { |output| output.type == 'playlist' })
|
42
37
|
threads.each { |t| t.join } if VideoConverter.paral
|
43
38
|
success
|
44
39
|
end
|
@@ -71,7 +66,7 @@ module VideoConverter
|
|
71
66
|
res = "#EXTINF:%0.2f,\n" % duration + res
|
72
67
|
end
|
73
68
|
res = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:#{durations.max}\n#EXT-X-MEDIA-SEQUENCE:0\n" + res + "#EXT-X-ENDLIST"
|
74
|
-
File.open(File.join(output.work_dir, output.filename), 'w') { |f| f.write res }
|
69
|
+
!!File.open(File.join(output.work_dir, output.filename), 'w') { |f| f.write res }
|
75
70
|
end
|
76
71
|
|
77
72
|
def gen_group_playlist playlist
|
@@ -90,11 +85,11 @@ module VideoConverter
|
|
90
85
|
:ffmpeg_bin => Ffmpeg.bin,
|
91
86
|
:ffmpeg_output => output.ffmpeg_output,
|
92
87
|
:bin => self.class.bin,
|
93
|
-
:
|
88
|
+
:keyframe_interval_in_seconds => Output.keyframe_interval_in_seconds,
|
94
89
|
:chunks_dir => output.chunks_dir,
|
95
90
|
:chunk_prefix => self.class.chunk_prefix,
|
96
91
|
:encoding_profile => self.class.encoding_profile,
|
97
|
-
:log =>
|
92
|
+
:log => output.log
|
98
93
|
}
|
99
94
|
end
|
100
95
|
end
|
@@ -3,31 +3,37 @@
|
|
3
3
|
module VideoConverter
|
4
4
|
class Output
|
5
5
|
class << self
|
6
|
-
attr_accessor :work_dir, :keyframe_interval, :threads, :video_codec, :audio_codec
|
6
|
+
attr_accessor :work_dir, :keyint_min, :keyframe_interval, :keyframe_interval_in_seconds, :threads, :video_codec, :audio_codec, :pixel_format
|
7
7
|
end
|
8
8
|
|
9
9
|
self.work_dir = '/tmp'
|
10
|
-
self.
|
10
|
+
self.keyint_min = 25
|
11
|
+
self.keyframe_interval = 100
|
12
|
+
self.keyframe_interval_in_seconds = 4
|
11
13
|
self.threads = 1
|
12
14
|
self.video_codec = 'libx264'
|
13
15
|
self.audio_codec = 'libfaac'
|
16
|
+
self.pixel_format = 'yuv420p'
|
14
17
|
|
15
|
-
attr_accessor :uid, :work_dir, :threads, :passlogfile
|
18
|
+
attr_accessor :uid, :work_dir, :log, :threads, :passlogfile
|
16
19
|
attr_accessor :type, :filename
|
17
|
-
attr_accessor :format, :ffmpeg_output, :video_codec, :audio_codec, :bitstream_format
|
20
|
+
attr_accessor :format, :ffmpeg_output, :video_codec, :audio_codec, :bitstream_format, :pixel_format
|
18
21
|
attr_accessor :one_pass, :video_bitrate, :audio_bitrate
|
19
22
|
attr_accessor :streams, :path, :chunks_dir
|
20
|
-
attr_accessor :keyframe_interval, :
|
23
|
+
attr_accessor :frame_rate, :keyint_min, :keyframe_interval, :force_keyframes
|
21
24
|
attr_accessor :size, :width, :height, :video_filter
|
22
25
|
attr_accessor :thumbnails
|
23
|
-
|
26
|
+
attr_accessor :rotate, :deinterlace
|
27
|
+
attr_accessor :faststart
|
24
28
|
|
25
29
|
def initialize params = {}
|
26
30
|
# Inner
|
27
31
|
self.uid = params[:uid].to_s
|
28
32
|
self.work_dir = File.join(self.class.work_dir, uid)
|
29
33
|
FileUtils.mkdir_p(work_dir)
|
34
|
+
self.log = File.join(work_dir, 'converter.log')
|
30
35
|
self.threads = params[:threads] || self.class.threads
|
36
|
+
# NOTE passlogile is defined in input
|
31
37
|
|
32
38
|
# General output options
|
33
39
|
self.type = params[:type]
|
@@ -41,10 +47,12 @@ module VideoConverter
|
|
41
47
|
else
|
42
48
|
self.format = File.extname(filename).sub('.', '')
|
43
49
|
self.ffmpeg_output = File.join(work_dir, filename)
|
50
|
+
raise ArgumentError.new('Invalid playlist extension') if type == 'playlist' && !['f4m', 'm3u8'].include?(format)
|
44
51
|
end
|
45
52
|
self.video_codec = params[:video_codec] || self.class.video_codec
|
46
53
|
self.audio_codec = params[:audio_codec] || self.class.audio_codec
|
47
54
|
self.bitstream_format = params[:bitstream_format]
|
55
|
+
self.pixel_format = params[:pixel_format] || self.class.pixel_format
|
48
56
|
|
49
57
|
# Rate controle
|
50
58
|
self.one_pass = !!params[:one_pass]
|
@@ -60,16 +68,29 @@ module VideoConverter
|
|
60
68
|
end
|
61
69
|
|
62
70
|
# Frame rate
|
63
|
-
self.keyframe_interval = params[:keyframe_interval] || self.class.keyframe_interval
|
64
71
|
self.frame_rate = params[:frame_rate]
|
72
|
+
self.keyint_min = params[:keyint_min] || self.class.keyint_min
|
73
|
+
self.keyframe_interval = params[:keyframe_interval] || self.class.keyframe_interval
|
65
74
|
|
66
75
|
# Resolution
|
67
76
|
self.size = params[:size]
|
68
|
-
self.width = params[:width]
|
69
|
-
self.height = params[:height]
|
77
|
+
self.width = params[:size] ? params[:size].split('x').first : params[:width]
|
78
|
+
self.height = params[:size] ? params[:size].split('x').last : params[:height]
|
79
|
+
self.size = "#{width}x#{height}" if !size && width && height
|
70
80
|
|
71
81
|
#Thumbnails
|
72
82
|
self.thumbnails = params[:thumbnails]
|
83
|
+
|
84
|
+
# Video processing
|
85
|
+
self.rotate = params[:rotate]
|
86
|
+
unless [nil, true, false].include? rotate
|
87
|
+
self.rotate = rotate.to_i
|
88
|
+
raise ArgumentError.new('Invalid rotate') unless [0, 90, 180, 270].include? rotate
|
89
|
+
end
|
90
|
+
self.deinterlace = params[:deinterlace]
|
91
|
+
|
92
|
+
#Faststart
|
93
|
+
self.faststart = params.has_key?(:faststart) ? params[:faststart] : self.format == 'mp4'
|
73
94
|
end
|
74
95
|
end
|
75
96
|
end
|
data/lib/video_converter.rb
CHANGED
@@ -5,8 +5,10 @@ require "video_converter/array"
|
|
5
5
|
require "video_converter/base"
|
6
6
|
require "video_converter/command"
|
7
7
|
require "video_converter/ffmpeg"
|
8
|
+
require "video_converter/faststart"
|
8
9
|
require "video_converter/hash"
|
9
10
|
require "video_converter/hls"
|
11
|
+
require "video_converter/hds"
|
10
12
|
require "video_converter/input"
|
11
13
|
require "video_converter/live_segmenter"
|
12
14
|
require "video_converter/object"
|
@@ -19,11 +21,11 @@ module VideoConverter
|
|
19
21
|
attr_accessor :log, :paral
|
20
22
|
end
|
21
23
|
# TODO log into tmp log file, merge to main log, delete tmp log
|
22
|
-
self.log = 'log/
|
24
|
+
self.log = 'log/converter.log'
|
23
25
|
# TODO output option
|
24
26
|
self.paral = true
|
25
27
|
|
26
28
|
def self.new params
|
27
29
|
VideoConverter::Base.new params.deep_symbolize_keys.deep_shellescape_values
|
28
30
|
end
|
29
|
-
end
|
31
|
+
end
|
data/test/hls_test.rb
CHANGED
data/test/test_helper.rb
CHANGED
@@ -1,39 +1,95 @@
|
|
1
1
|
require 'test_helper'
|
2
2
|
|
3
3
|
class VideoConverterTest < Test::Unit::TestCase
|
4
|
-
|
5
|
-
|
6
|
-
end
|
7
|
-
|
8
|
-
context 'segmentation' do
|
9
|
-
context 'with transcoding' do
|
4
|
+
context 'convertation' do
|
5
|
+
context 'with thumbnails' do
|
10
6
|
setup do
|
11
|
-
VideoConverter.paral = true
|
12
7
|
(@c = VideoConverter.new(
|
13
|
-
|
14
|
-
|
15
|
-
{
|
16
|
-
{
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
{"video_bitrate"=>1550, "filename"=>"hd1.m3u8", "type"=>"segmented", "audio_bitrate"=>48, "height"=>720},
|
21
|
-
{"video_bitrate"=>3200, "filename"=>"hd2.m3u8", "type"=>"segmented", "audio_bitrate"=>128, "height"=>720},
|
22
|
-
{"filename"=>"hd_playlist.m3u8", "type"=>"playlist", "streams"=>[
|
23
|
-
{"path"=>"hd1.m3u8", "bandwidth"=>1598}, {"path"=>"hd2.m3u8", "bandwidth"=>3328}
|
24
|
-
]}
|
8
|
+
:input => 'test/fixtures/test (1).mp4',
|
9
|
+
:outputs => [
|
10
|
+
{ :video_bitrate => 300, :filename => 'q1.mp4' },
|
11
|
+
{ :video_bitrate => 400, :filename => 'q2.mp4', :thumbnails => {
|
12
|
+
:number => 2, :offset_start => '5%', :offset_end => '5%', :presets => { :norm => '-normalize' }
|
13
|
+
} },
|
14
|
+
{ :video_bitrate => 500, :filename => 'q3.mp4' }
|
25
15
|
]
|
26
16
|
)).run
|
27
17
|
end
|
28
18
|
|
29
|
-
should '
|
30
|
-
|
31
|
-
#
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
19
|
+
should 'create qualities and thumbnails' do
|
20
|
+
3.times.each do |n|
|
21
|
+
q = File.join(VideoConverter::Output.work_dir, @c.uid, "q#{n + 1}.mp4")
|
22
|
+
assert File.exists?(q)
|
23
|
+
assert File.size(q) > 0
|
24
|
+
end
|
25
|
+
assert_equal %w(. .. scr004.jpg scr004_norm.jpg scr005.jpg scr005_norm.jpg).sort, Dir.entries(File.join(VideoConverter::Output.work_dir, @c.uid, 'thumbnails')).sort
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
context 'segmentation' do
|
31
|
+
context 'with transcoding' do
|
32
|
+
context 'to HLS' do
|
33
|
+
setup do
|
34
|
+
VideoConverter.paral = true
|
35
|
+
(@c = VideoConverter.new(
|
36
|
+
"input"=>["test/fixtures/test (1).mp4"],
|
37
|
+
"output"=>[
|
38
|
+
{"video_bitrate"=>676, "filename"=>"sd1.m3u8", "type"=>"segmented", "audio_bitrate"=>128, "height"=>528},
|
39
|
+
{"video_bitrate"=>1172, "filename"=>"sd2.m3u8", "type"=>"segmented", "audio_bitrate"=>128, "height"=>528},
|
40
|
+
{"filename"=>"playlist.m3u8", "type"=>"playlist", "streams"=>[
|
41
|
+
{"path"=>"sd1.m3u8", "bandwidth"=>804}, {"path"=>"sd2.m3u8", "bandwidth"=>1300}
|
42
|
+
]},
|
43
|
+
{"video_bitrate"=>1550, "filename"=>"hd1.m3u8", "type"=>"segmented", "audio_bitrate"=>48, "height"=>720},
|
44
|
+
{"video_bitrate"=>3200, "filename"=>"hd2.m3u8", "type"=>"segmented", "audio_bitrate"=>128, "height"=>720},
|
45
|
+
{"filename"=>"hd_playlist.m3u8", "type"=>"playlist", "streams"=>[
|
46
|
+
{"path"=>"hd1.m3u8", "bandwidth"=>1598}, {"path"=>"hd2.m3u8", "bandwidth"=>3328}
|
47
|
+
]}
|
48
|
+
]
|
49
|
+
)).run
|
50
|
+
end
|
51
|
+
|
52
|
+
should 'generate hls' do
|
53
|
+
%w(sd1 sd2 hd1 hd2).each do |quality|
|
54
|
+
# should create chunks
|
55
|
+
assert_equal ['s-00001.ts', 's-00002.ts', 's-00003.ts'], Dir.entries(File.join(VideoConverter::Output.work_dir, @c.uid, quality)).delete_if { |e| ['.', '..'].include?(e) }.sort
|
56
|
+
# TODO verify that chunks have different quality (weight)
|
57
|
+
# should create playlists
|
58
|
+
assert File.exists?(playlist = File.join(VideoConverter::Output.work_dir, @c.uid, "#{quality}.m3u8"))
|
59
|
+
# TODO verify that playlist is valid (contain all chunks and modifiers)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
context 'to HDS' do
|
65
|
+
setup do
|
66
|
+
VideoConverter.paral = true
|
67
|
+
(@c = VideoConverter.new(
|
68
|
+
"input"=>["test/fixtures/test (1).mp4"],
|
69
|
+
"output"=>[
|
70
|
+
{"video_bitrate"=>676, "filename"=>"sd1.mp4", "audio_bitrate"=>128, "height"=>360},
|
71
|
+
{"video_bitrate"=>1172, "filename"=>"sd2.mp4", "audio_bitrate"=>128, "height"=>528},
|
72
|
+
{"filename"=>"playlist.f4m", "type"=>"playlist", "streams"=>[
|
73
|
+
{"path"=>"sd1.mp4", "bandwidth"=>804}, {"path"=>"sd2.mp4", "bandwidth"=>1300}
|
74
|
+
]},
|
75
|
+
{"video_bitrate"=>1550, "filename"=>"hd1.mp4", "audio_bitrate"=>48, "height"=>720},
|
76
|
+
{"video_bitrate"=>3200, "filename"=>"hd2.mp4", "audio_bitrate"=>128, "height"=>720},
|
77
|
+
{"filename"=>"hd_playlist.f4m", "type"=>"playlist", "streams"=>[
|
78
|
+
{"path"=>"hd1.mp4", "bandwidth"=>1598}, {"path"=>"hd2.mp4", "bandwidth"=>3328}
|
79
|
+
]}
|
80
|
+
]
|
81
|
+
)).run
|
82
|
+
end
|
83
|
+
|
84
|
+
should 'generate hds with sync keyframes' do
|
85
|
+
%w(sd1.mp4 sd2.mp4 playlist.f4m hd1.mp4 hd2.mp4 hd_playlist.f4m).each do |filename|
|
86
|
+
assert File.exists?(File.join(VideoConverter::Output.work_dir, @c.uid, "#{filename}"))
|
87
|
+
end
|
88
|
+
|
89
|
+
assert_equal(
|
90
|
+
(k1 = VideoConverter::Command.new(VideoConverter::Ffmpeg.keyframes_command, :ffprobe_bin => VideoConverter::Ffmpeg.ffprobe_bin, :input => File.join(VideoConverter::Output.work_dir, @c.uid, 'sd1.mp4')).capture),
|
91
|
+
(k2 = VideoConverter::Command.new(VideoConverter::Ffmpeg.keyframes_command, :ffprobe_bin => VideoConverter::Ffmpeg.ffprobe_bin, :input => File.join(VideoConverter::Output.work_dir, @c.uid, 'sd2.mp4')).capture)
|
92
|
+
)
|
37
93
|
end
|
38
94
|
end
|
39
95
|
end
|
data/video_converter.gemspec
CHANGED
@@ -18,7 +18,7 @@ Gem::Specification.new do |spec|
|
|
18
18
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
19
|
spec.require_paths = ["lib"]
|
20
20
|
|
21
|
-
spec.add_runtime_dependency "video_screenshoter", "~> 0.2.
|
21
|
+
spec.add_runtime_dependency "video_screenshoter", "~> 0.2.2"
|
22
22
|
|
23
23
|
spec.add_development_dependency "bundler", "~> 1.3"
|
24
24
|
spec.add_development_dependency "rake"
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: video_converter
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.0
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2014-
|
12
|
+
date: 2014-05-16 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: video_screenshoter
|
@@ -18,7 +18,7 @@ dependencies:
|
|
18
18
|
requirements:
|
19
19
|
- - ~>
|
20
20
|
- !ruby/object:Gem::Version
|
21
|
-
version: 0.2.
|
21
|
+
version: 0.2.2
|
22
22
|
type: :runtime
|
23
23
|
prerelease: false
|
24
24
|
version_requirements: !ruby/object:Gem::Requirement
|
@@ -26,7 +26,7 @@ dependencies:
|
|
26
26
|
requirements:
|
27
27
|
- - ~>
|
28
28
|
- !ruby/object:Gem::Version
|
29
|
-
version: 0.2.
|
29
|
+
version: 0.2.2
|
30
30
|
- !ruby/object:Gem::Dependency
|
31
31
|
name: bundler
|
32
32
|
requirement: !ruby/object:Gem::Requirement
|
@@ -109,8 +109,10 @@ files:
|
|
109
109
|
- lib/video_converter/array.rb
|
110
110
|
- lib/video_converter/base.rb
|
111
111
|
- lib/video_converter/command.rb
|
112
|
+
- lib/video_converter/faststart.rb
|
112
113
|
- lib/video_converter/ffmpeg.rb
|
113
114
|
- lib/video_converter/hash.rb
|
115
|
+
- lib/video_converter/hds.rb
|
114
116
|
- lib/video_converter/hls.rb
|
115
117
|
- lib/video_converter/input.rb
|
116
118
|
- lib/video_converter/live_segmenter.rb
|
@@ -140,7 +142,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
140
142
|
version: '0'
|
141
143
|
segments:
|
142
144
|
- 0
|
143
|
-
hash:
|
145
|
+
hash: 948361468017971705
|
144
146
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
145
147
|
none: false
|
146
148
|
requirements:
|
@@ -149,7 +151,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
149
151
|
version: '0'
|
150
152
|
segments:
|
151
153
|
- 0
|
152
|
-
hash:
|
154
|
+
hash: 948361468017971705
|
153
155
|
requirements:
|
154
156
|
- ffmpeg, version 1.2 or greated configured with libx264 and libfaac
|
155
157
|
- live_segmenter to convert to hls
|