panda/app/models/video.rb

656 строки
20 KiB
Ruby
Исходник Постоянная ссылка Ответственный История

Этот файл содержит невидимые символы Юникода!

Этот файл содержит невидимые символы Юникода, которые могут быть отображены не так, как показано ниже. Если это намеренно, можете спокойно проигнорировать это предупреждение. Используйте кнопку Экранировать, чтобы показать скрытые символы.

class Video < SimpleDB::Base
include LocalStore
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, :thumbnail_position
# 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 self.create_empty
video = Video.create
video.status = 'empty'
video.save
return video
end
def to_sym
'videos'
end
def clipping(position = nil)
Clipping.new(self, position)
end
def clippings
self.thumbnail_percentages.map do |p|
Clipping.new(self, p)
end
end
# Classification
# ==============
def encoding?
['queued', 'processing', 'success', 'error'].include?(self.status)
end
def parent?
['original', 'empty'].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 successful_encodings
self.class.query("['parent' = '#{self.key}'] intersection ['status' = 'success']")
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!
# TODO: should this raise an exception if the file does not exist?
self.delete_from_store
self.encodings.each do |e|
e.delete_from_store
e.destroy!
end
self.destroy!
end
# Location to store video file fetched from S3 for encoding
def tmp_filepath
private_filepath(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
# 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.clipping.url(:screenshot)}" />)
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.clipping.url(:screenshot)}";
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
# Interaction with store
# ======================
def upload_to_store
Store.set(self.filename, self.tmp_filepath)
end
def fetch_from_store
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_store
Store.delete(self.filename)
self.clippings.each { |c| c.delete_from_store }
Store.delete(self.clipping.filename(:screenshot, :default => true))
Store.delete(self.clipping.filename(:thumbnail, :default => true))
rescue AbstractStore::FileDoesNotExistError
false
end
# Returns configured number of 'middle points', for example [25,50,75]
def thumbnail_percentages
n = Panda::Config[:choose_thumbnail]
return [50] if n == false
# Interval length
interval = 100.0 / (n + 1)
# Points is [0,25,50,75,100] for example
points = (0..(n + 1)).map { |p| p * interval }.map { |p| p.to_i }
# Don't include the end points
return points[1..-2]
end
def generate_thumbnail_selection
self.thumbnail_percentages.each do |percentage|
self.clipping(percentage).capture
self.clipping(percentage).resize
end
end
def upload_thumbnail_selection
self.thumbnail_percentages.each do |percentage|
self.clipping(percentage).upload_to_store
self.clipping(percentage).delete_locally
end
end
# Checks that video can accept new file, checks that the video is valid,
# reads some metadata from it, and moves video into a private tmp location.
#
# File is the tempfile object supplied by merb. It looks like
# {
# "content_type"=>"video/mp4",
# "size"=>100,
# "tempfile" => @tempfile,
# "filename" => "file.mov"
# }
#
def initial_processing(file)
raise NoFileSubmitted if !file || file.blank?
raise NotValid unless self.empty?
# Set filename and original filename
self.filename = self.key + File.extname(file[:filename])
# Split out any directory path Windows adds in
self.original_filename = file[:filename].split("\\\\").last
# Move file into tmp location
FileUtils.mv file[:tempfile].path, self.tmp_filepath
self.read_metadata
self.status = "original"
self.save
end
# Uploads video to store, generates thumbnails if required, cleans up
# tempoary file, and adds encodings to the encoding queue.
#
def finish_processing_and_queue_encodings
self.upload_to_store
# Generate thumbnails before we add to encoding queue
self.generate_thumbnail_selection
self.clipping(self.thumbnail_percentages.first).set_as_default
self.upload_thumbnail_selection
self.thumbnail_position = self.thumbnail_percentages.first
self.save
self.add_to_queue
FileUtils.rm self.tmp_filepath
end
# Reads information about the video into attributes.
#
# Raises FormatNotRecognised if the video is not valid
#
def read_metadata
Merb.logger.info "#{self.key}: Reading 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)
# Don't allow videos with a duration of 0
raise FormatNotRecognised if self.duration == 0
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
# ===
# Hash of paramenters for video and encodings when video.xml/yaml requested.
#
# See the specs for an example of what this returns
#
def show_response
r = {
:video => {
:id => self.key,
:status => self.status
}
}
# Common attributes for originals and encodings
if self.status == 'original' or self.encoding?
[:filename, :original_filename, :width, :height, :duration].each do |k|
r[:video][k] = self.send(k)
end
r[:video][:screenshot] = self.clipping.filename(:screenshot)
r[:video][:thumbnail] = self.clipping.filename(:thumbnail)
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)
if uri.user and uri.password
req.basic_auth uri.user, uri.password
end
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_store
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_store
self.generate_thumbnail_selection
self.clipping.set_as_default
self.upload_thumbnail_selection
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