video_transcoding 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,21 @@
1
+ #
2
+ # video_transcoding.rb
3
+ #
4
+ # Copyright (c) 2013-2015 Don Melton
5
+ #
6
+
7
+ require 'English'
8
+ require 'shellwords'
9
+ require 'video_transcoding/console'
10
+ require 'video_transcoding/copyright'
11
+ require 'video_transcoding/crop'
12
+ require 'video_transcoding/errors'
13
+ require 'video_transcoding/ffmpeg'
14
+ require 'video_transcoding/handbrake'
15
+ require 'video_transcoding/media'
16
+ require 'video_transcoding/mkvmerge'
17
+ require 'video_transcoding/mkvpropedit'
18
+ require 'video_transcoding/mp4track'
19
+ require 'video_transcoding/mplayer'
20
+ require 'video_transcoding/tool'
21
+ require 'video_transcoding/version'
@@ -0,0 +1,53 @@
1
+ #
2
+ # cli.rb
3
+ #
4
+ # Copyright (c) 2013-2015 Don Melton
5
+ #
6
+
7
+ require 'optparse'
8
+ require 'video_transcoding'
9
+
10
+ module VideoTranscoding
11
+ module CLI
12
+ def run
13
+ begin
14
+ OptionParser.new do |opts|
15
+ define_options opts
16
+ opts.on('-v', '--verbose') { Console.volume += 1 }
17
+ opts.on('-q', '--quiet') { Console.volume -= 1 }
18
+
19
+ opts.on '-h', '--help' do
20
+ puts usage
21
+ exit
22
+ end
23
+
24
+ opts.on '--version' do
25
+ puts about
26
+ exit
27
+ end
28
+ end.parse!
29
+ rescue OptionParser::ParseError => e
30
+ raise UsageError, e
31
+ end
32
+
33
+ Console.info about
34
+ configure
35
+ fail UsageError, 'missing argument' if ARGV.empty?
36
+ ARGV.each { |arg| process_input arg }
37
+ complete if self.respond_to? :complete
38
+ Console.info 'Done.'
39
+ terminate if self.respond_to? :terminate
40
+ exit
41
+ rescue UsageError => e
42
+ warn "#{$PROGRAM_NAME}: #{e}\nTry `#{$PROGRAM_NAME} --help` for more information."
43
+ exit false
44
+ rescue StandardError => e
45
+ Console.error "#{$PROGRAM_NAME}: #{e}"
46
+ exit(-1)
47
+ rescue SignalException
48
+ terminate if self.respond_to? :terminate
49
+ puts
50
+ exit(-1)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,46 @@
1
+ #
2
+ # console.rb
3
+ #
4
+ # Copyright (c) 2013-2015 Don Melton
5
+ #
6
+
7
+ module VideoTranscoding
8
+ module Console
9
+ OFF = 0
10
+ ERROR = 1
11
+ WARN = 2
12
+ INFO = 3
13
+ DEBUG = 4
14
+
15
+ def self.included(base)
16
+ fail "#{self} singleton can't be included by #{base}"
17
+ end
18
+
19
+ def self.extended(base)
20
+ fail "#{self} singleton can't be extended by #{base}" unless base.class == Module
21
+ @speaker = ->(msg) { Kernel.warn msg }
22
+ @volume = WARN
23
+ end
24
+
25
+ extend self
26
+
27
+ attr_accessor :speaker
28
+ attr_accessor :volume
29
+
30
+ def error(msg)
31
+ @speaker.call msg unless @speaker.nil? or @volume < ERROR
32
+ end
33
+
34
+ def warn(msg)
35
+ @speaker.call msg unless @speaker.nil? or @volume < WARN
36
+ end
37
+
38
+ def info(msg)
39
+ @speaker.call msg unless @speaker.nil? or @volume < INFO
40
+ end
41
+
42
+ def debug(msg)
43
+ @speaker.call msg unless @speaker.nil? or @volume < DEBUG
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,9 @@
1
+ #
2
+ # copyright.rb
3
+ #
4
+ # Copyright (c) 2013-2015 Don Melton
5
+ #
6
+
7
+ module VideoTranscoding
8
+ COPYRIGHT = 'Copyright (c) 2013-2015 Don Melton'
9
+ end
@@ -0,0 +1,110 @@
1
+ #
2
+ # crop.rb
3
+ #
4
+ # Copyright (c) 2013-2015 Don Melton
5
+ #
6
+
7
+ module VideoTranscoding
8
+ module Crop
9
+ extend self
10
+
11
+ def detect(path, duration, width, height)
12
+ Console.info "Detecting crop with mplayer..."
13
+ fail "media duration too short: #{duration} second(s)" if duration < 2
14
+ steps = 10
15
+ interval = duration / (steps + 1)
16
+ target_interval = 5 * 60
17
+
18
+ if interval == 0
19
+ steps = 1
20
+ interval = 1
21
+ elsif interval > target_interval
22
+ steps = (duration / target_interval) - 1
23
+ interval = duration / (steps + 1)
24
+ end
25
+
26
+ crop_width, crop_height, crop_x, crop_y = 0, 0, width, height
27
+ last_seconds = Time.now.tv_sec
28
+
29
+ (1..steps).each do |step|
30
+ begin
31
+ IO.popen([
32
+ MPlayer.command_name,
33
+ '-quiet',
34
+ '-benchmark',
35
+ '-vo', 'null',
36
+ '-ao', 'null',
37
+ '-vf', 'cropdetect=24:2',
38
+ path,
39
+ '-ss', (interval * step).to_s,
40
+ '-frames', '10'
41
+ ], :err=>[:child, :out]) do |io|
42
+ io.each do |line|
43
+ seconds = Time.now.tv_sec
44
+
45
+ if seconds - last_seconds >= 3
46
+ Console.warn '...'
47
+ last_seconds = seconds
48
+ end
49
+
50
+ if line =~ /^\[CROP\] .* crop=([0-9]+):([0-9]+):([0-9]+):([0-9]+)/
51
+ d_width, d_height, d_x, d_y = $1.to_i, $2.to_i, $3.to_i, $4.to_i
52
+ crop_width = d_width if crop_width < d_width
53
+ crop_height = d_height if crop_height < d_height
54
+ crop_x = d_x if crop_x > d_x
55
+ crop_y = d_y if crop_y > d_y
56
+ Console.debug line
57
+ end
58
+ end
59
+ end
60
+ rescue SystemCallError => e
61
+ raise "crop detection failed: #{e}"
62
+ end
63
+
64
+ fail "crop detection failed" unless $CHILD_STATUS.exitstatus == 0
65
+ end
66
+
67
+ {
68
+ :top => crop_y,
69
+ :bottom => height - (crop_y + crop_height),
70
+ :left => crop_x,
71
+ :right => width - (crop_x + crop_width)
72
+ }
73
+ end
74
+
75
+ def constrain(raw_crop, width, height)
76
+ crop = raw_crop.dup
77
+ delta_x = crop[:left] + crop[:right]
78
+ delta_y = crop[:top] + crop[:bottom]
79
+ min_delta = (width / 64)
80
+ min_delta += 1 if min_delta.odd?
81
+
82
+ if delta_x > delta_y
83
+ crop[:top], crop[:bottom] = 0, 0
84
+
85
+ if delta_x < min_delta or delta_x >= width
86
+ crop[:left], crop[:right] = 0, 0
87
+ end
88
+ else
89
+ crop[:left], crop[:right] = 0, 0
90
+
91
+ if delta_y < min_delta or delta_y >= height
92
+ crop[:top], crop[:bottom] = 0, 0
93
+ end
94
+ end
95
+
96
+ crop
97
+ end
98
+
99
+ def handbrake_string(crop)
100
+ "#{crop[:top]}:#{crop[:bottom]}:#{crop[:left]}:#{crop[:right]}"
101
+ end
102
+
103
+ def mplayer_string(crop, width, height)
104
+ "#{width - (crop[:left] + crop[:right])}:" +
105
+ "#{height - (crop[:top] + crop[:bottom])}:" +
106
+ "#{crop[:left]}:" +
107
+ "#{crop[:top]}"
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,10 @@
1
+ #
2
+ # errors.rb
3
+ #
4
+ # Copyright (c) 2013-2015 Don Melton
5
+ #
6
+
7
+ module VideoTranscoding
8
+ class UsageError < RuntimeError
9
+ end
10
+ end
@@ -0,0 +1,47 @@
1
+ #
2
+ # ffmpeg.rb
3
+ #
4
+ # Copyright (c) 2013-2015 Don Melton
5
+ #
6
+
7
+ module VideoTranscoding
8
+ module FFmpeg
9
+ extend self
10
+
11
+ COMMAND_NAME = 'ffmpeg'
12
+
13
+ def setup
14
+ Tool.provide(COMMAND_NAME, ['-version']) do |output, status, properties|
15
+ fail "#{COMMAND_NAME} failed during execution" unless status == 0
16
+
17
+ unless output =~ /^ffmpeg version ([0-9.]+)/
18
+ Console.debug output
19
+ fail "#{COMMAND_NAME} version unknown"
20
+ end
21
+
22
+ version = $1
23
+ Console.info "#{$MATCH} found..."
24
+
25
+ unless version =~ /^([0-9]+)\.([0-9]+)/ and (($1.to_i * 100) + $2.to_i) >= 205
26
+ fail "#{COMMAND_NAME} version 2.5.0 or later required"
27
+ end
28
+
29
+ if output =~ /--enable-libfdk-aac/
30
+ properties[:aac_encoder] = 'libfdk_aac'
31
+ elsif output =~ /--enable-libfaac/
32
+ properties[:aac_encoder] = 'libfaac'
33
+ else
34
+ properties[:aac_encoder] = 'aac'
35
+ end
36
+ end
37
+ end
38
+
39
+ def command_name
40
+ Tool.use(COMMAND_NAME)
41
+ end
42
+
43
+ def aac_encoder
44
+ Tool.properties(COMMAND_NAME)[:aac_encoder]
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,56 @@
1
+ #
2
+ # handbrake.rb
3
+ #
4
+ # Copyright (c) 2013-2015 Don Melton
5
+ #
6
+
7
+ module VideoTranscoding
8
+ module HandBrake
9
+ extend self
10
+
11
+ COMMAND_NAME = 'HandBrakeCLI'
12
+
13
+ def setup
14
+ Tool.provide(COMMAND_NAME, ['--preset-list']) do |output, status, _|
15
+ fail "#{COMMAND_NAME} failed during execution" unless status == 0
16
+
17
+ unless output =~ /^HandBrake ([^ ]+)/
18
+ Console.debug output
19
+ fail "#{COMMAND_NAME} version unknown"
20
+ end
21
+
22
+ version = $1
23
+ Console.info "#{$MATCH} found..."
24
+
25
+ unless (version =~ /^([0-9]+)\.([0-9]+)/ and (($1.to_i * 100) + $2.to_i) >= 10) or
26
+ (version =~ /^svn([0-9]+)/ and $1.to_i >= 6536)
27
+ fail "#{COMMAND_NAME} version 0.10.0 or later required"
28
+ end
29
+ end
30
+ end
31
+
32
+ def command_name
33
+ Tool.use(COMMAND_NAME)
34
+ end
35
+
36
+ def aac_encoder
37
+ properties = Tool.properties(COMMAND_NAME)
38
+ return properties[:aac_encoder] if properties.has_key? :aac_encoder
39
+ output = ''
40
+
41
+ begin
42
+ IO.popen([Tool.use(COMMAND_NAME), '--help'], :err=>[:child, :out]) { |io| output = io.read }
43
+ rescue SystemCallError => e
44
+ raise "#{COMMAND_NAME} AAC encoder unknown: #{e}"
45
+ end
46
+
47
+ fail "#{COMMAND_NAME} failed during execution" unless $CHILD_STATUS.exitstatus == 0
48
+
49
+ if output =~ /ca_aac/
50
+ properties[:aac_encoder] = 'ca_aac'
51
+ else
52
+ properties[:aac_encoder] = 'av_aac'
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,323 @@
1
+ #
2
+ # media.rb
3
+ #
4
+ # Copyright (c) 2013-2015 Don Melton
5
+ #
6
+
7
+ module VideoTranscoding
8
+ class Media
9
+ attr_reader :path
10
+
11
+ def initialize(path:, title: nil, autocrop: false, extended: true, allow_directory: true)
12
+ @path = path
13
+ @previews = autocrop ? 10 : 2
14
+ @extended = extended
15
+ @scan = nil
16
+ @stat = File.stat(@path)
17
+
18
+ if @stat.directory?
19
+ fail UsageError, "not a file: #{@path}" unless allow_directory
20
+ @title = title
21
+
22
+ if @title.nil?
23
+ @scan = Media.scan(@path, 0, 2)
24
+ elsif @title < 1
25
+ fail UsageError, "invalid title index: #{@title}"
26
+ end
27
+ else
28
+ fail UsageError, "invalid title index: #{title}" unless title.nil? or title == 1
29
+ @title = 1
30
+ end
31
+
32
+ @scan = Media.scan(@path, @title, @previews) if @scan.nil?
33
+ @first_scan = @scan
34
+ @mp4_scan = nil
35
+ @summary = nil
36
+ @info = nil
37
+ end
38
+
39
+ def summary
40
+ if @summary.nil?
41
+ @summary = ''
42
+ @first_scan.each_line { |line| @summary += line if line =~ /^ *\+ (?!(autocrop|support))/ }
43
+ end
44
+
45
+ @summary
46
+ end
47
+
48
+ def info
49
+ return @info unless @info.nil?
50
+ @info = {}
51
+
52
+ if @title.nil?
53
+ if @scan =~ /\+ title ([0-9]+):\n \+ Main Feature/m
54
+ @title = $1.to_i
55
+ elsif @scan =~ /^\+ title ([0-9]+):$/
56
+ @title = $1.to_i
57
+ else
58
+ @title = 1
59
+ end
60
+
61
+ @scan = Media.scan(@path, @title, @previews)
62
+ end
63
+
64
+ if @scan =~ / libhb: scan thread found ([0-9.]+) valid title/
65
+ fail 'multiple titles found' if $1.to_i > 1
66
+ end
67
+
68
+ @info[:title] = @title
69
+ @info[:size] = @stat.size
70
+ @info[:directory] = @stat.directory?
71
+
72
+ unless @scan =~ /^ \+ duration: ([0-9]{2}):([0-9]{2}):([0-9]{2})$/
73
+ fail 'media duration not found'
74
+ end
75
+
76
+ @info[:duration] = ($1.to_i * 60 * 60) + ($2.to_i * 60) + $3.to_i
77
+
78
+ unless @scan =~ / scan: [0-9]+ previews, ([0-9]+)x([0-9]+), ([0-9.]+) fps, autocrop = ([0-9]+)\/([0-9]+)\/([0-9]+)\/([0-9]+), aspect [0-9.]+:[0-9.]+, PAR [0-9]+:[0-9]+/
79
+ fail 'video information not found'
80
+ end
81
+
82
+ @info[:width] = $1.to_i
83
+ @info[:height] = $2.to_i
84
+ @info[:fps] = $3.to_f
85
+
86
+ if @previews > 2
87
+ @info[:autocrop] = {:top => $4.to_i, :bottom => $5.to_i, :left => $6.to_i, :right => $7.to_i}
88
+ else
89
+ @info[:autocrop] = nil
90
+ end
91
+
92
+ return @info unless @extended
93
+
94
+ unless @scan =~ / \+ audio tracks:\n(.*) \+ subtitle tracks:\n(.*)HandBrake has exited./m
95
+ fail 'audio and subtitle information not found'
96
+ end
97
+
98
+ audio = $1
99
+ subtitle = $2
100
+ @info[:audio] = {}
101
+ @info[:subtitle] = {}
102
+
103
+ audio.each_line do |line|
104
+ if line =~ /^ \+ ([0-9.]+), [^(]+\(([^)]+)\) .*\(([0-9.]+) ch\) .*\(iso639-2: ([a-z]{3})\)/
105
+ track = $1.to_i
106
+ track_info = {}
107
+ track_info[:format] = $2
108
+ track_info[:channels] = $3.to_f
109
+ track_info[:language] = $4
110
+
111
+ if line =~ /([0-9]+)bps/
112
+ track_info[:bps] = $1.to_i
113
+ else
114
+ track_info[:bps] = nil
115
+ end
116
+
117
+ @info[:audio][track] = track_info
118
+ else
119
+ fail "invalid audio track information: #{line}"
120
+ end
121
+ end
122
+
123
+ subtitle.each_line do |line|
124
+ if line =~ /^ \+ ([0-9.]+), .*\(iso639-2: ([a-z]{3})\) \((?:Text|Bitmap)\)\(([^)]+)\)/
125
+ track = $1.to_i
126
+ track_info = {}
127
+ track_info[:language] = $2
128
+ track_info[:format] = $3
129
+ @info[:subtitle][track] = track_info
130
+ else
131
+ fail "invalid subtitle track information: #{line}"
132
+ end
133
+ end
134
+
135
+ @info[:h264] = false
136
+ @info[:mpeg2] = false
137
+ audio_track = 0
138
+ subtitle_track = 0
139
+
140
+ @scan.each_line do |line|
141
+ if line =~ /[ ]+Stream #0\.([0-9]+)[^ ]*: (Video|Audio|Subtitle): (.*)/
142
+ stream = $1.to_i
143
+ type = $2
144
+ attributes = $3
145
+
146
+ case type
147
+ when 'Video'
148
+ unless @info.has_key? :stream
149
+ @info[:stream] = stream
150
+
151
+ if attributes =~ /^h264/
152
+ @info[:h264] = true
153
+ elsif attributes =~ /^mpeg2video/
154
+ @info[:mpeg2] = true
155
+ end
156
+ end
157
+ when 'Audio'
158
+ audio_track += 1
159
+ track_info = @info[:audio][audio_track]
160
+ track_info[:stream] = stream
161
+
162
+ if attributes =~ /\(default\)/
163
+ track_info[:default] = true
164
+ else
165
+ track_info[:default] = false
166
+ end
167
+
168
+ if @scan =~ /[ ]+Stream #0\.#{stream}[^ ]*: Audio: [^\n]+\n[ ]+Metadata:\n^[ ]+title[ ]+: ([^\n]+)/m
169
+ track_info[:name] = $1
170
+ else
171
+ track_info[:name] = nil
172
+ end
173
+ when 'Subtitle'
174
+ subtitle_track += 1
175
+ track_info = @info[:subtitle][subtitle_track]
176
+ track_info[:stream] = stream
177
+
178
+ if attributes =~ /\(default\)/
179
+ track_info[:default] = true
180
+ else
181
+ track_info[:default] = false
182
+ end
183
+
184
+ if attributes =~ /\(forced\)/
185
+ track_info[:forced] = true
186
+ else
187
+ track_info[:forced] = false
188
+ end
189
+
190
+ if @scan =~ /[ ]+Stream #0\.#{stream}[^ ]*: Subtitle: [^\n]+\n[ ]+Metadata:\n^[ ]+title[ ]+: ([^\n]+)/m
191
+ track_info[:name] = $1
192
+ else
193
+ track_info[:name] = nil
194
+ end
195
+ end
196
+ end
197
+ end
198
+
199
+ @info[:bd] = false
200
+ @info[:dvd] = false
201
+
202
+ if @scan =~ / scan: (BD|DVD) has [0-9]+ title/
203
+ @info[$1.downcase.to_sym] = true
204
+ @info[:mpeg2] = true if @info[:dvd]
205
+ end
206
+
207
+ if @scan =~ /Input #0, matroska/
208
+ @info[:mkv] = true
209
+ else
210
+ @info[:mkv] = false
211
+ end
212
+
213
+ if @scan =~ /Input #0, mov,mp4/
214
+ @info[:mp4] = true
215
+ else
216
+ @info[:mp4] = false
217
+ end
218
+
219
+ if @info[:mp4]
220
+ @mp4_scan = Media.scan_mp4(@path)
221
+ types = []
222
+ flags = []
223
+ names = []
224
+
225
+ @mp4_scan.each_line do |line|
226
+ if line =~ /^[ ]+type[ ]+=[ ]+(.*)$/
227
+ types << $1
228
+ elsif line =~ /^[ ]+enabled[ ]+=[ ]+(.*)$/
229
+ if $1 == 'true'
230
+ flags << true
231
+ else
232
+ flags << false
233
+ end
234
+ elsif line =~ /^[ ]+userDataName[ ]+=[ ]+(.*)$/
235
+ if $1 == '<absent>'
236
+ names << nil
237
+ else
238
+ names << $1
239
+ end
240
+ end
241
+ end
242
+
243
+ index = 0
244
+ audio_track = 0
245
+ subtitle_track = 0
246
+
247
+ types.each do |type|
248
+ case type
249
+ when 'audio'
250
+ audio_track += 1
251
+ track_info = @info[:audio][audio_track]
252
+ track_info[:default] = flags[index]
253
+ track_info[:name] = names[index]
254
+ when 'text'
255
+ subtitle_track += 1
256
+ track_info = @info[:subtitle][subtitle_track]
257
+ track_info[:default] = flags[index]
258
+ track_info[:forced] = flags[index]
259
+ track_info[:name] = names[index]
260
+ end
261
+
262
+ index += 1
263
+ end
264
+ end
265
+
266
+ @info
267
+ end
268
+
269
+ def self.scan(path, title, previews)
270
+ output = ''
271
+ label = title == 0 ? 'media' : "media title #{title}"
272
+ Console.info "Scanning #{label} with HandBrakeCLI..."
273
+ last_seconds = Time.now.tv_sec
274
+
275
+ begin
276
+ IO.popen([
277
+ HandBrake.command_name,
278
+ "--title=#{title}",
279
+ '--scan',
280
+ "--previews=#{previews}:0",
281
+ "--input=#{path}"
282
+ ], :err=>[:child, :out]) do |io|
283
+ io.each do |line|
284
+ seconds = Time.now.tv_sec
285
+
286
+ if seconds - last_seconds >= 3
287
+ Console.warn '...'
288
+ last_seconds = seconds
289
+ end
290
+
291
+ line.encode! 'UTF-8', 'binary', invalid: :replace, undef: :replace, replace: ''
292
+ Console.debug line
293
+ output += line
294
+ end
295
+ end
296
+ rescue SystemCallError => e
297
+ raise "scanning #{label} failed: #{e}"
298
+ end
299
+
300
+ fail "scanning #{label} failed" unless $CHILD_STATUS.exitstatus == 0
301
+ output
302
+ end
303
+
304
+ def self.scan_mp4(path)
305
+ output = ''
306
+ Console.info 'Scanning with mp4track...'
307
+
308
+ begin
309
+ IO.popen([
310
+ MP4track.command_name,
311
+ '--list',
312
+ path
313
+ ], :err=>[:child, :out]) { |io| output = io.read }
314
+ rescue SystemCallError => e
315
+ raise "scanning failed: #{e}"
316
+ end
317
+
318
+ Console.debug output
319
+ fail 'scanning failed' unless $CHILD_STATUS.exitstatus == 0
320
+ output
321
+ end
322
+ end
323
+ end