video_transcoding 0.1.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.
@@ -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