Allow uploading subtitle tracks to videos

when you upload a video, you will now see a [cc]
box in the player for that video if you are
the uploader of that video and in a modern browser.
it will have a link to upload a caption track in
SRT or WebVTT format in any language you specify.

once you attach a caption track to any of your
videos, other people that view it will be able
to choose that track while playing it.
helpful for people that are deaf or that speak
another language!

this change also adds google analytics tracking to
videos so we know when a given media_id was
played, paused, and ended.

Test Plan:
* record a video, go to play the video
* you should see a [cc] button
* click the "upload subtitles" link, a dialog should appear
  that lets you choose a language and file to upload.  here's
  one you can test with:
  http://mediaelementjs.com/media/NT113_u008_v005_transcript.srt
* refresh, you (and anyone else that can see video)
  should now have the option to select that track
  for subtitles. (if using in HTML5 player)
* you should see a delete 'x' on it to delete
  it, when you click it it should go away. reload
  to make sure it is not there.
* The user who created the video, or any admins of the course
  the video is in should be able to manage these captions

closes CNVS-324

Change-Id: Id6d4abcb581f0daf101d601221dc45edaad6eaa8
Reviewed-on: https://gerrit.instructure.com/16882
Tested-by: Jenkins <jenkins@instructure.com>
Reviewed-by: Bracken Mosbacker <bracken@instructure.com>
QA-Review: Adam Phillipps <adam@instructure.com>
This commit is contained in:
Bracken Mosbacker 2013-01-15 14:15:42 -07:00
parent 4bb2413cfd
commit 26ec655a8d
12 changed files with 353 additions and 27 deletions

View File

@ -1,3 +1,4 @@
#mediaComment.coffee
define [
'i18n!media_comments'
'underscore'
@ -20,11 +21,14 @@ define [
# track events in google analytics
mejs.MepDefaults.features.push('googleanalytics')
getSources = (id) ->
getSourcesAndTracks = (id) ->
dfd = new $.Deferred
$.getJSON "/media_objects/#{id}/info", (data) ->
sources = _.map data.media_sources, (source) -> "<source type='#{source.content_type}' src='#{source.url}' />"
dfd.resolve {sources, can_add_captions: false}
tracks = _.map data.media_tracks, (track) ->
languageName = mejs.language.codes[track.locale] || track.locale
"<track kind='#{track.kind}' label='#{languageName}' src='#{track.url}' srclang='#{track.locale}' />"
dfd.resolve {sources, tracks, can_add_captions: data.can_add_captions}
dfd
mediaCommentActions =
@ -49,13 +53,13 @@ define [
showInline = (id) ->
width = Math.min ($holder.closest("div,p,table").width() || VIDEO_WIDTH), VIDEO_WIDTH
height = Math.round width / 336 * 240
getSources(id).done (sources) ->
if sources.sources.length
getSourcesAndTracks(id).done (sourcesAndTracks) ->
if sourcesAndTracks.sources.length
$("#{if mediaType is 'video' then "<video width='#{width}' height='#{height}'" else '<audio'} controls preload autoplay />")
.append(sources.sources.join(''))
.append(sourcesAndTracks.sources.concat(sourcesAndTracks.tracks).join(''))
.appendTo($holder.html(''))
.mediaelementplayer
can_add_captions: false
can_add_captions: sourcesAndTracks.can_add_captions
mediaCommendId: id
googleAnalyticsTitle: id
else
@ -95,14 +99,15 @@ define [
resizable: false
close: -> $this.data('mediaelementplayer').pause()
$dialog.disableWhileLoading getSources(id).done (sources) ->
if sources.sources.length
$dialog.disableWhileLoading getSourcesAndTracks(id).done (sourcesAndTracks) ->
if sourcesAndTracks.sources.length
$mediaElement = $("#{if mediaType is 'video' then "<video width='#{width}' height='#{height - spaceNeededForControls}'" else '<audio'} controls preload autoplay />")
.append(sourcesAndTracks.sources.concat(sourcesAndTracks.tracks).join(''))
.appendTo($dialog)
$this.data
mediaelementplayer: new MediaElementPlayer $mediaElement,
can_add_captions: sources.can_add_captions
can_add_captions: sourcesAndTracks.can_add_captions
mediaCommendId: id
googleAnalyticsTitle: id
media_comment_dialog: $dialog

View File

@ -0,0 +1,65 @@
define [
'i18n!media_comments'
'underscore'
'jst/widget/UploadMediaTrackForm'
'vendor/mediaelement-and-player'
'jquery'
], (I18n, _, template, mejs, $) ->
class UploadMediaTrackForm
# video url needs to be the url to mp4 version of the video.
# it will be passed along to universalsubtitles.org
constructor: (@mediaCommentId, @video_url) ->
templateVars =
languages: _.map(mejs.language.codes, (name, code) -> {name, code})
video_url: @video_url
is_amazon_url: @video_url.search(/.mp4/) != -1
@$dialog = $(template(templateVars))
.appendTo('body')
.dialog
width: 650
resizable: false
buttons: [
'data-text-while-loading' : I18n.t 'cancel', 'Cancel'
text : I18n.t 'cancel', 'Cancel'
click: => @$dialog.remove()
,
class : 'btn-primary'
'data-text-while-loading' : I18n.t 'uploading', 'Uploading...'
text: I18n.t 'upload', 'Upload'
click: @onSubmit
]
onSubmit: =>
submitDfd = new $.Deferred()
submitDfd.fail =>
@$dialog.find('.invalidInputMsg').show()
@$dialog.disableWhileLoading submitDfd
@getFileContent().fail(-> submitDfd.reject()).done (content) =>
params =
content: content
locale: @$dialog.find('[name="locale"]').val()
return submitDfd.reject() unless params.content && params.locale
$.ajaxJSON "/media_objects/#{@mediaCommentId}/media_tracks", 'POST', params, =>
submitDfd.resolve()
@$dialog.dialog('close')
$.flashMessage I18n.t 'track_uploaded_successfully', "Track uploaded successfuly, refresh to see it."
getFileContent: ->
dfd = new $.Deferred
file = @$dialog.find('input[name="content"]')[0].files[0]
if file
reader = new FileReader()
reader.onload = (e) ->
content = e.target.result
dfd.resolve content
reader.readAsText file
else
dfd.reject()
dfd

View File

@ -23,6 +23,24 @@
#
# @object Media Object
# {
# // whether or not the current user can upload media_tracks (subtitles) to this Media Object
# "can_add_captions": true,
# // an array of all the media_tracks uploaded to this Media Object
# "media_tracks": [{
# "kind": "captions",
# "created_at": "2012-09-27T16:46:50-06:00",
# "updated_at": "2012-09-27T16:46:50-06:00",
# "url": "http://<canvas>/media_objects/0_r949z9lk/media_tracks/1",
# "id": 1,
# "locale": "af"
# }, {
# "kind": "subtitles",
# "created_at": "2012-09-27T20:29:17-06:00",
# "updated_at": "2012-09-27T20:29:17-06:00",
# "url": "http://<canvas>/media_objects/0_r949z9lk/media_tracks/14",
# "id": 14,
# "locale": "cs"
# }],
# // an array of all the transcoded files (flavors) available for this Media Object
# "media_sources": [{
# "height": "240",
@ -55,7 +73,7 @@ class MediaObjectsController < ApplicationController
# Returns the Details of the given Media Object.
#
# @example_request
# curl https://<canvas>/api/v1/media_objects/<media_object_id> \
# curl https://<canvas>/media_objects/<media_object_id> \
# -H 'Authorization: Bearer <token>'
#
# @returns Media Object

View File

@ -0,0 +1,88 @@
#
# Copyright (C) 2012 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
# @API Media Objects
class MediaTracksController < ApplicationController
include Api::V1::MediaObject
TRACK_SETTABLE_ATTRIBUTES = [:kind, :locale, :content]
# @{not an}API Create a media track
#
# Create a new media track to be used as captions for different languages or deaf users. for more info, {https://developer.mozilla.org/en-US/docs/HTML/HTML_Elements/track read the MDN docs}
#
# @argument kind one of: [subtitles, captions, descriptions, chapters, metadata]. default: 'subtitles'
# @argument locale Language code of the track being uploaded, examples: ["en", "es", "ru"]
# @argument content The contets of the track, in SRT or WebVTT format
#
# @example_request
# curl https://<canvas>/media_objects/<media_object_id>/media_tracks \
# -F kind='subtitles' \
# -F locale='es' \
# -F content='0\n00:00:00,000 --> 00:00:01,000\nInstructor…This is the first sentance\n\n\n1\n00:00:01,000 --> 00:00:04,000\nand a second...' \
# -H 'Authorization: Bearer <token>'
#
# @returns Media Object
def create
@media_object = MediaObject.active.by_media_id(params[:media_object_id]).first
if authorized_action(@media_object, @current_user, :add_captions)
track = @media_object.media_tracks.find_or_initialize_by_user_id_and_locale(@current_user.id, params[:locale])
track.update_attributes! params.slice(*TRACK_SETTABLE_ATTRIBUTES)
render :json => media_object_api_json(@media_object, @current_user, session)
end
end
# @{not an}API Get the content of a Media Track
#
# returns the actual content of the uploaded media track.
#
# @example_request
# curl https://<canvas>/media_objects/<media_object_id>/media_tracks/<media_track_id> \
# -H 'Authorization: Bearer <token>'
#
def show
@media_track = MediaTrack.find params[:id]
if stale? :etag => @media_track, :last_modified => @media_track.updated_at.utc
render :text => @media_track.content
end
end
# @{not an}API Delete a Media Track
#
# Deletes the media track.
#
# @example_request
# curl -X DELETE https://<canvas>/media_objects/<media_object_id>/media_tracks/<media_track_id> \
# -H 'Authorization: Bearer <token>'
#
# @returns Media Object
def destroy
@media_object = MediaObject.by_media_id(params[:media_object_id]).first
if authorized_action(@media_object, @current_user, :delete_captions)
@track = @media_object.media_tracks.find(params[:media_track_id])
if @track.destroy
render :json => media_object_api_json(@media_object, @current_user, session)
else
render :json => @track.errors.to_json, :status => :bad_request
end
end
end
end

View File

@ -23,6 +23,7 @@ class MediaObject < ActiveRecord::Base
belongs_to :attachment
belongs_to :root_account, :class_name => 'Account'
validates_presence_of :media_id
has_many :media_tracks, :dependent => :destroy, :order => 'locale'
after_create :retrieve_details_later
after_save :update_title_on_kaltura_later
serialize :data
@ -35,12 +36,12 @@ class MediaObject < ActiveRecord::Base
@push_user_title = true
write_attribute(:user_entered_title, val)
end
def update_title_on_kaltura_later
send_later(:update_title_on_kaltura) if @push_user_title
@push_user_title = nil
end
def self.find_by_media_id(media_id)
unless Rails.env.production?
raise "Do not look up MediaObjects by media_id - use the scope by_media_id instead to support migrated content."
@ -48,6 +49,12 @@ class MediaObject < ActiveRecord::Base
super
end
set_policy do
given { |user| self.user && (self.user == user || (self.context && self.context.grants_right?(user, nil, :manage_content))) }
can :add_captions and can :delete_captions
end
# if wait_for_completion is true, this will wait SYNCHRONOUSLY for the bulk
# upload to complete. Wrap it in a timeout if you ever want it to give up
# waiting.
@ -86,7 +93,7 @@ class MediaObject < ActiveRecord::Base
end
res
end
def self.bulk_migration(csv, root_account_id)
client = Kaltura::ClientV3.new
client.startSession(Kaltura::SessionType::ADMIN)
@ -98,7 +105,7 @@ class MediaObject < ActiveRecord::Base
end
res
end
def self.migration_csv(media_objects)
FasterCSV.generate do |csv|
media_objects.each do |mo|
@ -186,7 +193,7 @@ class MediaObject < ActiveRecord::Base
end
res
end
def retrieve_details_later
send_later(:retrieve_details_ensure_codecs)
end
@ -208,11 +215,11 @@ class MediaObject < ActiveRecord::Base
end
end
end
def name
self.title
end
def retrieve_details
return unless self.media_id
# From Kaltura, retrieve the title (if it's not already set)
@ -245,7 +252,7 @@ class MediaObject < ActiveRecord::Base
self.save
self.data
end
def podcast_format_details
data = self.data && self.data[:extensions] && self.data[:extensions][:mp3]
data ||= self.data && self.data[:extensions] && self.data[:extensions][:mp4]
@ -256,7 +263,7 @@ class MediaObject < ActiveRecord::Base
end
data
end
def delete_from_remote
return unless self.media_id
@ -264,14 +271,14 @@ class MediaObject < ActiveRecord::Base
client.startSession(Kaltura::SessionType::ADMIN)
client.mediaDelete(self.media_id)
end
alias_method :destroy!, :destroy
def destroy
self.workflow_state = 'deleted'
self.attachment.destroy if self.attachment
save!
end
def data
self.read_attribute(:data) || self.write_attribute(:data, {})
end
@ -280,30 +287,30 @@ class MediaObject < ActiveRecord::Base
send_later(:updated_viewed_at_and_retrieve_details, Time.now) if !self.data[:last_viewed_at] || self.data[:last_viewed_at] > 1.hour.ago
true
end
def updated_viewed_at_and_retrieve_details(time)
self.data[:last_viewed_at] = [time, self.data[:last_viewed_at]].compact.max
self.retrieve_details
end
def destroy_without_destroying_attachment
self.workflow_state = 'deleted'
self.attachment_id = nil
save!
end
named_scope :active, lambda{
{:conditions => ['media_objects.workflow_state != ?', 'deleted'] }
}
named_scope :by_media_id, lambda { |media_id|
{ :conditions => [ 'media_objects.media_id = ? OR media_objects.old_media_id = ?', media_id, media_id ] }
}
named_scope :by_media_type, lambda { |media_type|
{ :conditions => [ 'media_objects.media_type = ?', media_type ]}
}
workflow do
state :active
state :deleted

View File

@ -0,0 +1,6 @@
class MediaTrack < ActiveRecord::Base
belongs_to :user
belongs_to :media_object, :touch => true
validates_presence_of :media_object_id, :content
attr_accessible :user_id, :kind, :locale, :content
end

View File

@ -0,0 +1,26 @@
.uploadMediaTrackForm{
.media-track-video-url{
background-color: #F4F3EE;
display: block;
text-align: center;
padding: 10px;
margin-bottom: 10px;
}
.media-track-content-box{
padding: 10px;
margin: 0 auto;
display: block;
margin-bottom: 10px;
form{
padding: 0px;
}
}
.media-track-form-button{
padding: 10px;
display: block;
margin: 0 auto;
width: 100%;
}
}

View File

@ -0,0 +1,77 @@
<div title="Create/Add Subtitles" class="uploadMediaTrackForm bootstrap-form form-horizontal">
{{#if is_amazon_url}}
<p class="alert alert-info">{{#t "upload_media_track_info"}}<strong>Instructions:</strong>
Follow these three steps to create a subtitle file for your video, then upload it here.
If you all ready have a srf subtitle file you can skip to step 3.{{/t}}
</p>
{{/if}}
<div class="content-box border border-trbl border-round">
{{#if is_amazon_url}}
<div class="media-track-step">
<p class="uploadMediaTrackFormDescription">
<strong>{{#t "upload_media_track_form_step1_label"}}Step 1:{{/t}}</strong>
{{#t "upload_media_track_form_description_1"}}Copy this video url{{/t}}
</p>
<div class="content-callout media-track-video-url">{{video_url}}</div>
</div><!-- media track step -->
<div class="media-track-step">
<p class="uploadMediaTrackFormDescription">
<strong>{{#t "upload_media_track_form_step2_label"}}Step 2:{{/t}}</strong>
{{#t "upload_media_track_form_description_2"}}Create a subtitle file by clicking linking this link and following its instructions.{{/t}}
</p>
<div class="content-box media-track-content-box">
<form action="http://www.universalsubtitles.org/en/videos/create/" method="POST" target="_blank" >
<input type="hidden" name="video_url" value="{{video_url}}">
<button class="btn btn-small media-track-form-button" type="submit" value="Begin">{{#t "media_track_form_button"}}Go to subtitle creation tool{{/t}}</button>
</form>
</div>
</div><!-- media track step -->
{{/if}}
<div class="media-track-step">
<p class="uploadMediaTrackFormDescription">
{{#if is_amazon_url}}
<strong>{{#t "upload_media_track_form_step3_label"}}Step 3:{{/t}}</strong>
{{#t "upload_media_track_form_description"}}
Once you have a subtitle track in either the SRT or <a target="_blank" href="http://dev.w3.org/html5/webvtt/">WebVTT</a> format,
you can upload it here.
{{/t}}
{{ else }}
{{#t "upload_media_track_form_description_3"}}
Upload a subtitle track in either the SRT or <a target="_blank" href="http://dev.w3.org/html5/webvtt/">WebVTT</a> format.
{{/t}}
{{/if}}
</p>
<div class="content-box media-track-content-box">
<div class="invalidInputMsg alert alert-error" style="display:none;">
{{#t "error_message"}}<strong>Error:</strong> You must choose a language and a valid track file.{{/t}}
</div>
<div class="control-group">
<label class="control-label" for="umtf_locale">{{#t "language"}}Language{{/t}}</label>
<div class="controls">
<select name="locale" id="umtf_locale">
<option value=''>{{#t "choose_a_language"}}--Choose a Language--{{/t}}</option>
{{#each languages}}
<option value="{{code}}">{{name}}</option>
{{/each}}
</select>
</div>
</div><!-- control group end -->
<div class="control-group">
<label class="control-label" for="umtf_content">{{#t "file"}}File{{/t}}</label>
<div class="controls">
<input class="input-file" id="umtf_content" name="content" type="file">
</div>
</div><!-- control group end -->
</div><!-- content-box end -->
</div><!-- media track step -->
</div><!-- end border border-tbl -->
</div>

View File

@ -343,6 +343,10 @@ ActionController::Routing::Routes.draw do |map|
map.media_object_thumbnail 'media_objects/:id/thumbnail', :controller => 'context', :action => 'media_object_thumbnail'
map.media_object_info 'media_objects/:media_object_id/info', :controller => 'media_objects', :action => 'show'
map.show_media_tracks "media_objects/:media_object_id/media_tracks/:id", :controller => :media_tracks, :action => :show, :conditions => {:method => :get}
map.create_media_tracks 'media_objects/:media_object_id/media_tracks', :controller => :media_tracks, :action => :create, :conditions => {:method => :post}
map.delete_media_tracks "media_objects/:media_object_id/media_tracks/:media_track_id", :controller => :media_tracks, :action => :destroy, :conditions => {:method => :delete}
map.external_content_success 'external_content/success/:service', :controller => 'external_content', :action => 'success'
map.external_content_oembed_retrieve 'external_content/retrieve/oembed', :controller => 'external_content', :action => 'oembed_retrieve'
map.external_content_cancel 'external_content/cancel/:service', :controller => 'external_content', :action => 'cancel'

View File

@ -0,0 +1,22 @@
class CreateMediaTracks < ActiveRecord::Migration
tag :predeploy
def self.up
create_table :media_tracks do |t|
t.integer :user_id, :limit => 8
t.integer :media_object_id, :limit => 8
t.string :kind, :default => "subtitles"
t.string :locale, :default => "en"
t.text :content
t.timestamps
end
add_index :media_tracks, [:media_object_id, :locale], :name => 'media_object_id_locale'
end
def self.down
drop_table :media_tracks
end
end

View File

@ -20,7 +20,13 @@ module Api::V1::MediaObject
def media_object_api_json(media_object, current_user, session)
hash = {}
hash['can_add_captions'] = media_object.grants_right?(current_user, session, :add_captions)
hash['media_sources'] = media_object.media_sources
hash['media_tracks'] = media_object.media_tracks.map do |track|
api_json(track, current_user, session, :only => %w(kind created_at updated_at id locale)).tap do |json|
json.merge! :url => show_media_tracks_url(media_object.media_id, track.id)
end
end
hash
end

View File

@ -32,6 +32,8 @@ describe MediaObjectsController do
get 'show', :media_object_id => missing_media_id
json_parse(response.body).should == {
'can_add_captions' => false,
'media_tracks' => [],
'media_sources' => []
}
MediaObject.by_media_id(missing_media_id).first.media_id.should == missing_media_id