tickyworld 的网页应用已经支持视频拨放一段时间,但都是通过YouTube的嵌入模式实现。我们开始提供新的版本支持视频操作,可以让我们的用户不用受制于YouTube的服务。
我过去曾经参与过一个项目,客户需要视频转码功能,这实在不是个容易达成的需求。需要大量的读取每一个视频、音讯与视频容器的格式再输出符合网页使用与喜好的视频格式。
考虑到这一点,我们决定将转码的工作交给?Encoding.com?。这个网站可以免费让你编码1GB大小的视频,超过1GB容量的文件将采取分级计价收费。
开发的代码如下,我上传了一个178KB容量的两秒视频来测试代码是否成功运作。当测试过程没有发生任何的例外错误后,我继续测试其它更大的外部文件。
阶段一:用户上传视频文件
现在这的新的代码段提供了一个基于 HTML5且可以快速上手的 的上传机制。用CoffeeScript撰写的代码,可以从客户端上传文件到服务器端。
$scope.upload_slide = (upload_slide_form) ->
????
file = document.getElementById(
"slide_file"
).files[0]
????
reader =
new
FileReader()
????
reader.readAsDataURL file
????
reader.onload = (event) ->
??????
result = event.target.result
??????
fileName = document.getElementById(
"slide_file"
).files[0].name
??????
$.post
"/world/upload_slide"
,
????????
data: result
????????
name: fileName
????????
room_id: $scope.room.id
????????
(response_data) ->
??????????
if
response_data.success? is not yes
????????????
console.error
"There was an error uploading the file"
, response_data
??????????
else
????????????
console.log
"Upload successful"
, response_data
????
reader.onloadstart = ->
??????
console.log
"onloadstart"
????
reader.onprogress = (event) ->
??????
console.log
"onprogress"
, event.total, event.loaded, (event.loaded / event.total) * 100
????
reader.onabort = ->
??????
console.log
"onabort"
????
reader.onerror = ->
??????
console.log
"onerror"
????
reader.onloadend = (event) ->
??????
console.log
"onloadend"
, event
最好可以通过 (“slide_file”).files 且经由独立的POST上传每个文件,而不是由一个POST需求上传所有文件。稍后我们会解释这点。
阶段二:验证并上传至 Amazon S3
后端我们运行了Django与RabbitMQ。主要的模块如下:
$ pip
install
'Django>=1.5.2'
'django-celery>=3.0.21'
\
????
'django-storages>=1.1.8'
'lxml>=3.2.3'
'python-magic>=0.4.3'
我建立了两个模块:SlideUploadQueue?用来储存每一次上传的数据,SlideVideoMedia?则是用来储存每个要上传影片的数据。
class
SlideUploadQueue(models.Model):
????
created_by
=
models.ForeignKey(User)
????
created_time
=
models.DateTimeField(db_index
=
True
)
????
original_file
=
models.FileField(
????????
upload_to
=
filename_sanitiser, blank
=
True
, default
=
'')
????
media_type
=
models.ForeignKey(MediaType)
????
encoding_com_tracking_code
=
models.CharField(
????????
default
=
'', max_length
=
24
, blank
=
True
)
????
STATUS_AWAITING_DATA
=
0
????
STATUS_AWAITING_PROCESSING
=
1
????
STATUS_PROCESSING
=
2
????
STATUS_AWAITING_3RD_PARTY_PROCESSING
=
5
????
STATUS_FINISHED
=
3
????
STATUS_FAILED
=
4
????
STATUS_LIST
=
(
????????
(STATUS_AWAITING_DATA,
'Awaiting Data'
),
????????
(STATUS_AWAITING_PROCESSING,
'Awaiting processing'
),
????????
(STATUS_PROCESSING,
'Processing'
),
????????
(STATUS_AWAITING_3RD_PARTY_PROCESSING,
????????????
'Awaiting 3rd-party processing'
),
????????
(STATUS_FINISHED,
'Finished'
),
????????
(STATUS_FAILED,
'Failed'
),
????
)
????
status
=
models.PositiveSmallIntegerField(
????????
default
=
STATUS_AWAITING_DATA, choices
=
STATUS_LIST)
????
class
Meta:
????????
verbose_name
=
'Slide'
????????
verbose_name_plural
=
'Slide upload queue'
????
def
save(
self
,
*
args,
*
*
kwargs):
????????
if
not
self
.created_time:
????????????
self
.created_time
=
\
????????????????
datetime.utcnow().replace(tzinfo
=
pytz.utc)
????????
return
super
(SlideUploadQueue,
self
).save(
*
args,
*
*
kwargs)
????
def
__unicode__(
self
):
????????
if
self
.
id
is
None
:
????????????
return
'new <SlideUploadQueue>'
????????
return
'<SlideUploadQueue> %d'
%
self
.
id
class
SlideVideoMedia(models.Model):
????
converted_file
=
models.FileField(
????????
upload_to
=
filename_sanitiser, blank
=
True
, default
=
'')
????
FORMAT_MP4
=
0
????
FORMAT_WEBM
=
1
????
FORMAT_OGG
=
2
????
FORMAT_FL9
=
3
????
FORMAT_THUMB
=
4
????
supported_formats
=
(
????????
(FORMAT_MP4,
'MPEG 4'
),
????????
(FORMAT_WEBM,
'WebM'
),
????????
(FORMAT_OGG,
'OGG'
),
????????
(FORMAT_FL9,
'Flash 9 Video'
),
????????
(FORMAT_THUMB,
'Thumbnail'
),
????
)
????
mime_types
=
(
????????
(FORMAT_MP4,
'video/mp4'
),
????????
(FORMAT_WEBM,
'video/webm'
),
????????
(FORMAT_OGG,
'video/ogg'
),
????????
(FORMAT_FL9,
'video/mp4'
),
????????
(FORMAT_THUMB,
'image/jpeg'
),
????
)
????
format
=
models.PositiveSmallIntegerField(
????????
default
=
FORMAT_MP4, choices
=
supported_formats)
????
class
Meta:
????????
verbose_name
=
'Slide video'
????????
verbose_name_plural
=
'Slide videos'
????
def
__unicode__(
self
):
????????
if
self
.
id
is
None
:
????????????
return
'new <SlideVideoMedia>'
????????
return
'<SlideVideoMedia> %d'
%
self
.
id
我们的模块皆使用?filename_sanitiser。FileField 自动的将文件名调整成?<model>/<uuid4>.<extention>?格式。整理每个文件名并确保其独一性。我们采用了有时效性签署的网址列让我们可以掌控哪些使用者在使用我们的服务,使用了多久。
def
filename_sanitiser(instance, filename):
????
folder
=
instance.__class__.__name__.lower()
????
ext
=
'jpg'
????
if
'.'
in
filename:
????????
t_ext
=
filename.split(
'.'
)[
-
1
].strip().lower()
????????
if
t_ext !
=
'':
????????????
ext
=
t_ext
????
return
'%s/%s.%s'
%
(folder,
str
(uuid.uuid4()), ext)
拿来测试的文件?testing.mov?将会转换成以下网址:https://our-bucket.s3.amazonaws.com/slideuploadqueue/3fe27193-e87f-4244-9aa2-66409f70ebd3.mov并经由Django Storages 模块上传。
我们通过?Magic?验证从使用者端浏览器上传的文件。Magic可以从文件内容侦测是何种类型的文件。
@verify_auth_token
@return_json
def
upload_slide(request):
????
file_data
=
request.POST.get(
'data'
, '')
????
file_data
=
base64.b64decode(file_data.split(
';base64,'
)[
1
])
????
description
=
magic.from_buffer(file_data)
如果文件类型符合MPEG v4?系统或是Apple QuickTime?电影,我们就知道该文件转码不会有太大问题。如果格式不是上述所提的几种,我们会标志给用户知悉。接着,我们将通过SlideUploadQueue?模块将视频储存到队列并发送一个需求给?RabbitMQ。因为我们使用了Django Storages 模块,文件将自动被上传到 Amazon S3。
slide_upload
=
SlideUploadQueue()
...
slide_upload.status
=
SlideUploadQueue.STATUS_AWAITING_PROCESSING
slide_upload.save()
slide_upload.original_file.\
????
save(
'anything.%s'
%
file_ext, ContentFile(file_data))
slide_upload.save()
task
=
ConvertRawSlideToSlide()
task.delay(slide_upload)
阶段3:发送视频到第三方.
RabbitMQ?将控管?task.delay(slide_upload)?的呼叫。我们现在只需要发送视频档网址与输出格式给Encoding.com。该网站会回复我们一个工作码让我们检查视频转码的进度。
class
ConvertRawSlideToSlide(Task):
????
queue
=
'backend_convert_raw_slides'
????
...
????
def
_handle_video(
self
, slide_upload):
????????
mp4
=
{
????????????
'output'
:
'mp4'
,
????????????
'size'
:
'320x240'
,
????????????
'bitrate'
:
'256k'
,
????????????
'audio_bitrate'
:
'64k'
,
????????????
'audio_channels_number'
:
'2'
,
????????????
'keep_aspect_ratio'
:
'yes'
,
????????????
'video_codec'
:
'mpeg4'
,
????????????
'profile'
:
'main'
,
????????????
'vcodecparameters'
:
'no'
,
????????????
'audio_codec'
:
'libfaac'
,
????????????
'two_pass'
:
'no'
,
????????????
'cbr'
:
'no'
,
????????????
'deinterlacing'
:
'no'
,
????????????
'keyframe'
:
'300'
,
????????????
'audio_volume'
:
'100'
,
????????????
'file_extension'
:
'mp4'
,
????????????
'hint'
:
'no'
,
????????
}
????????
webm
=
{
????????????
'output'
:
'webm'
,
????????????
'size'
:
'320x240'
,
????????????
'bitrate'
:
'256k'
,
????????????
'audio_bitrate'
:
'64k'
,
????????????
'audio_sample_rate'
:
'44100'
,
????????????
'audio_channels_number'
:
'2'
,
????????????
'keep_aspect_ratio'
:
'yes'
,
????????????
'video_codec'
:
'libvpx'
,
????????????
'profile'
:
'baseline'
,
????????????
'vcodecparameters'
:
'no'
,
????????????
'audio_codec'
:
'libvorbis'
,
????????????
'two_pass'
:
'no'
,
????????????
'cbr'
:
'no'
,
????????????
'deinterlacing'
:
'no'
,
????????????
'keyframe'
:
'300'
,
????????????
'audio_volume'
:
'100'
,
????????????
'preset'
:
'6'
,
????????????
'file_extension'
:
'webm'
,
????????????
'acbr'
:
'no'
,
????????
}
????????
ogg
=
{
????????????
'output'
:
'ogg'
,
????????????
'size'
:
'320x240'
,
????????????
'bitrate'
:
'256k'
,
????????????
'audio_bitrate'
:
'64k'
,
????????????
'audio_sample_rate'
:
'44100'
,
????????????
'audio_channels_number'
:
'2'
,
????????????
'keep_aspect_ratio'
:
'yes'
,
????????????
'video_codec'
:
'libtheora'
,
????????????
'profile'
:
'baseline'
,
????????????
'vcodecparameters'
:
'no'
,
????????????
'audio_codec'
:
'libvorbis'
,
????????????
'two_pass'
:
'no'
,
????????????
'cbr'
:
'no'
,
????????????
'deinterlacing'
:
'no'
,
????????????
'keyframe'
:
'300'
,
????????????
'audio_volume'
:
'100'
,
????????????
'file_extension'
:
'ogg'
,
????????????
'acbr'
:
'no'
,
????????
}
????????
flv
=
{
????????????
'output'
:
'fl9'
,
????????????
'size'
:
'320x240'
,
????????????
'bitrate'
:
'256k'
,
????????????
'audio_bitrate'
:
'64k'
,
????????????
'audio_channels_number'
:
'2'
,
????????????
'keep_aspect_ratio'
:
'yes'
,
????????????
'video_codec'
:
'libx264'
,
????????????
'profile'
:
'high'
,
????????????
'vcodecparameters'
:
'no'
,
????????????
'audio_codec'
:
'libfaac'
,
????????????
'two_pass'
:
'no'
,
????????????
'cbr'
:
'no'
,
????????????
'deinterlacing'
:
'no'
,
????????????
'keyframe'
:
'300'
,
????????????
'audio_volume'
:
'100'
,
????????????
'file_extension'
:
'mp4'
,
????????
}
????????
thumbnail
=
{
????????????
'output'
:
'thumbnail'
,
????????????
'time'
:
'5'
,
????????????
'video_codec'
:
'mjpeg'
,
????????????
'keep_aspect_ratio'
:
'yes'
,
????????????
'file_extension'
:
'jpg'
,
????????
}
????????
encoder
=
Encoding(settings.ENCODING_API_USER_ID,
????????????
settings.ENCODING_API_USER_KEY)
????????
resp
=
encoder.add_media(source
=
[slide_upload.original_file.url],
????????????
formats
=
[mp4, webm, ogg, flv, thumbnail])
????????
media_id
=
None
????????
if
resp
is
not
None
and
resp.get(
'response'
)
is
not
None
:
????????????
media_id
=
resp.get(
'response'
).get(
'MediaID'
)
????????
if
media_id
is
None
:
????????????
slide_upload.status
=
SlideUploadQueue.STATUS_FAILED
????????????
slide_upload.save()
????????????
log.error(
'Unable to communicate with encoding.com'
)
????????????
return
False
????????
slide_upload.encoding_com_tracking_code
=
media_id
????????
slide_upload.status
=
\
????????????
SlideUploadQueue.STATUS_AWAITING_3RD_PARTY_PROCESSING
????????
slide_upload.save()
????????
return
True
Encoding.com?推荐一些堪用的Python程序,可用来与它们的服务沟通。我修改了模块一些地方,但还需要修改一些功能才能达到我满意的状态。以下是修改过后目前正在使用的程序代码:
import
httplib
from
lxml
import
etree
import
urllib
from
xml.parsers.expat
import
ExpatError
import
xmltodict
ENCODING_API_URL
=
'manage.encoding.com:80'
class
Encoding(
object
):
????
def
__init__(
self
, userid, userkey, url
=
ENCODING_API_URL):
????????
self
.url
=
url
????????
self
.userid
=
userid
????????
self
.userkey
=
userkey
????
def
get_media_info(
self
, action
=
'GetMediaInfo'
, ids
=
[],
????????
headers
=
{
'Content-Type'
:
'application/x-www-form-urlencoded'
}):
????????
query
=
etree.Element(
'query'
)
????????
nodes
=
{
????????????
'userid'
:
self
.userid,
????????????
'userkey'
:
self
.userkey,
????????????
'action'
: action,
????????????
'mediaid'
:
','
.join(ids),
????????
}
????????
query
=
self
._build_tree(etree.Element(
'query'
), nodes)
????????
results
=
self
._execute_request(query, headers)
????????
return
self
._parse_results(results)
????
def
get_status(
self
, action
=
'GetStatus'
, ids
=
[], extended
=
'no'
,
????????
headers
=
{
'Content-Type'
:
'application/x-www-form-urlencoded'
}):
????????
query
=
etree.Element(
'query'
)
????????
nodes
=
{
????????????
'userid'
:
self
.userid,
????????????
'userkey'
:
self
.userkey,
????????????
'action'
: action,
????????????
'extended'
: extended,
????????????
'mediaid'
:
','
.join(ids),
????????
}
????????
query
=
self
._build_tree(etree.Element(
'query'
), nodes)
????????
results
=
self
._execute_request(query, headers)
????????
return
self
._parse_results(results)
????
def
add_media(
self
, action
=
'AddMedia'
, source
=
[], notify
=
'', formats
=
[],
????????
instant
=
'no'
,
????????
headers
=
{
'Content-Type'
:
'application/x-www-form-urlencoded'
}):
????????
query
=
etree.Element(
'query'
)
????????
nodes
=
{
????????????
'userid'
:
self
.userid,
????????????
'userkey'
:
self
.userkey,
????????????
'action'
: action,
????????????
'source'
: source,
????????????
'notify'
: notify,
????????????
'instant'
: instant,
????????
}
????????
query
=
self
._build_tree(etree.Element(
'query'
), nodes)
????????
for
format
in
formats:
????????????
format_node
=
self
._build_tree(etree.Element(
'format'
),
format
)
????????????
query.append(format_node)
????????
results
=
self
._execute_request(query, headers)
????????
return
self
._parse_results(results)
????
def
_build_tree(
self
, node, data):
????????
for
k, v
in
data.items():
????????????
if
isinstance
(v,
list
):
????????????????
for
item
in
v:
????????????????????
element
=
etree.Element(k)
????????????????????
element.text
=
item
????????????????????
node.append(element)
????????????
else
:
????????????????
element
=
etree.Element(k)
????????????????
element.text
=
v
????????????????
node.append(element)
????????
return
node
????
def
_execute_request(
self
, xml, headers, path
=
'
', method='
POST'):
????????
params
=
urllib.urlencode({
'xml'
: etree.tostring(xml)})
????????
conn
=
httplib.HTTPConnection(
self
.url)
????????
conn.request(method, path, params, headers)
????????
response
=
conn.getresponse()
????????
data
=
response.read()
????????
conn.close()
????????
return
data
????
def
_parse_results(
self
, results):
????????
try
:
????????????
return
xmltodict.parse(results)
????????
except
ExpatError, e:
????????????
print
'Error parsing encoding.com response'
????????????
print
e
????????????
return
None
其他待完成事项包括通过HTTPS-only (加密联机) 使用Encoding.com 严谨的SSL验证,还有一些单元测试。
阶段4:下载所有新的视频档格式
我们有个定期执行的程序,通过RabbitMQ每15秒检查视频转码的进度:
class
CheckUpOnThirdParties(PeriodicTask):
????
run_every
=
timedelta(seconds
=
settings.THIRD_PARTY_CHECK_UP_INTERVAL)
????
...
????
def
_handle_encoding_com(
self
, slides):
????????
format_lookup
=
{
????????????
'mp4'
: SlideVideoMedia.FORMAT_MP4,
????????????
'webm'
: SlideVideoMedia.FORMAT_WEBM,
????????????
'ogg'
: SlideVideoMedia.FORMAT_OGG,
????????????
'fl9'
: SlideVideoMedia.FORMAT_FL9,
????????????
'thumbnail'
: SlideVideoMedia.FORMAT_THUMB,
????????
}
????????
encoder
=
Encoding(settings.ENCODING_API_USER_ID,
????????????
settings.ENCODING_API_USER_KEY)
????????
job_ids
=
[item.encoding_com_tracking_code
for
item
in
slides]
????????
resp
=
encoder.get_status(ids
=
job_ids)
????????
if
resp
is
None
:
????????????
log.error(
'Unable to check up on encoding.com'
)
????????????
return
False
检查Encoding.com的响应来验证每个部分是否正确以利我们继续下去。
if
resp.get(
'response'
)
is
None
:
????
log.error(
'Unable to get response node from encoding.com'
)
????
return
False
resp_id
=
resp.get(
'response'
).get(
'id'
)
if
resp_id
is
None
:
????
log.error(
'Unable to get media id from encoding.com'
)
????
return
False
slide
=
SlideUploadQueue.objects.
filter
(
????
status
=
SlideUploadQueue.STATUS_AWAITING_3RD_PARTY_PROCESSING,
????
encoding_com_tracking_code
=
resp_id)
if
len
(slide) !
=
1
:
????
log.error(
'Unable to find a single record for %s'
%
resp_id)
????
return
False
resp_status
=
resp.get(
'response'
).get(
'status'
)
if
resp_status
is
None
:
????
log.error(
'Unable to get status from encoding.com'
)
????
return
False
if
resp_status !
=
u
'Finished'
:
????
log.debug(
"%s isn't finished, will check back later"
%
resp_id)
????
return
True
formats
=
resp.get(
'response'
).get(
'format'
)
if
formats
is
None
:
????
log.error(
"No output formats were found. Something's wrong."
)
????
return
False
for
format
in
formats:
????
try
:
????????
assert
format
.get(
'status'
)
=
=
u
'Finished'
, \
????????
"%s is not finished. Something's wrong."
%
format
.get('
id
')
????????
output
=
format
.get(
'output'
)
????????
assert
output
in
(
'mp4'
,
'webm'
,
'ogg'
,
'fl9'
,
????????????
'thumbnail'
),
'Unknown output format %s'
%
output
????????
s3_dest
=
format
.get(
's3_destination'
)
????????
assert
'http://encoding.com.result.s3.amazonaws.com/'
\
????????????
in
s3_dest,
'Suspicious S3 url: %s'
%
s3_dest
????????
https_link
=
\
????????????
'https://s3.amazonaws.com/encoding.com.result/%s'
%
\
????????????
s3_dest.split(
'/'
)[
-
1
]
????????
file_ext
=
https_link.split(
'.'
)[
-
1
].strip()
????????
assert
len
(file_ext) >
0
,\
????????????
'Unable to get file extension from %s'
%
https_link
????????
count
=
SlideVideoMedia.objects.
filter
(slide_upload
=
slide,
????????????
format
=
format_lookup[output]).count()
????????
if
count !
=
0
:
????????????
print
'There is already a %s file for this slide'
%
output
????????????
continue
????????
content
=
self
.download_content(https_link)
????????
assert
content
is
not
None
,\
????????????
'There is no content for %s'
%
format
.get(
'id'
)
????
except
AssertionError, e:
????????
log.error(
'A format did not pass all assertions: %s'
%
e)
????????
continue
到这里我们已确认所有事项皆正常,所以我们可以储存所有的视频档了:
media
=
SlideVideoMedia()
media.
format
=
format_lookup[output]
media.converted_file.save(
'blah.%s'
%
file_ext, ContentFile(content))
media.save()
阶段5:经由HTML5播放视频档
在我们的前端网页已经新增了一个有HTML5的影像单元的网页。并采用对每个浏览器都有最佳支持的video.js来显示视频。
? bower
install
video.js
bower caching git:
//github
.com
/videojs/video
.js-component.git
bower cloning git:
//github
.com
/videojs/video
.js-component.git
bower fetching video.js
bower checking out video.js
#v4.0.3
bower copying
/home/mark/
.bower
/cache/video
.js
/5ab058cd60c5615aa38e8e706cd0f307
bower installing video.js
#4.0.3
在我们的首页有包含其他相依的文件:
!!! 5
html(lang="en",)
??
head
????
meta(http-equiv='Content-Type', content='text/html; charset=UTF-8')
????
...
????
link(rel='stylesheet', type='text/css', href="http://www.geek521.com/components/video-js-4.1.0/video-js.css")
????
script(type='text/javascript', src="http://www.geek521.com/components/video-js-4.1.0/video.js")
在Angular.js/JADE-based 框架下的模块,我们引入<video>卷标 与其<source>子卷标。每个视频文件都会有缩图通过<video>卷标的 poster 组件显示,缩图的图像是由我们从视频的前几秒撷取下来。
#main.span12
????
video#example_video_1.video-js.vjs-default-skin(controls, preload="auto", width="640", height="264", poster="{{video_thumbnail}}", data-setup='{"example_option":true}', ng-show="videos")
????????
source(ng-repeat="video in videos", src="http://www.geek521.com/{{video.src}}", type="{{video.type}}")
还会显示出我们转换的每个视频文件格式,并使用在<source>标签。Video.js?会根据使用者使用的浏览器决定播放哪种格式的视频。