/
video.rb
576 lines (456 loc) · 18.2 KB
/
video.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
class Video < SimpleDB::Base
set_domain Panda::Config[:sdb_videos_domain]
properties :filename, :original_filename, :parent, :status, :duration, :container, :width, :height, :video_codec, :video_bitrate, :fps, :audio_codec, :audio_bitrate, :audio_sample_rate, :profile, :profile_title, :player, :queued_at, :started_encoding_at, :encoding_time, :encoded_at, :last_notification_at, :notification, :updated_at, :created_at
# TODO: state machine for status
# An original video can either be 'empty' if it hasn't had the video file uploaded, or 'original' if it has
# An encoding will have it's original attribute set to the key of the original parent, and a status of 'queued', 'processing', 'success', or 'error'
def to_sym
'videos'
end
# Classification
# ==============
def encoding?
['queued', 'processing', 'success', 'error'].include?(self.status)
end
# Finders
# =======
# Only parent videos (no encodings)
def self.all
self.query("['status' = 'original'] intersection ['created_at' != ''] sort 'created_at' desc", :load_attrs => true) # TODO: Don't throw an exception if attrs for a record in the search can't be found - it probably means its just been deleted
end
def self.recent_videos
self.query("['status' = 'original']", :max_results => 10, :load_attrs => true)
end
def self.recent_encodings
self.query("['status' = 'success']", :max_results => 10, :load_attrs => true)
end
def self.queued_encodings
self.query("['status' = 'processing' or 'status' = 'queued']", :load_attrs => true)
end
def self.next_job
# TODO: change to outstanding_jobs and remove .first
self.query("['status' = 'queued']").first
end
def self.outstanding_notifications
self.query("['notification' != 'success' and 'notification' != 'error'] intersection ['status' = 'success' or 'status' = 'error']") # sort 'last_notification_at' asc
end
# def self.recently_completed_videos
# self.query("['status' = 'success']")
# end
def parent_video
self.class.find(self.parent)
end
def encodings
self.class.query("['parent' = '#{self.key}']")
end
def find_encoding_for_profile(p)
self.class.query("['parent' = '#{self.key}'] intersection ['profile' = '#{p.key}']")
end
# Attr helpers
# ============
# Delete an original video and all it's encodings.
def obliterate!
self.delete_from_s3
self.encodings.each do |e|
e.delete_from_s3
e.destroy!
end
self.destroy!
end
# Location to store video file fetched from S3 for encoding
def tmp_filepath
Panda::Config[:tmp_video_dir] / self.filename
end
# Has the actual video file been uploaded for encoding?
def empty?
self.status == 'empty'
end
def upload_redirect_url
Panda::Config[:upload_redirect_url].gsub(/\$id/,self.key)
end
def state_update_url
Panda::Config[:state_update_url].gsub(/\$id/,self.key)
end
def duration_str
s = (self.duration.to_i || 0) / 1000
"#{sprintf("%02d", s/60)}:#{sprintf("%02d", s%60)}"
end
def resolution
self.width ? "#{self.width}x#{self.height}" : nil
end
def video_bitrate_in_bits
self.video_bitrate.to_i * 1024
end
def audio_bitrate_in_bits
self.audio_bitrate.to_i * 1024
end
def screenshot
self.filename + ".jpg"
end
def thumbnail
self.filename + "_thumb.jpg"
end
def screenshot_url
Store.url(self.screenshot)
end
def thumbnail_url
Store.url(self.thumbnail)
end
# Encding attr helpers
# ====================
def url
Store.url(self.filename)
end
def embed_html
return nil unless self.encoding?
%(<embed src="#{Store.url('flvplayer.swf')}" width="#{self.width}" height="#{self.height}" allowfullscreen="true" allowscriptaccess="always" flashvars="&displayheight=#{self.height}&file=#{self.url}&width=#{self.width}&height=#{self.height}&image=#{self.screenshot_url}" />)
end
def embed_js
return nil unless self.encoding?
%(
<div id="flash_container_#{self.key[0..4]}"><a href="http://www.macromedia.com/go/getflashplayer">Get the latest Flash Player</a> to watch this video.</div>
<script type="text/javascript">
var flashvars = {};
flashvars.file = "#{self.url}";
flashvars.image = "#{self.screenshot_url}";
flashvars.width = "#{self.width}";
flashvars.height = "#{self.height}";
flashvars.fullscreen = "true";
flashvars.controlbar = "over";
var params = {wmode:"transparent",allowfullscreen:"true"};
var attributes = {};
attributes.align = "top";
swfobject.embedSWF("#{Store.url('player.swf')}", "flash_container_#{self.key[0..4]}", "#{self.width}", "#{self.height}", "9.0.115", "#{Store.url('expressInstall.swf')}", flashvars, params, attributes);
</script>
)
end
# S3
# ==
def upload_to_s3
Store.set(self.filename, self.tmp_filepath)
end
def fetch_from_s3
Store.get(self.filename, self.tmp_filepath)
end
# Deletes the video file without raising an exception if the file does
# not exist.
def delete_from_s3
Store.delete(self.filename)
rescue AbstractStore::FileDoesNotExistError
false
end
def capture_thumbnail_and_upload_to_s3
screenshot_tmp_filepath = self.tmp_filepath + ".jpg"
thumbnail_tmp_filepath = self.tmp_filepath + "_thumb.jpg"
t = RVideo::Inspector.new(:file => self.tmp_filepath)
t.capture_frame('50%', screenshot_tmp_filepath)
constrain_to_height = Panda::Config[:thumbnail_height_constrain].to_f
width = (self.width.to_f/(self.height.to_f/constrain_to_height)).to_i
height = constrain_to_height.to_i
GDResize.new.resize(screenshot_tmp_filepath, thumbnail_tmp_filepath, [width,height])
Store.set(self.screenshot, screenshot_tmp_filepath)
Store.set(self.thumbnail, thumbnail_tmp_filepath)
end
# Uploads
# =======
def process
self.valid?
self.read_metadata
self.upload_to_s3
self.add_to_queue
end
def valid?
raise NotValid unless self.empty?
return true
end
def read_metadata
Merb.logger.info "#{self.key}: Meading metadata of video file"
inspector = RVideo::Inspector.new(:file => self.tmp_filepath)
raise FormatNotRecognised unless inspector.valid? and inspector.video?
self.duration = (inspector.duration rescue nil)
self.container = (inspector.container rescue nil)
self.width = (inspector.width rescue nil)
self.height = (inspector.height rescue nil)
self.video_codec = (inspector.video_codec rescue nil)
self.video_bitrate = (inspector.bitrate rescue nil)
self.fps = (inspector.fps rescue nil)
self.audio_codec = (inspector.audio_codec rescue nil)
self.audio_sample_rate = (inspector.audio_sample_rate rescue nil)
raise FormatNotRecognised if self.duration == 0 # Don't allow videos with a duration of 0
# raise FormatNotRecognised if self.width.nil? or self.height.nil? # Little final check we actually have some usable video
end
def create_encoding_for_profile(p)
encoding = Video.new
encoding.status = 'queued'
encoding.filename = "#{encoding.key}.#{p.container}"
# Attrs from the parent video
encoding.parent = self.key
[:original_filename, :duration].each do |k|
encoding.send("#{k}=", self.get(k))
end
# Attrs from the profile
encoding.profile = p.key
encoding.profile_title = p.title
[:container, :width, :height, :video_codec, :video_bitrate, :fps, :audio_codec, :audio_bitrate, :audio_sample_rate, :player].each do |k|
encoding.send("#{k}=", p.get(k))
end
encoding.save
return encoding
end
# TODO: Breakout Profile adding into a different method
def add_to_queue
# Die if there's no profiles!
if Profile.query.empty?
Merb.logger.error "There are no encoding profiles!"
return nil
end
# TODO: Allow manual selection of encoding profiles used in both form and api
# For now we will just encode to all available profiles
Profile.query.each do |p|
if self.find_encoding_for_profile(p).empty?
self.create_encoding_for_profile(p)
end
end
return true
end
# Exceptions
class VideoError < StandardError; end
class NotificationError < StandardError; end
# 404
class NotValid < VideoError; end
# 500
class NoFileSubmitted < VideoError; end
class FormatNotRecognised < VideoError; end
# API
# ===
def show_response
# :filename, :original_filename, :parent, :status, :duration, :container, :width, :height, :video_codec, :video_bitrate, :fps, :audio_codec, :audio_bitrate, :audio_sample_rate, :profile, :profile_title, :player, :encoding_time, :encoded_at, :updated_at, :created_at
r = {:video => {
:id => self.key,
:status => self.status
}
}
# Common attributes for originals and encodings
if self.status == 'original' or self.encoding?
r[:video].merge!([:filename, :original_filename, :screenshot, :thumbnail, :width, :height, :duration].map_to_hash {|k| {k => self.send(k)} })
end
# If the video is a parent, also return the data for all its encodings
if self.status == 'original'
r[:video][:encodings] = self.encodings.map {|e| e.show_response}
end
# Reutrn extra attributes if the video is an encoding
if self.encoding?
r[:video].merge!([:parent, :profile, :profile_title, :encoded_at, :encoding_time].map_to_hash {|k| {k => self.send(k)} })
end
return r
end
def create_response
{:video => {
:id => self.key
}
}
end
# Notifications
# =============
def notification_wait_period
(Panda::Config[:notification_frequency] * self.notification.to_i)
end
def time_to_send_notification?
return true if self.last_notification_at.nil?
Time.now > (self.last_notification_at + self.notification_wait_period)
end
def send_notification
raise "You can only send the status of encodings" unless self.encoding?
self.last_notification_at = Time.now
begin
self.parent_video.send_status_update_to_client
self.notification = 'success'
self.save
Merb.logger.info "Notification successfull"
rescue
# Increment num retries
if self.notification.to_i >= Panda::Config[:notification_retries]
self.notification = 'error'
else
self.notification = self.notification.to_i + 1
end
self.save
raise
end
end
def send_status_update_to_client
Merb.logger.info "Sending notification to #{self.state_update_url}"
params = {"video" => self.show_response.to_yaml}
uri = URI.parse(self.state_update_url)
http = Net::HTTP.new(uri.host, uri.port)
req = Net::HTTP::Post.new(uri.path)
req.form_data = params
response = http.request(req)
unless response.code.to_i == 200# and response.body.match /ok/
ErrorSender.log_and_email("notification error", "Error sending notification for parent video #{self.key} to #{self.state_update_url} (POST)
REQUEST PARAMS
#{"="*60}\n#{params.to_yaml}\n#{"="*60}
RESPONSE
#{response.code} #{response.message} (#{response.body.length})
#{"="*60}\n#{response.body}\n#{"="*60}")
raise NotificationError
end
end
# Encoding
# ========
def ffmpeg_resolution_and_padding
# Calculate resolution and any padding
in_w = self.parent_video.width.to_f
in_h = self.parent_video.height.to_f
out_w = self.width.to_f
out_h = self.height.to_f
begin
aspect = in_w / in_h
rescue
Merb.logger.error "Couldn't do w/h to caculate aspect. Just using the output resolution now."
return %(-s #{self.width}x#{self.height})
end
height = (out_w / aspect.to_f).to_i
height -= 1 if height % 2 == 1
opts_string = %(-s #{self.width}x#{height} )
# Crop top and bottom is the video is too tall, but add top and bottom bars if it's too wide (aspect wise)
if height > out_h
crop = ((height.to_f - out_h) / 2.0).to_i
crop -= 1 if crop % 2 == 1
opts_string += %(-croptop #{crop} -cropbottom #{crop})
elsif height < out_h
pad = ((out_h - height.to_f) / 2.0).to_i
pad -= 1 if pad % 2 == 1
opts_string += %(-padtop #{pad} -padbottom #{pad})
end
return opts_string
end
def ffmpeg_resolution_and_padding_no_cropping
# Calculate resolution and any padding
in_w = self.parent_video.width.to_f
in_h = self.parent_video.height.to_f
out_w = self.width.to_f
out_h = self.height.to_f
begin
aspect = in_w / in_h
aspect_inv = in_h / in_w
rescue
Merb.logger.error "Couldn't do w/h to caculate aspect. Just using the output resolution now."
return %(-s #{self.width}x#{self.height} )
end
height = (out_w / aspect.to_f).to_i
height -= 1 if height % 2 == 1
opts_string = %(-s #{self.width}x#{height} )
# Keep the video's original width if the height
if height > out_h
width = (out_h / aspect_inv.to_f).to_i
width -= 1 if width % 2 == 1
opts_string = %(-s #{width}x#{self.height} )
self.width = width
self.save
# Otherwise letterbox it
elsif height < out_h
pad = ((out_h - height.to_f) / 2.0).to_i
pad -= 1 if pad % 2 == 1
opts_string += %(-padtop #{pad} -padbottom #{pad})
end
return opts_string
end
def recipe_options(input_file, output_file)
{
:input_file => input_file,
:output_file => output_file,
:container => self.container,
:video_codec => self.video_codec,
:video_bitrate_in_bits => self.video_bitrate_in_bits.to_s,
:fps => self.fps,
:audio_codec => self.audio_codec.to_s,
:audio_bitrate => self.audio_bitrate.to_s,
:audio_bitrate_in_bits => self.audio_bitrate_in_bits.to_s,
:audio_sample_rate => self.audio_sample_rate.to_s,
:resolution => self.resolution,
:resolution_and_padding => self.ffmpeg_resolution_and_padding_no_cropping
}
end
def encode_flv_flash
Merb.logger.info "Encoding with encode_flv_flash"
transcoder = RVideo::Transcoder.new
recipe = "ffmpeg -i $input_file$ -ar 22050 -ab $audio_bitrate$k -f flv -b $video_bitrate_in_bits$ -r 24 $resolution_and_padding$ -y $output_file$"
recipe += "\nflvtool2 -U $output_file$"
transcoder.execute(recipe, self.recipe_options(self.parent_video.tmp_filepath, self.tmp_filepath))
end
def encode_mp4_aac_flash
Merb.logger.info "Encoding with encode_mp4_aac_flash"
transcoder = RVideo::Transcoder.new
# Just the video without audio
temp_video_output_file = "#{self.tmp_filepath}.temp.video.mp4"
temp_audio_output_file = "#{self.tmp_filepath}.temp.audio.mp4"
temp_audio_output_wav_file = "#{self.tmp_filepath}.temp.audio.wav"
recipe = "ffmpeg -i $input_file$ -b $video_bitrate_in_bits$ -an -vcodec libx264 -rc_eq 'blurCplx^(1-qComp)' -qcomp 0.6 -qmin 10 -qmax 51 -qdiff 4 -coder 1 -flags +loop -cmp +chroma -partitions +parti4x4+partp8x8+partb8x8 -me hex -subq 5 -me_range 16 -g 250 -keyint_min 25 -sc_threshold 40 -i_qfactor 0.71 $resolution_and_padding$ -r 24 -threads 4 -y $output_file$"
recipe_audio_extraction = "ffmpeg -i $input_file$ -ar 48000 -ac 2 -y $output_file$"
transcoder.execute(recipe, self.recipe_options(self.parent_video.tmp_filepath, temp_video_output_file))
Merb.logger.info "Video encoding done"
unless self.parent_video.audio_codec.blank?
# We have to use nero to encode the audio as ffmpeg doens't support HE-AAC yet
transcoder.execute(recipe_audio_extraction, recipe_options(self.parent_video.tmp_filepath, temp_audio_output_wav_file))
Merb.logger.info "Audio extraction done"
# Convert to HE-AAC
%x(neroAacEnc -br #{self.audio_bitrate_in_bits} -he -if #{temp_audio_output_wav_file} -of #{temp_audio_output_file})
Merb.logger.info "Audio encoding done"
# Squash the audio and video together
FileUtils.rm(self.tmp_filepath) if File.exists?(self.tmp_filepath) # rm, otherwise we end up with multiple video streams when we encode a few times!!
%x(MP4Box -add #{temp_video_output_file}#video #{self.tmp_filepath})
%x(MP4Box -add #{temp_audio_output_file}#audio #{self.tmp_filepath})
# Interleave meta data
%x(MP4Box -inter 500 #{self.tmp_filepath})
Merb.logger.info "Squashing done"
else
Merb.logger.info "This video does't have an audio stream"
FileUtils.mv(temp_video_output_file, self.tmp_filepath)
end
end
def encode_unknown_format
Merb.logger.info "Encoding with encode_unknown_format"
transcoder = RVideo::Transcoder.new
recipe = "ffmpeg -i $input_file$ -f $container$ -vcodec $video_codec$ -b $video_bitrate_in_bits$ -ar $audio_sample_rate$ -ab $audio_bitrate$k -acodec $audio_codec$ -r 24 $resolution_and_padding$ -y $output_file$"
Merb.logger.info "Unknown encoding format given but trying to encode anyway."
transcoder.execute(recipe, recipe_options(self.parent_video.tmp_filepath, self.tmp_filepath))
end
def encode
raise "You can only encode encodings" unless self.encoding?
self.status = "processing"
self.save
begun_encoding = Time.now
begin
encoding = self
parent_obj = self.parent_video
Merb.logger.info "(#{Time.now.to_s}) Encoding #{self.key}"
parent_obj.fetch_from_s3
if self.container == "flv" and self.player == "flash"
self.encode_flv_flash
elsif self.container == "mp4" and self.audio_codec == "aac" and self.player == "flash"
self.encode_mp4_aac_flash
else # Try straight ffmpeg encode
self.encode_unknown_format
end
self.upload_to_s3
self.capture_thumbnail_and_upload_to_s3
self.notification = 0
self.status = "success"
self.encoded_at = Time.now
self.encoding_time = (Time.now - begun_encoding).to_i
self.save
Merb.logger.info "Removing tmp video files"
FileUtils.rm self.tmp_filepath
FileUtils.rm parent_obj.tmp_filepath
Merb.logger.info "Encoding successful"
rescue
self.notification = 0
self.status = "error"
self.save
FileUtils.rm parent_obj.tmp_filepath
Merb.logger.error "Unable to transcode file #{self.key}: #{$!.class} - #{$!.message}"
raise
end
end
end