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 CHANGED
@@ -17,3 +17,5 @@ test/version_tmp
17
17
  test/fixtures/*.log*
18
18
  tmp
19
19
  *.swp
20
+ log
21
+ .tags
@@ -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
- [:convert, :segment, :screenshot].each { |action| success &&= send(action) }
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
- # TODO delete logs and ts file for segmented type if clear_tmp is true
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 -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'
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 :inputs, :outputs
34
+ attr_accessor :input, :group
29
35
 
30
- def initialize inputs, outputs
31
- self.inputs = inputs
32
- self.outputs = outputs
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
- 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
47
- end
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 }
52
- else
53
- success &&= command.execute
54
- end
55
- end
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
- 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}"
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 => VideoConverter.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
- ffmpeg_option += ' ' + output.send(output_option).to_s
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
  }
@@ -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
- else
22
- value = value.to_s.shellescape
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
- outputs.select { |output| output.type == 'playlist' }.each_with_index do |playlist, index|
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) && (!output.path || output.path == input.to_s) }
27
+ output_group = outputs.select { |output| paths.include?(output.filename) }
22
28
  if output_group.any?
23
- output_group.each { |output| output.passlogfile = File.join(output.work_dir, "group#{index}.log") }
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 metadata
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
- s = `#{Command.new self.class.metadata_command, :ffprobe_bin => Ffmpeg.ffprobe_bin, :input => input}`.encode!('UTF-8', 'UTF-8', :invalid => :replace)
48
- if (m = s.match(/Stream.*?Audio:\s*(\w+).*?(\d+)\s*Hz.*?(\d+)\s*kb\/s.*?$/).to_a).any?
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
- if (m = s.scan(/Stream #\d+:\d+/)).any?
54
- metadata[:channels] = m.count
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 (m = s.match(/Stream.*?Video:\s(\S+).*?,\s*((\d+)x(\d+)).*?(\d+)\s*kb\/s.*?([\d.]+)\s*fps/).to_a).any?
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 metadata.any?
68
- if is_http?
69
- url = URI.parse(input)
70
- Net::HTTP.start(url.host) do |http|
71
- response = http.request_head url.path
72
- metadata[:file_size_in_bytes] = response['content-length'].to_i
73
- end
74
- elsif is_local?
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
- metadata[:format] = File.extname(input).sub('.', '')
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
- def is_http?
83
- !!input.match(/^http:\/\//)
84
- end
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
- def is_local?
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, :segment_seconds, :chunk_prefix, :encoding_profile, :select_streams, :command
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} -vcodec copy -acodec copy -f mpegts pipe:1 2>>/dev/null | %{bin} %{segment_seconds} %{chunks_dir} %{chunk_prefix} %{encoding_profile} 1>>%{log} 2>&1'
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 :inputs, :outputs
16
+ attr_accessor :input, :group
18
17
 
19
- def initialize inputs, outputs
20
- self.inputs = inputs
21
- self.outputs = outputs
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
- 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
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
- :segment_seconds => self.class.segment_seconds,
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 => VideoConverter.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.keyframe_interval = 250
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, :frame_rate
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
@@ -1,3 +1,3 @@
1
1
  module VideoConverter
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -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/convertation.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
@@ -2,7 +2,7 @@ require 'test_helper'
2
2
 
3
3
  class HlsTest < Test::Unit::TestCase
4
4
  setup do
5
- @concat = "/tmp/test_concat.mp4"
5
+ @concat = "tmp/test_concat.mp4"
6
6
  VideoConverter::Hls.new({:input=>"test/fixtures/test_playlist.m3u8", :output=>@concat}).concat
7
7
  end
8
8
 
data/test/test_helper.rb CHANGED
@@ -3,3 +3,6 @@ $:.unshift File.expand_path('../lib', File.dirname(__FILE__))
3
3
  require 'test/unit'
4
4
  require 'shoulda-context'
5
5
  require 'video_converter'
6
+
7
+ VideoConverter::Command.verbose = true
8
+ VideoConverter::Output.work_dir = 'tmp'
@@ -1,39 +1,95 @@
1
1
  require 'test_helper'
2
2
 
3
3
  class VideoConverterTest < Test::Unit::TestCase
4
- setup do
5
- VideoConverter::Command.verbose = true
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
- "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
- ]}
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 '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)
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
@@ -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.1"
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.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-04-01 00:00:00.000000000 Z
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.1
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.1
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: -3922597022375012517
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: -3922597022375012517
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