video_converter 0.3.2 → 0.4.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/lib/video_converter.rb +15 -31
- data/lib/video_converter/array.rb +11 -0
- data/lib/video_converter/base.rb +15 -39
- data/lib/video_converter/command.rb +4 -19
- data/lib/video_converter/ffmpeg.rb +60 -73
- data/lib/video_converter/hash.rb +32 -0
- data/lib/video_converter/input.rb +20 -18
- data/lib/video_converter/live_segmenter.rb +38 -30
- data/lib/video_converter/object.rb +9 -0
- data/lib/video_converter/output.rb +39 -60
- data/lib/video_converter/version.rb +1 -1
- data/test/fixtures/chunk0.ts +0 -0
- data/test/fixtures/chunk1.ts +0 -0
- data/test/fixtures/test (1).mp4 +0 -0
- data/test/fixtures/test_playlist.m3u8 +8 -0
- data/test/hls_test.rb +14 -0
- data/test/video_converter_test.rb +51 -119
- metadata +17 -11
- data/lib/video_converter/input_array.rb +0 -24
- data/lib/video_converter/output_array.rb +0 -21
- data/lib/video_converter/process.rb +0 -82
- data/test/fixtures/test.mp4 +0 -0
- data/test/input_test.rb +0 -73
data/lib/video_converter.rb
CHANGED
@@ -1,45 +1,29 @@
|
|
1
|
-
require "
|
1
|
+
require "fileutils"
|
2
|
+
require "net/http"
|
3
|
+
require "shellwords"
|
4
|
+
require "video_converter/array"
|
2
5
|
require "video_converter/base"
|
3
6
|
require "video_converter/command"
|
4
|
-
require "video_converter/process"
|
5
7
|
require "video_converter/ffmpeg"
|
6
|
-
require "video_converter/
|
8
|
+
require "video_converter/hash"
|
9
|
+
require "video_converter/hls"
|
7
10
|
require "video_converter/input"
|
8
|
-
require "video_converter/
|
11
|
+
require "video_converter/live_segmenter"
|
12
|
+
require "video_converter/object"
|
9
13
|
require "video_converter/output"
|
10
|
-
require "video_converter/
|
11
|
-
require "video_converter/hls"
|
12
|
-
require "fileutils"
|
13
|
-
require "net/http"
|
14
|
+
require "video_converter/version"
|
14
15
|
require "video_screenshoter"
|
15
|
-
require "shellwords"
|
16
16
|
|
17
17
|
module VideoConverter
|
18
18
|
class << self
|
19
|
-
attr_accessor :paral
|
19
|
+
attr_accessor :log, :paral
|
20
20
|
end
|
21
|
+
# TODO log into tmp log file, merge to main log, delete tmp log
|
22
|
+
self.log = 'log/convertation.log'
|
23
|
+
# TODO output option
|
21
24
|
self.paral = true
|
22
25
|
|
23
26
|
def self.new params
|
24
|
-
VideoConverter::Base.new params.deep_symbolize_keys
|
25
|
-
end
|
26
|
-
|
27
|
-
def self.find uid
|
28
|
-
VideoConverter::Process.find uid
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
Hash.class_eval do
|
33
|
-
def deep_symbolize_keys
|
34
|
-
inject({}) do |options, (key, value)|
|
35
|
-
value = value.map { |v| v.is_a?(Hash) ? v.deep_symbolize_keys : v } if value.is_a? Array
|
36
|
-
value = value.deep_symbolize_keys if value.is_a? Hash
|
37
|
-
options[(key.to_sym rescue key) || key] = value
|
38
|
-
options
|
39
|
-
end
|
40
|
-
end
|
41
|
-
|
42
|
-
def deep_symbolize_keys!
|
43
|
-
self.replace(self.deep_symbolize_keys)
|
27
|
+
VideoConverter::Base.new params.deep_symbolize_keys.deep_shellescape_values
|
44
28
|
end
|
45
|
-
end
|
29
|
+
end
|
data/lib/video_converter/base.rb
CHANGED
@@ -2,68 +2,44 @@
|
|
2
2
|
|
3
3
|
module VideoConverter
|
4
4
|
class Base
|
5
|
-
attr_accessor :
|
5
|
+
attr_accessor :uid, :outputs, :inputs, :clear_tmp
|
6
6
|
|
7
7
|
def initialize params
|
8
8
|
self.uid = params[:uid] || (Socket.gethostname + object_id.to_s)
|
9
|
-
self.
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
else
|
15
|
-
self.log = params[:log]
|
16
|
-
FileUtils.mkdir_p File.dirname(log)
|
9
|
+
self.outputs = Array.wrap(params[:output] || params[:outputs]).map do |output|
|
10
|
+
Output.new(output.merge(:uid => uid))
|
11
|
+
end
|
12
|
+
self.inputs = Array.wrap(params[:input] || params[:inputs]).map do |input|
|
13
|
+
Input.new(input, outputs)
|
17
14
|
end
|
18
15
|
self.clear_tmp = params[:clear_tmp].nil? ? true : params[:clear_tmp]
|
16
|
+
FileUtils.mkdir_p(File.dirname(VideoConverter.log))
|
19
17
|
end
|
20
18
|
|
21
19
|
def run
|
22
|
-
|
23
|
-
[:convert, :segment, :screenshot].each
|
24
|
-
|
25
|
-
|
26
|
-
end
|
27
|
-
process.status = 'finished'
|
28
|
-
clear if clear_tmp
|
29
|
-
true
|
20
|
+
success = true
|
21
|
+
[:convert, :segment, :screenshot].each { |action| success &&= send(action) }
|
22
|
+
clear if clear_tmp && success
|
23
|
+
success
|
30
24
|
end
|
31
25
|
|
32
26
|
private
|
33
27
|
|
34
28
|
def convert
|
35
|
-
|
36
|
-
[:input_array, :output_array, :log, :process].each do |param|
|
37
|
-
params[param] = self.send(param)
|
38
|
-
end
|
39
|
-
Ffmpeg.new(params).run
|
29
|
+
Ffmpeg.new(inputs, outputs).run
|
40
30
|
end
|
41
31
|
|
42
32
|
def segment
|
43
|
-
|
44
|
-
[:output_array, :log].each do |param|
|
45
|
-
params[param] = self.send(param)
|
46
|
-
end
|
47
|
-
LiveSegmenter.new(params).run
|
33
|
+
LiveSegmenter.new(inputs, outputs).run
|
48
34
|
end
|
49
35
|
|
50
36
|
def screenshot
|
51
|
-
|
52
|
-
output.thumbnails.to_a.each do |thumbnail_params|
|
53
|
-
VideoScreenshoter.new(thumbnail_params.merge(:ffmpeg => Ffmpeg.bin, :input => File.join(output.work_dir, output.filename.sub(/\.m3u8/, '.ts')), :output_dir => File.join(output.work_dir, 'thumbnails'))).make_screenshots
|
54
|
-
end
|
55
|
-
end
|
37
|
+
# TODO use VideoScreenshoter
|
56
38
|
true
|
57
39
|
end
|
58
40
|
|
59
41
|
def clear
|
60
|
-
|
61
|
-
FileUtils.rm(Dir.glob(File.join(output.work_dir, '*.log')))
|
62
|
-
FileUtils.rm(Dir.glob(File.join(output.work_dir, '*.log.mbtree')))
|
63
|
-
FileUtils.rm(File.join(output.work_dir, output.filename.sub(/\.m3u8$/, '.ts'))) if output.type == :segmented
|
64
|
-
end
|
65
|
-
FileUtils.rm_r(process.process_dir)
|
66
|
-
true
|
42
|
+
# TODO delete logs and ts file for segmented type if clear_tmp is true
|
67
43
|
end
|
68
44
|
end
|
69
45
|
end
|
@@ -3,36 +3,21 @@
|
|
3
3
|
module VideoConverter
|
4
4
|
class Command
|
5
5
|
class << self
|
6
|
-
attr_accessor :
|
6
|
+
attr_accessor :dry_run, :verbose
|
7
7
|
end
|
8
|
-
self.
|
8
|
+
self.dry_run = false
|
9
9
|
self.verbose = false
|
10
10
|
|
11
11
|
attr_accessor :command
|
12
12
|
|
13
13
|
def initialize command, params = {}
|
14
|
-
|
15
|
-
params.each do |param, value|
|
16
|
-
value = value.to_s.strip
|
17
|
-
res.gsub! "%{#{param}}", if value.empty?
|
18
|
-
''
|
19
|
-
elsif matches = value.match(/^([\w-]+)\s+(.+)$/)
|
20
|
-
"#{matches[1]} #{escape(matches[2])}"
|
21
|
-
else
|
22
|
-
escape(value)
|
23
|
-
end
|
24
|
-
end
|
25
|
-
self.command = res
|
14
|
+
self.command = command.gsub(/%\{(\w+?)\}/) { |m| params[$1.to_sym] }
|
26
15
|
raise ArgumentError.new("Command is not parsed '#{self.command}'") if self.command.match(/%{[\w\-.]+}/)
|
27
16
|
end
|
28
17
|
|
29
|
-
def escape value
|
30
|
-
Shellwords.escape(value.to_s.gsub(/(?<!\\)(['+])/, '\\\\\1')).gsub("\\\\","\\")
|
31
|
-
end
|
32
|
-
|
33
18
|
def execute params = {}
|
34
19
|
puts command if params[:verbose] || self.class.verbose
|
35
|
-
if params[:
|
20
|
+
if params[:dry_run] || self.class.dry_run
|
36
21
|
true
|
37
22
|
else
|
38
23
|
system command
|
@@ -3,99 +3,86 @@
|
|
3
3
|
module VideoConverter
|
4
4
|
class Ffmpeg
|
5
5
|
class << self
|
6
|
-
attr_accessor :bin, :
|
6
|
+
attr_accessor :bin, :ffprobe_bin, :options, :one_pass_command, :first_pass_command, :second_pass_command
|
7
7
|
end
|
8
8
|
|
9
9
|
self.bin = '/usr/local/bin/ffmpeg'
|
10
|
-
self.
|
11
|
-
self.
|
12
|
-
|
13
|
-
|
14
|
-
|
10
|
+
self.ffprobe_bin = '/usr/local/bin/ffprobe'
|
11
|
+
self.options = {
|
12
|
+
:video_codec => '-c:v',
|
13
|
+
:audio_codec => '-c:a',
|
14
|
+
:keyframe_interval => '-g',
|
15
|
+
:passlogfile => '-passlogfile',
|
16
|
+
:video_bitrate => '-b:v',
|
17
|
+
:audio_bitrate => '-b:a',
|
18
|
+
:video_filter => '-vf',
|
19
|
+
:frame_rate => '-r',
|
20
|
+
:threads => '-threads',
|
21
|
+
:format => '-f',
|
22
|
+
:bitstream_format => '-bsf'
|
23
|
+
}
|
24
|
+
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 -keyint_min 25 -pix_fmt yuv420p %{options} /dev/null 1>>%{log} 2>&1 || exit 1'
|
26
|
+
self.second_pass_command = '%{bin} -i %{input} -y -pass 2 -keyint_min 25 -pix_fmt yuv420p %{options} %{output} 1>>%{log} 2>&1 || exit 1'
|
15
27
|
|
16
|
-
|
28
|
+
attr_accessor :inputs, :outputs
|
17
29
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
def initialize params
|
23
|
-
[:input_array, :output_array].each do |param|
|
24
|
-
self.send("#{param}=", params[param]) or raise ArgumentError.new("#{param} is needed")
|
25
|
-
end
|
26
|
-
[:one_pass, :paral, :log, :process].each do |param|
|
27
|
-
self.send("#{param}=", params[param] ? params[param] : self.class.send(param))
|
28
|
-
end
|
30
|
+
def initialize inputs, outputs
|
31
|
+
self.inputs = inputs
|
32
|
+
self.outputs = outputs
|
29
33
|
end
|
30
34
|
|
35
|
+
# NOTE outputs of one group must have common first pass
|
31
36
|
def run
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
res &&= first_pass_command.execute
|
37
|
+
success = true
|
38
|
+
threads = []
|
39
|
+
inputs.each do |input|
|
40
|
+
input.output_groups.each do |group|
|
41
|
+
qualities = group.select { |output| output.type != 'playlist' }
|
42
|
+
# NOTE if all qualities in group contain one_pass, use one_pass_command for ffmpeg
|
43
|
+
unless one_pass = qualities.inject { |r, q| r && q.one_pass }
|
44
|
+
# NOTE first pass is executed with maximal group's video bitrate
|
45
|
+
best_quality = qualities.sort { |q1, q2| q1.video_bitrate.to_i <=> q2.video_bitrate.to_i }.last
|
46
|
+
success &&= Command.new(self.class.first_pass_command, prepare_params(input, best_quality)).execute
|
43
47
|
end
|
44
|
-
|
45
|
-
|
46
|
-
if
|
47
|
-
|
48
|
+
qualities.each do |output|
|
49
|
+
command = Command.new(one_pass ? self.class.one_pass_command : self.class.second_pass_command, prepare_params(input, output))
|
50
|
+
if VideoConverter.paral
|
51
|
+
threads << Thread.new { success &&= command.execute }
|
48
52
|
else
|
49
|
-
|
50
|
-
end
|
51
|
-
if paral
|
52
|
-
threads << Thread.new { res &&= quality_command.execute }
|
53
|
-
else
|
54
|
-
res &&= quality_command.execute
|
53
|
+
success &&= command.execute
|
55
54
|
end
|
56
55
|
end
|
57
56
|
end
|
58
|
-
threads.each { |t| t.join } if paral
|
59
57
|
end
|
60
|
-
|
61
|
-
|
58
|
+
threads.each { |t| t.join } if VideoConverter.paral
|
59
|
+
success
|
62
60
|
end
|
63
|
-
|
64
|
-
private
|
65
61
|
|
66
|
-
|
67
|
-
{ :bin => self.class.bin, :log => log }
|
68
|
-
end
|
62
|
+
private
|
69
63
|
|
70
|
-
def prepare_params
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
64
|
+
def prepare_params input, output
|
65
|
+
if output.size
|
66
|
+
output.video_filter = "scale=#{output.size.sub('x', ':')}"
|
67
|
+
elsif output.width && output.size
|
68
|
+
output.video_filter = "scale=#{output.width}:#{output.height}"
|
69
|
+
elsif output.width
|
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}"
|
77
73
|
end
|
78
|
-
params[:frame_rate] = params[:frame_rate] ? "-r #{params[:frame_rate]}" : ''
|
79
|
-
params
|
80
|
-
end
|
81
74
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
sleep Process.collect_progress_interval
|
91
|
-
koefs = []
|
92
|
-
Dir.glob(File.join(process.progress_dir, '*.log')).each do |progressfile|
|
93
|
-
if matches = `tail -n 9 #{progressfile}`.match(/out_time_ms=(\d+).*?progress=(\w+)/m)
|
94
|
-
koefs << (matches[2] == 'end' ? 1 : (matches[1].to_f / 1000 / duration))
|
75
|
+
{
|
76
|
+
:bin => self.class.bin,
|
77
|
+
:input => input.to_s,
|
78
|
+
:log => VideoConverter.log,
|
79
|
+
:output => output.ffmpeg_output,
|
80
|
+
:options => self.class.options.map do |output_option, ffmpeg_option|
|
81
|
+
if output.send(output_option).present?
|
82
|
+
ffmpeg_option += ' ' + output.send(output_option).to_s
|
95
83
|
end
|
96
|
-
end
|
97
|
-
|
98
|
-
end
|
84
|
+
end.join(' ')
|
85
|
+
}
|
99
86
|
end
|
100
87
|
end
|
101
88
|
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
class Hash
|
2
|
+
def deep_symbolize_keys
|
3
|
+
inject({}) do |options, (key, value)|
|
4
|
+
value = value.map { |v| v.is_a?(Hash) ? v.deep_symbolize_keys : v } if value.is_a? Array
|
5
|
+
value = value.deep_symbolize_keys if value.is_a? Hash
|
6
|
+
options[(key.to_sym rescue key) || key] = value
|
7
|
+
options
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def deep_symbolize_keys!
|
12
|
+
self.replace(self.deep_symbolize_keys)
|
13
|
+
end
|
14
|
+
|
15
|
+
def deep_shellescape_values
|
16
|
+
inject({}) do |options, (key, value)|
|
17
|
+
if value.is_a? Array
|
18
|
+
value = value.map { |v| v.is_a?(Hash) ? v.deep_shellescape_values : v.shellescape }
|
19
|
+
elsif value.is_a? Hash
|
20
|
+
value = value.deep_shellescape_values
|
21
|
+
else
|
22
|
+
value = value.to_s.shellescape
|
23
|
+
end
|
24
|
+
options[key] = value
|
25
|
+
options
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def deep_shellescape_values!
|
30
|
+
self.replace(self.deep_shellescape_values)
|
31
|
+
end
|
32
|
+
end
|
@@ -6,19 +6,28 @@ module VideoConverter
|
|
6
6
|
attr_accessor :metadata_command
|
7
7
|
end
|
8
8
|
|
9
|
-
self.metadata_command = "%{
|
9
|
+
self.metadata_command = "%{ffprobe_bin} %{input} 2>&1"
|
10
10
|
|
11
|
-
attr_accessor :input, :
|
11
|
+
attr_accessor :input, :output_groups
|
12
12
|
|
13
|
-
def initialize input
|
14
|
-
raise ArgumentError.new('input is needed') if input.
|
13
|
+
def initialize input, outputs = []
|
14
|
+
raise ArgumentError.new('input is needed') if input.blank?
|
15
15
|
self.input = input
|
16
|
-
|
16
|
+
raise ArgumentError.new("#{input} does not exist") unless exists?
|
17
|
+
|
17
18
|
self.output_groups = []
|
19
|
+
outputs.select { |output| output.type == 'playlist' }.each_with_index do |playlist, index|
|
20
|
+
paths = playlist.streams.map { |stream| stream[:path] }
|
21
|
+
output_group = outputs.select { |output| paths.include?(output.filename) && (!output.path || output.path == input.to_s) }
|
22
|
+
if output_group.any?
|
23
|
+
output_group.each { |output| output.passlogfile = File.join(output.work_dir, "group#{index}.log") }
|
24
|
+
self.output_groups << output_group.unshift(playlist)
|
25
|
+
end
|
26
|
+
end
|
18
27
|
end
|
19
28
|
|
20
29
|
def to_s
|
21
|
-
|
30
|
+
input
|
22
31
|
end
|
23
32
|
|
24
33
|
def exists?
|
@@ -28,16 +37,14 @@ module VideoConverter
|
|
28
37
|
response = http.request_head url.path
|
29
38
|
Net::HTTPSuccess === response
|
30
39
|
end
|
31
|
-
elsif is_local?
|
32
|
-
File.file? input
|
33
40
|
else
|
34
|
-
|
41
|
+
is_local?
|
35
42
|
end
|
36
43
|
end
|
37
44
|
|
38
45
|
def metadata
|
39
46
|
metadata = {}
|
40
|
-
s = `#{Command.new self.class.metadata_command,
|
47
|
+
s = `#{Command.new self.class.metadata_command, :ffprobe_bin => Ffmpeg.ffprobe_bin, :input => input}`.encode!('UTF-8', 'UTF-8', :invalid => :replace)
|
41
48
|
if (m = s.match(/Stream.*?Audio:\s*(\w+).*?(\d+)\s*Hz.*?(\d+)\s*kb\/s.*?$/).to_a).any?
|
42
49
|
metadata[:audio_codec] = m[1]
|
43
50
|
metadata[:audio_sample_rate] = m[2].to_i
|
@@ -65,7 +72,7 @@ module VideoConverter
|
|
65
72
|
metadata[:file_size_in_bytes] = response['content-length'].to_i
|
66
73
|
end
|
67
74
|
elsif is_local?
|
68
|
-
metadata[:file_size_in_bytes] = File.size(input)
|
75
|
+
metadata[:file_size_in_bytes] = File.size(input.gsub('\\', ''))
|
69
76
|
end
|
70
77
|
metadata[:format] = File.extname(input).sub('.', '')
|
71
78
|
end
|
@@ -77,13 +84,8 @@ module VideoConverter
|
|
77
84
|
end
|
78
85
|
|
79
86
|
def is_local?
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
private
|
84
|
-
|
85
|
-
def common_params
|
86
|
-
{ :bin => VideoConverter::Ffmpeg.bin, :input => input }
|
87
|
+
# NOTE method file escapes himself
|
88
|
+
File.file?(input.gsub('\\', ''))
|
87
89
|
end
|
88
90
|
end
|
89
91
|
end
|
@@ -3,70 +3,69 @@
|
|
3
3
|
module VideoConverter
|
4
4
|
class LiveSegmenter
|
5
5
|
class << self
|
6
|
-
attr_accessor :bin, :
|
6
|
+
attr_accessor :bin, :segment_seconds, :chunk_prefix, :encoding_profile, :select_streams, :command
|
7
7
|
end
|
8
8
|
|
9
9
|
self.bin = '/usr/local/bin/live_segmenter'
|
10
|
-
self.
|
10
|
+
self.segment_seconds = 4
|
11
11
|
self.chunk_prefix = 's'
|
12
12
|
self.encoding_profile = 's'
|
13
|
-
self.log = '/dev/null'
|
14
|
-
self.paral = true
|
15
13
|
self.select_streams = 'v'
|
16
14
|
|
17
|
-
self.
|
15
|
+
self.command = '%{ffmpeg_bin} -i %{ffmpeg_output} -vcodec copy -acodec copy -f mpegts pipe:1 2>>/dev/null | %{bin} %{segment_seconds} %{chunks_dir} %{chunk_prefix} %{encoding_profile} 1>>%{log} 2>&1'
|
18
16
|
|
19
|
-
attr_accessor :
|
17
|
+
attr_accessor :inputs, :outputs
|
20
18
|
|
21
|
-
def initialize
|
22
|
-
self.
|
23
|
-
|
24
|
-
self.send("#{param}=", params[param].nil? ? self.class.send(param) : params[param])
|
25
|
-
end
|
19
|
+
def initialize inputs, outputs
|
20
|
+
self.inputs = inputs
|
21
|
+
self.outputs = outputs
|
26
22
|
end
|
27
23
|
|
28
24
|
def run
|
29
|
-
|
25
|
+
success = true
|
30
26
|
threads = []
|
31
27
|
p = Proc.new do |output|
|
32
28
|
make_chunks(output) && gen_quality_playlist(output)
|
33
29
|
end
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
30
|
+
inputs.each do |input|
|
31
|
+
input.output_groups.each do |group|
|
32
|
+
group.select { |output| output.type != 'playlist' }.each do |output|
|
33
|
+
if VideoConverter.paral
|
34
|
+
threads << Thread.new { success &&= p.call(output) }
|
35
|
+
else
|
36
|
+
success &&= p.call(output)
|
37
|
+
end
|
40
38
|
end
|
39
|
+
success &&= gen_group_playlist(group.detect { |output| output.type == 'playlist' })
|
41
40
|
end
|
42
|
-
res &&= gen_group_playlist playlist
|
43
41
|
end
|
44
|
-
threads.each { |t| t.join } if paral
|
45
|
-
|
42
|
+
threads.each { |t| t.join } if VideoConverter.paral
|
43
|
+
success
|
46
44
|
end
|
47
45
|
|
48
46
|
private
|
49
47
|
|
50
48
|
def make_chunks output
|
51
|
-
Command.new(self.class.
|
49
|
+
Command.new(self.class.command, prepare_params(output)).execute
|
52
50
|
end
|
53
51
|
|
54
52
|
def gen_quality_playlist output
|
55
53
|
res = ''
|
56
54
|
durations = []
|
57
55
|
# order desc
|
58
|
-
chunks = Dir::glob(File.join(output.chunks_dir, "#{chunk_prefix}-*[0-9].ts")).sort do |c1, c2|
|
56
|
+
chunks = Dir::glob(File.join(output.chunks_dir, "#{self.class.chunk_prefix}-*[0-9].ts")).sort do |c1, c2|
|
59
57
|
File.basename(c2).match(/\d+/).to_s.to_i <=> File.basename(c1).match(/\d+/).to_s.to_i
|
60
58
|
end
|
61
59
|
# chunk duration = (pts of first frame of the next chunk - pts of first frame of current chunk) / time_base
|
62
60
|
# for the last chunks the last two pts are used
|
63
|
-
prl_pts, l_pts = `#{
|
61
|
+
prl_pts, l_pts = `#{Ffmpeg.ffprobe_bin} -show_frames -select_streams #{self.class.select_streams} -print_format csv -loglevel fatal #{chunks.first} | tail -n2 2>&1`.split("\n").map { |l| l.split(',')[3].to_i }
|
62
|
+
# NOTE for case when chunk has one frame
|
63
|
+
l_pts ||= prl_pts
|
64
64
|
next_chunk_pts = 2 * l_pts - prl_pts
|
65
|
-
time_base = `#{
|
65
|
+
time_base = `#{Ffmpeg.ffprobe_bin} -show_streams -select_streams #{self.class.select_streams} -loglevel fatal #{chunks.first} 2>&1`.match(/\ntime_base=1\/(\d+)/)[1].to_f
|
66
66
|
chunks.each do |chunk|
|
67
|
-
pts = `#{
|
67
|
+
pts = `#{Ffmpeg.ffprobe_bin} -show_frames -select_streams #{self.class.select_streams} -print_format csv -loglevel fatal #{chunk} | head -n1 2>&1`.split(',')[3].to_i
|
68
68
|
durations << (duration = (next_chunk_pts - pts) / time_base)
|
69
|
-
puts "chunk: #{chunk}, duration: #{duration}, next_chunk_pts: #{next_chunk_pts}, pts: #{pts}"
|
70
69
|
next_chunk_pts = pts
|
71
70
|
res = File.join(File.basename(output.chunks_dir), File.basename(chunk)) + "\n" + res
|
72
71
|
res = "#EXTINF:%0.2f,\n" % duration + res
|
@@ -86,8 +85,17 @@ module VideoConverter
|
|
86
85
|
true
|
87
86
|
end
|
88
87
|
|
89
|
-
def
|
90
|
-
{
|
88
|
+
def prepare_params output
|
89
|
+
{
|
90
|
+
:ffmpeg_bin => Ffmpeg.bin,
|
91
|
+
:ffmpeg_output => output.ffmpeg_output,
|
92
|
+
:bin => self.class.bin,
|
93
|
+
:segment_seconds => self.class.segment_seconds,
|
94
|
+
:chunks_dir => output.chunks_dir,
|
95
|
+
:chunk_prefix => self.class.chunk_prefix,
|
96
|
+
:encoding_profile => self.class.encoding_profile,
|
97
|
+
:log => VideoConverter.log
|
98
|
+
}
|
91
99
|
end
|
92
100
|
end
|
93
|
-
end
|
101
|
+
end
|
@@ -3,67 +3,65 @@
|
|
3
3
|
module VideoConverter
|
4
4
|
class Output
|
5
5
|
class << self
|
6
|
-
attr_accessor :
|
6
|
+
attr_accessor :work_dir, :keyframe_interval, :threads, :video_codec, :audio_codec
|
7
7
|
end
|
8
8
|
|
9
|
-
self.base_url = '/tmp'
|
10
9
|
self.work_dir = '/tmp'
|
11
|
-
self.video_bitrate = 700
|
12
|
-
self.audio_bitrate = 200
|
13
|
-
self.segment_seconds = 10
|
14
10
|
self.keyframe_interval = 250
|
15
11
|
self.threads = 1
|
16
12
|
self.video_codec = 'libx264'
|
17
13
|
self.audio_codec = 'libfaac'
|
18
14
|
|
19
|
-
attr_accessor :
|
15
|
+
attr_accessor :uid, :work_dir, :threads, :passlogfile
|
16
|
+
attr_accessor :type, :filename
|
17
|
+
attr_accessor :format, :ffmpeg_output, :video_codec, :audio_codec, :bitstream_format
|
18
|
+
attr_accessor :one_pass, :video_bitrate, :audio_bitrate
|
19
|
+
attr_accessor :streams, :path, :chunks_dir
|
20
|
+
attr_accessor :keyframe_interval, :frame_rate
|
21
|
+
attr_accessor :size, :width, :height, :video_filter
|
22
|
+
attr_accessor :thumbnails
|
23
|
+
|
20
24
|
|
21
25
|
def initialize params = {}
|
26
|
+
# Inner
|
22
27
|
self.uid = params[:uid].to_s
|
28
|
+
self.work_dir = File.join(self.class.work_dir, uid)
|
29
|
+
FileUtils.mkdir_p(work_dir)
|
30
|
+
self.threads = params[:threads] || self.class.threads
|
23
31
|
|
24
32
|
# General output options
|
25
|
-
self.type = params[:type]
|
26
|
-
raise ArgumentError.new('Incorrect type')
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
33
|
+
self.type = params[:type]
|
34
|
+
raise ArgumentError.new('Incorrect type') if type && !%w(default segmented playlist).include?(type)
|
35
|
+
self.filename = params[:filename] or raise ArgumentError.new('filename required')
|
36
|
+
|
37
|
+
# Formats and codecs
|
38
|
+
if type == 'segmented'
|
39
|
+
self.format = 'mpegts'
|
40
|
+
self.ffmpeg_output = File.join(work_dir, File.basename(filename, '.*') + '.ts')
|
41
|
+
else
|
42
|
+
self.format = File.extname(filename).sub('.', '')
|
43
|
+
self.ffmpeg_output = File.join(work_dir, filename)
|
34
44
|
end
|
35
|
-
self.
|
36
|
-
|
37
|
-
|
38
|
-
self.base_url = (params[:url] ? File.dirname(params[:url]) : params[:base_url]) || self.class.base_url
|
39
|
-
self.filename = (params[:url] ? File.basename(params[:url]) : params[:filename]) || self.uid + '.' + self.format
|
40
|
-
self.url = params[:url] ? params[:url] : File.join(base_url, filename)
|
41
|
-
self.work_dir = File.join(params[:work_dir] || self.class.work_dir, uid)
|
42
|
-
format_regexp = Regexp.new("#{File.extname(filename)}$")
|
43
|
-
self.local_path = File.join(work_dir, filename.sub(format_regexp, ".#{format}"))
|
44
|
-
FileUtils.mkdir_p File.dirname(local_path)
|
45
|
-
if type == :segmented
|
46
|
-
self.chunks_dir = File.join(work_dir, filename.sub(format_regexp, ''))
|
47
|
-
FileUtils.mkdir_p chunks_dir
|
48
|
-
end
|
49
|
-
self.threads = self.class.threads
|
50
|
-
self.path = params[:path]
|
45
|
+
self.video_codec = params[:video_codec] || self.class.video_codec
|
46
|
+
self.audio_codec = params[:audio_codec] || self.class.audio_codec
|
47
|
+
self.bitstream_format = params[:bitstream_format]
|
51
48
|
|
52
49
|
# Rate controle
|
53
|
-
self.
|
54
|
-
self.
|
50
|
+
self.one_pass = !!params[:one_pass]
|
51
|
+
self.video_bitrate = "#{params[:video_bitrate]}k" if params[:video_bitrate]
|
52
|
+
self.audio_bitrate = "#{params[:audio_bitrate]}k" if params[:audio_bitrate]
|
55
53
|
|
56
54
|
# Segmented streaming
|
57
|
-
self.streams = params[:streams]
|
58
|
-
self.
|
55
|
+
self.streams = params[:streams]
|
56
|
+
self.path = params[:path]
|
57
|
+
if type == 'segmented'
|
58
|
+
self.chunks_dir = File.join(work_dir, File.basename(filename, '.*'))
|
59
|
+
FileUtils.mkdir_p(chunks_dir)
|
60
|
+
end
|
59
61
|
|
60
62
|
# Frame rate
|
61
|
-
self.keyframe_interval = params[:keyframe_interval]
|
62
|
-
self.frame_rate = params[:frame_rate]
|
63
|
-
|
64
|
-
# Format and codecs
|
65
|
-
self.video_codec = (params[:copy_video] ? 'copy' : params[:video_codec]) || self.class.video_codec
|
66
|
-
self.audio_codec = (params[:copy_audio] ? 'copy' : params[:audio_codec]) || self.class.audio_codec
|
63
|
+
self.keyframe_interval = params[:keyframe_interval] || self.class.keyframe_interval
|
64
|
+
self.frame_rate = params[:frame_rate]
|
67
65
|
|
68
66
|
# Resolution
|
69
67
|
self.size = params[:size]
|
@@ -73,24 +71,5 @@ module VideoConverter
|
|
73
71
|
#Thumbnails
|
74
72
|
self.thumbnails = params[:thumbnails]
|
75
73
|
end
|
76
|
-
|
77
|
-
def to_hash
|
78
|
-
keys = [:video_bitrate, :local_path, :segment_seconds, :chunks_dir, :audio_bitrate, :keyframe_interval, :frame_rate, :threads, :video_codec, :audio_codec, :size, :width, :height]
|
79
|
-
Hash[*keys.map{ |key| [key, self.send(key)] }.flatten]
|
80
|
-
end
|
81
|
-
|
82
|
-
def self.outputs output
|
83
|
-
(output.is_a?(Hash) ? [output] : output).map { |output| output.is_a?(self.class) ? output : new(output) }
|
84
|
-
end
|
85
|
-
|
86
|
-
def self.playlists output
|
87
|
-
outputs(output).select { |output| output.type == :playlist }
|
88
|
-
end
|
89
|
-
|
90
|
-
def qualities output
|
91
|
-
raise TypeError.new('Only for playlists') unless type == :playlist
|
92
|
-
stream_paths = streams.map { |stream| stream[:path] }
|
93
|
-
self.class.outputs(output).select { |output| stream_paths.include? output.filename }
|
94
|
-
end
|
95
74
|
end
|
96
75
|
end
|
Binary file
|
Binary file
|
Binary file
|
data/test/hls_test.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class HlsTest < Test::Unit::TestCase
|
4
|
+
setup do
|
5
|
+
@concat = "/tmp/test_concat.mp4"
|
6
|
+
VideoConverter::Hls.new({:input=>"test/fixtures/test_playlist.m3u8", :output=>@concat}).concat
|
7
|
+
end
|
8
|
+
|
9
|
+
should 'concat' do
|
10
|
+
metadata = VideoConverter.new(:input => @concat).inputs.first.metadata
|
11
|
+
assert metadata[:duration_in_ms] > 20_000
|
12
|
+
assert_equal 'mp4', metadata[:format]
|
13
|
+
end
|
14
|
+
end
|
@@ -1,138 +1,70 @@
|
|
1
1
|
require 'test_helper'
|
2
2
|
|
3
3
|
class VideoConverterTest < Test::Unit::TestCase
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
@input_url = 'http://techslides.com/demos/sample-videos/small.mp4'
|
8
|
-
end
|
4
|
+
setup do
|
5
|
+
VideoConverter::Command.verbose = true
|
6
|
+
end
|
9
7
|
|
10
|
-
|
8
|
+
context 'segmentation' do
|
9
|
+
context 'with transcoding' do
|
11
10
|
setup do
|
12
|
-
|
13
|
-
@
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
assert File.exists?('tmp/test.log')
|
29
|
-
assert !File.read('tmp/test.log').empty?
|
11
|
+
VideoConverter.paral = true
|
12
|
+
(@c = VideoConverter.new(
|
13
|
+
"input"=>["test/fixtures/test (1).mp4"],
|
14
|
+
"output"=>[
|
15
|
+
{"video_bitrate"=>676, "filename"=>"sd1.m3u8", "type"=>"segmented", "audio_bitrate"=>128, "height"=>528},
|
16
|
+
{"video_bitrate"=>1172, "filename"=>"sd2.m3u8", "type"=>"segmented", "audio_bitrate"=>128, "height"=>528},
|
17
|
+
{"filename"=>"playlist.m3u8", "type"=>"playlist", "streams"=>[
|
18
|
+
{"path"=>"sd1.m3u8", "bandwidth"=>804}, {"path"=>"sd2.m3u8", "bandwidth"=>1300}
|
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
|
+
]}
|
25
|
+
]
|
26
|
+
)).run
|
30
27
|
end
|
31
|
-
end
|
32
28
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
assert Dir.entries(File.join(@work_dir, 'tmp/sd/r700')).count > 0
|
42
|
-
assert Dir.entries(File.join(@work_dir, 'tmp/ld/r200')).count > 0
|
43
|
-
assert Dir.entries(File.join(@work_dir, 'tmp/ld/r300')).count > 0
|
44
|
-
end
|
45
|
-
should 'create quality playlists' do
|
46
|
-
%w(tmp/sd/r500.m3u8 tmp/sd/r700.m3u8 tmp/ld/r200.m3u8 tmp/ld/r300.m3u8).each do |playlist|
|
47
|
-
assert File.exists?(File.join(@work_dir, playlist))
|
48
|
-
assert File.read(File.join(@work_dir, playlist)).include?('s-00001')
|
49
|
-
end
|
50
|
-
end
|
51
|
-
should 'create group playlists' do
|
52
|
-
playlist = File.join(@work_dir, 'tmp/playlist_sd.m3u8')
|
53
|
-
assert File.exists?(playlist)
|
54
|
-
assert File.read(playlist).include?('r500.m3u8')
|
55
|
-
assert File.read(playlist).include?('r700.m3u8')
|
56
|
-
assert !File.read(playlist).include?('r200.m3u8')
|
57
|
-
assert !File.read(playlist).include?('r300.m3u8')
|
58
|
-
|
59
|
-
playlist = File.join(@work_dir, 'tmp/playlist_ld.m3u8')
|
60
|
-
assert File.exists?(playlist)
|
61
|
-
assert !File.read(playlist).include?('r500.m3u8')
|
62
|
-
assert !File.read(playlist).include?('r700.m3u8')
|
63
|
-
assert File.read(playlist).include?('r200.m3u8')
|
64
|
-
assert File.read(playlist).include?('r300.m3u8')
|
65
|
-
end
|
66
|
-
should 'clear tmp files' do
|
67
|
-
[200, 300, 500, 700].each do |q|
|
68
|
-
assert !File.exists?(File.join(@work_dir, "r#{q}.ts"))
|
29
|
+
should 'generate hls' do
|
30
|
+
%w(sd1 sd2 hd1 hd2).each do |quality|
|
31
|
+
# should create chunks
|
32
|
+
assert_equal ['s-00001.ts', 's-00002.ts'], Dir.entries(File.join(VideoConverter::Output.work_dir, @c.uid, quality)).delete_if { |e| ['.', '..'].include?(e) }.sort
|
33
|
+
# TODO verify that chunks have different quality (weight)
|
34
|
+
# should create playlists
|
35
|
+
assert File.exists?(playlist = File.join(VideoConverter::Output.work_dir, @c.uid, "#{quality}.m3u8"))
|
36
|
+
# TODO verify that playlist is valid (contain all chunks and modifiers)
|
69
37
|
end
|
70
38
|
end
|
71
39
|
end
|
72
40
|
|
73
|
-
context '
|
41
|
+
context 'without transcoding' do
|
74
42
|
setup do
|
75
|
-
|
76
|
-
|
77
|
-
@
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
@res = @c2.run
|
89
|
-
@work_dir = File.join(VideoConverter::Output.work_dir, @c2.uid)
|
90
|
-
end
|
91
|
-
should 'create chunks' do
|
92
|
-
4.times do |n|
|
93
|
-
assert Dir.entries(File.join(@work_dir, "#{n + 1}")).count > 0
|
94
|
-
end
|
95
|
-
end
|
96
|
-
should 'create quality playlists' do
|
97
|
-
4.times do |n|
|
98
|
-
playlist = "#{n + 1}.m3u8"
|
99
|
-
assert File.exists?(File.join(@work_dir, playlist))
|
100
|
-
assert File.read(File.join(@work_dir, playlist)).include?('s-00001')
|
101
|
-
end
|
102
|
-
end
|
103
|
-
should 'create group playlist' do
|
104
|
-
playlist = File.join(@work_dir, 'playlist1.m3u8')
|
105
|
-
assert File.exists?(playlist)
|
106
|
-
assert File.read(playlist).include?('1.m3u8')
|
107
|
-
assert File.read(playlist).include?('2.m3u8')
|
108
|
-
|
109
|
-
playlist = File.join(@work_dir, 'playlist2.m3u8')
|
110
|
-
assert File.exists?(playlist)
|
111
|
-
assert File.read(playlist).include?('3.m3u8')
|
112
|
-
assert File.read(playlist).include?('4.m3u8')
|
113
|
-
end
|
114
|
-
should 'not convert inputs' do
|
115
|
-
assert_equal VideoConverter::Input.new(@input1).metadata[:video_bitrate_in_kbps], VideoConverter::Input.new(File.join(@work_dir, '1.ts')).metadata[:video_bitrate_in_kbps]
|
116
|
-
assert_equal VideoConverter::Input.new(@input2).metadata[:video_bitrate_in_kbps], VideoConverter::Input.new(File.join(@work_dir, '2.ts')).metadata[:video_bitrate_in_kbps]
|
117
|
-
assert_equal VideoConverter::Input.new(@input3).metadata[:video_bitrate_in_kbps], VideoConverter::Input.new(File.join(@work_dir, '3.ts')).metadata[:video_bitrate_in_kbps]
|
118
|
-
assert_equal VideoConverter::Input.new(@input4).metadata[:video_bitrate_in_kbps], VideoConverter::Input.new(File.join(@work_dir, '4.ts')).metadata[:video_bitrate_in_kbps]
|
43
|
+
VideoConverter.paral = false
|
44
|
+
FileUtils.cp("test/fixtures/test (1).mp4", "test/fixtures/test (2).mp4")
|
45
|
+
(@c = VideoConverter.new(
|
46
|
+
:input => ["test/fixtures/test (1).mp4", "test/fixtures/test (2).mp4"],
|
47
|
+
:output => [
|
48
|
+
{:filename=>"q1.m3u8", :path=>"test/fixtures/test (1).mp4", :type=>"segmented", :one_pass=>true, :video_codec=>"copy", :audio_codec=>"copy", :bitstream_format=>"h264_mp4toannexb"},
|
49
|
+
{:filename=>"q2.m3u8", :path=>"test/fixtures/test (2).mp4", :type=>"segmented", :one_pass=>true, :video_codec=>"copy", :audio_codec=>"copy", :bitstream_format=>"h264_mp4toannexb"},
|
50
|
+
{:filename=>"playlist.m3u8", :type=>"playlist", :streams=>[
|
51
|
+
{:path=>"q1.m3u8", :bandwidth=>464}, {:path=>"q2.m3u8", :bandwidth=>928}
|
52
|
+
]}
|
53
|
+
]
|
54
|
+
)).run
|
55
|
+
FileUtils.rm("test/fixtures/test (2).mp4")
|
119
56
|
end
|
120
|
-
end
|
121
57
|
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
58
|
+
should 'generate hls' do
|
59
|
+
%w(q1 q2).each do |quality|
|
60
|
+
# should create chunks
|
61
|
+
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
|
62
|
+
# TODO verify that chunks have the same quality (weight)
|
63
|
+
# should create playlists
|
64
|
+
assert File.exists?(playlist = File.join(VideoConverter::Output.work_dir, @c.uid, "#{quality}.m3u8"))
|
65
|
+
# TODO verify that playlist is valid (contain all chunks and modifiers)
|
130
66
|
end
|
131
67
|
end
|
132
|
-
should 'convert outputs' do
|
133
|
-
assert File.exists? File.join(@c.output_array.outputs.first.work_dir, 'test1.mp4')
|
134
|
-
assert File.exists? File.join(@c.output_array.outputs.first.work_dir, 'test2.mp4')
|
135
|
-
end
|
136
68
|
end
|
137
69
|
end
|
138
70
|
end
|
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.4.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-04-01 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: video_screenshoter
|
@@ -106,19 +106,22 @@ files:
|
|
106
106
|
- README.md
|
107
107
|
- Rakefile
|
108
108
|
- lib/video_converter.rb
|
109
|
+
- lib/video_converter/array.rb
|
109
110
|
- lib/video_converter/base.rb
|
110
111
|
- lib/video_converter/command.rb
|
111
112
|
- lib/video_converter/ffmpeg.rb
|
113
|
+
- lib/video_converter/hash.rb
|
112
114
|
- lib/video_converter/hls.rb
|
113
115
|
- lib/video_converter/input.rb
|
114
|
-
- lib/video_converter/input_array.rb
|
115
116
|
- lib/video_converter/live_segmenter.rb
|
117
|
+
- lib/video_converter/object.rb
|
116
118
|
- lib/video_converter/output.rb
|
117
|
-
- lib/video_converter/output_array.rb
|
118
|
-
- lib/video_converter/process.rb
|
119
119
|
- lib/video_converter/version.rb
|
120
|
-
- test/fixtures/
|
121
|
-
- test/
|
120
|
+
- test/fixtures/chunk0.ts
|
121
|
+
- test/fixtures/chunk1.ts
|
122
|
+
- test/fixtures/test (1).mp4
|
123
|
+
- test/fixtures/test_playlist.m3u8
|
124
|
+
- test/hls_test.rb
|
122
125
|
- test/test_helper.rb
|
123
126
|
- test/video_converter_test.rb
|
124
127
|
- video_converter.gemspec
|
@@ -137,7 +140,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
137
140
|
version: '0'
|
138
141
|
segments:
|
139
142
|
- 0
|
140
|
-
hash: -
|
143
|
+
hash: -3922597022375012517
|
141
144
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
142
145
|
none: false
|
143
146
|
requirements:
|
@@ -146,7 +149,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
146
149
|
version: '0'
|
147
150
|
segments:
|
148
151
|
- 0
|
149
|
-
hash: -
|
152
|
+
hash: -3922597022375012517
|
150
153
|
requirements:
|
151
154
|
- ffmpeg, version 1.2 or greated configured with libx264 and libfaac
|
152
155
|
- live_segmenter to convert to hls
|
@@ -156,7 +159,10 @@ signing_key:
|
|
156
159
|
specification_version: 3
|
157
160
|
summary: Ffmpeg, mencoder based converter to mp4, m3u8
|
158
161
|
test_files:
|
159
|
-
- test/fixtures/
|
160
|
-
- test/
|
162
|
+
- test/fixtures/chunk0.ts
|
163
|
+
- test/fixtures/chunk1.ts
|
164
|
+
- test/fixtures/test (1).mp4
|
165
|
+
- test/fixtures/test_playlist.m3u8
|
166
|
+
- test/hls_test.rb
|
161
167
|
- test/test_helper.rb
|
162
168
|
- test/video_converter_test.rb
|
@@ -1,24 +0,0 @@
|
|
1
|
-
# encoding: utf-8
|
2
|
-
|
3
|
-
module VideoConverter
|
4
|
-
class InputArray
|
5
|
-
attr_accessor :inputs
|
6
|
-
|
7
|
-
def initialize inputs, output_array
|
8
|
-
self.inputs = (inputs.is_a?(Array) ? inputs : [inputs]).map { |input| Input.new(input) }
|
9
|
-
output_array.outputs.each do |output|
|
10
|
-
if [:standard, :segmented].include? output.type
|
11
|
-
self.inputs[self.inputs.index { |input| input.to_s == Shellwords.escape(output.path.to_s).gsub("\\\\","\\") }.to_i].outputs << output
|
12
|
-
end
|
13
|
-
end
|
14
|
-
self.inputs.each do |input|
|
15
|
-
output_array.playlists.each do |playlist|
|
16
|
-
unless (groups = input.outputs.select { |output| output.playlist == playlist }).empty?
|
17
|
-
input.output_groups << groups
|
18
|
-
end
|
19
|
-
end
|
20
|
-
input.outputs.select { |output| output.playlist.nil? }.each { |output| input.output_groups << [output] }
|
21
|
-
end
|
22
|
-
end
|
23
|
-
end
|
24
|
-
end
|
@@ -1,21 +0,0 @@
|
|
1
|
-
# encoding: utf-8
|
2
|
-
|
3
|
-
module VideoConverter
|
4
|
-
class OutputArray
|
5
|
-
attr_accessor :outputs, :uid
|
6
|
-
|
7
|
-
def initialize outputs, uid
|
8
|
-
self.uid = uid
|
9
|
-
self.outputs = (outputs.is_a?(Array) ? outputs : [outputs]).map { |output| Output.new(output.merge(:uid => uid)) }
|
10
|
-
self.outputs.select { |output| output.type == :playlist }.each do |playlist|
|
11
|
-
stream_names = playlist.streams.map { |stream| stream[:path] }
|
12
|
-
playlist.items = self.outputs.select { |output| [:standard, :segmented].include?(output.type) && stream_names.include?(output.filename) }
|
13
|
-
playlist.items.each { |item| item.playlist = playlist }
|
14
|
-
end
|
15
|
-
end
|
16
|
-
|
17
|
-
def playlists
|
18
|
-
outputs.select { |output| output.type == :playlist }
|
19
|
-
end
|
20
|
-
end
|
21
|
-
end
|
@@ -1,82 +0,0 @@
|
|
1
|
-
# encoding: utf-8
|
2
|
-
|
3
|
-
module VideoConverter
|
4
|
-
class Process
|
5
|
-
attr_accessor :uid, :status, :progress, :status_progress, :pid, :process_dir, :status_progress_koefs
|
6
|
-
|
7
|
-
class << self
|
8
|
-
attr_accessor :process_dir, :collect_progress_interval
|
9
|
-
end
|
10
|
-
self.process_dir = '/tmp/video_converter_process'
|
11
|
-
self.collect_progress_interval = 10
|
12
|
-
|
13
|
-
def self.find uid
|
14
|
-
if Dir.exists?(File.join(process_dir, uid.to_s))
|
15
|
-
new uid
|
16
|
-
else
|
17
|
-
nil
|
18
|
-
end
|
19
|
-
end
|
20
|
-
|
21
|
-
def initialize uid, output_array = nil
|
22
|
-
self.uid = uid.to_s
|
23
|
-
self.process_dir = File.join(self.class.process_dir, uid)
|
24
|
-
|
25
|
-
unless Dir.exists? process_dir
|
26
|
-
FileUtils.mkdir_p process_dir
|
27
|
-
self.pid = `cat /proc/self/stat`.split[3]
|
28
|
-
self.status = 'started'
|
29
|
-
self.progress = self.status_progress = 0
|
30
|
-
end
|
31
|
-
|
32
|
-
if output_array
|
33
|
-
self.status_progress_koefs = {}
|
34
|
-
self.status_progress_koefs[:screenshot] = 0.05 if output_array.outputs.detect { |o| o.thumbnails }
|
35
|
-
self.status_progress_koefs[:segment] = 0.1 if output_array.playlists.any?
|
36
|
-
self.status_progress_koefs[:convert] = 1 - status_progress_koefs.values.inject(0, :+)
|
37
|
-
else
|
38
|
-
self.status_progress_koefs = { :screenshot => 0.05, :segment => 0.1, :convert => 0.85 }
|
39
|
-
end
|
40
|
-
end
|
41
|
-
|
42
|
-
# attr are saved in local file
|
43
|
-
def get_attr attr
|
44
|
-
File.open(File.join(process_dir, "#{attr}.txt"), 'r') { |f| f.read } rescue nil
|
45
|
-
end
|
46
|
-
def set_attr attr, value
|
47
|
-
File.open(File.join(process_dir, "#{attr}.txt"), 'w') { |f| f.write value }
|
48
|
-
end
|
49
|
-
|
50
|
-
[:progress, :status_progress, :pid].each do |attr|
|
51
|
-
define_method attr do
|
52
|
-
get_attr attr
|
53
|
-
end
|
54
|
-
define_method "#{attr}=" do |value|
|
55
|
-
set_attr attr, value
|
56
|
-
end
|
57
|
-
end
|
58
|
-
|
59
|
-
def status
|
60
|
-
get_attr :status
|
61
|
-
end
|
62
|
-
|
63
|
-
def status= value
|
64
|
-
set_attr :status, value
|
65
|
-
FileUtils.mkdir_p progress_dir if %w(convert).include?(value.to_s)
|
66
|
-
self.progress = self.status_progress = 1 if value.to_s == 'finished'
|
67
|
-
end
|
68
|
-
|
69
|
-
def stop
|
70
|
-
`pkill -P #{pid}`
|
71
|
-
end
|
72
|
-
|
73
|
-
def collect_progress progress
|
74
|
-
self.status_progress = progress
|
75
|
-
self.progress = progress * status_progress_koefs[status.to_sym].to_f
|
76
|
-
end
|
77
|
-
|
78
|
-
def progress_dir
|
79
|
-
File.join process_dir, 'progress', status
|
80
|
-
end
|
81
|
-
end
|
82
|
-
end
|
data/test/fixtures/test.mp4
DELETED
Binary file
|
data/test/input_test.rb
DELETED
@@ -1,73 +0,0 @@
|
|
1
|
-
require 'test_helper'
|
2
|
-
|
3
|
-
class InputTest < Test::Unit::TestCase
|
4
|
-
setup do
|
5
|
-
@test_url = 'http://techslides.com/demos/sample-videos/small.mp4'
|
6
|
-
end
|
7
|
-
|
8
|
-
context 'exists' do
|
9
|
-
context 'when it is local' do
|
10
|
-
context 'and exists' do
|
11
|
-
should 'return true' do
|
12
|
-
assert VideoConverter::Input.new('test/fixtures/test.mp4').exists?
|
13
|
-
end
|
14
|
-
end
|
15
|
-
|
16
|
-
context 'and does not exist or is not file' do
|
17
|
-
should 'return false' do
|
18
|
-
assert !VideoConverter::Input.new('test/fixtures/not_existed_file').exists?
|
19
|
-
assert !VideoConverter::Input.new('test/fixtures').exists?
|
20
|
-
end
|
21
|
-
end
|
22
|
-
end
|
23
|
-
|
24
|
-
context 'when it is http' do
|
25
|
-
context 'and exists' do
|
26
|
-
should 'return true' do
|
27
|
-
assert VideoConverter::Input.new(@test_url)
|
28
|
-
end
|
29
|
-
end
|
30
|
-
|
31
|
-
context 'when does not exist or is not file' do
|
32
|
-
should 'return false' do
|
33
|
-
assert !VideoConverter::Input.new(@test_url + 'any').exists?
|
34
|
-
assert !VideoConverter::Input.new(URI.parse(@test_url).host).exists?
|
35
|
-
end
|
36
|
-
end
|
37
|
-
end
|
38
|
-
end
|
39
|
-
|
40
|
-
context 'metadata' do
|
41
|
-
context 'when input does not exist' do
|
42
|
-
should 'be empty' do
|
43
|
-
assert VideoConverter::Input.new('test/fixtures/not_existed_file').metadata.empty?
|
44
|
-
end
|
45
|
-
end
|
46
|
-
|
47
|
-
context 'when input is dir' do
|
48
|
-
should 'be empty' do
|
49
|
-
assert VideoConverter::Input.new('tmp/fixtures').metadata.empty?
|
50
|
-
end
|
51
|
-
end
|
52
|
-
|
53
|
-
context 'when input is not video file' do
|
54
|
-
should 'be empty' do
|
55
|
-
assert VideoConverter::Input.new(__FILE__).metadata.empty?
|
56
|
-
end
|
57
|
-
end
|
58
|
-
|
59
|
-
context 'when input is video file' do
|
60
|
-
should 'not be empty' do
|
61
|
-
h = {:audio_bitrate_in_kbps=>97, :audio_codec=>"aac", :audio_sample_rate=>44100, :channels=>2, :duration_in_ms=>4019, :total_bitrate_in_kbps=>1123, :frame_rate=>29.97, :height=>360, :video_bitrate_in_kbps=>1020, :video_codec=>"h264", :width=>640, :file_size_in_bytes=>564356, :format=>"mp4"}
|
62
|
-
assert_equal h, VideoConverter::Input.new('test/fixtures/test.mp4').metadata
|
63
|
-
end
|
64
|
-
end
|
65
|
-
|
66
|
-
context 'when input is url' do
|
67
|
-
should 'not be empty' do
|
68
|
-
h = {:audio_codec=>"aac", :audio_sample_rate=>48000, :audio_bitrate_in_kbps=>83, :channels=>2, :duration_in_ms=>5570, :total_bitrate_in_kbps=>551, :video_codec=>"h264", :width=>560, :height=>320, :video_bitrate_in_kbps=>465, :frame_rate=>30.0, :file_size_in_bytes=>383631, :format=>"mp4"}
|
69
|
-
assert_equal h, VideoConverter::Input.new(@test_url).metadata
|
70
|
-
end
|
71
|
-
end
|
72
|
-
end
|
73
|
-
end
|