Commit 95b3055f by Mushtaq Ali Committed by GitHub

Merge pull request #15178 from edx/course-video-image

Course video image
parents 7b910953 d2b420a9
...@@ -1975,7 +1975,7 @@ class RerunCourseTest(ContentStoreTestCase): ...@@ -1975,7 +1975,7 @@ class RerunCourseTest(ContentStoreTestCase):
create_video( create_video(
dict( dict(
edx_video_id="tree-hugger", edx_video_id="tree-hugger",
courses=[source_course.id], courses=[unicode(source_course.id)],
status='test', status='test',
duration=2, duration=2,
encoded_videos=[] encoded_videos=[]
......
...@@ -6,24 +6,52 @@ import csv ...@@ -6,24 +6,52 @@ import csv
import json import json
import re import re
from datetime import datetime from datetime import datetime
from functools import wraps
from StringIO import StringIO from StringIO import StringIO
import dateutil.parser import dateutil.parser
import ddt import ddt
import pytz import pytz
from django.conf import settings from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
from django.test.utils import override_settings from django.test.utils import override_settings
from edxval.api import create_profile, create_video, get_video_info from edxval.api import create_profile, create_video, get_video_info, get_course_video_image_url
from mock import Mock, patch from mock import Mock, patch
from contentstore.models import VideoUploadConfig from contentstore.models import VideoUploadConfig
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
from contentstore.utils import reverse_course_url from contentstore.utils import reverse_course_url
from contentstore.views.videos import (
_get_default_video_image_url,
validate_video_image,
VIDEO_IMAGE_UPLOAD_ENABLED,
WAFFLE_SWITCHES,
)
from contentstore.views.videos import KEY_EXPIRATION_IN_SECONDS, StatusDisplayStrings, convert_video_status from contentstore.views.videos import KEY_EXPIRATION_IN_SECONDS, StatusDisplayStrings, convert_video_status
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file
class VideoUploadTestMixin(object):
def override_switch(switch, active):
"""
Overrides the given waffle switch to `active` boolean.
Arguments:
switch(str): switch name
active(bool): A boolean representing (to be overridden) value
"""
def decorate(function):
@wraps(function)
def inner(*args, **kwargs):
with WAFFLE_SWITCHES.override(switch, active=active):
function(*args, **kwargs)
return inner
return decorate
class VideoUploadTestBase(object):
""" """
Test cases for the video upload feature Test cases for the video upload feature
""" """
...@@ -32,7 +60,7 @@ class VideoUploadTestMixin(object): ...@@ -32,7 +60,7 @@ class VideoUploadTestMixin(object):
return reverse_course_url(self.VIEW_NAME, course_key, kwargs) return reverse_course_url(self.VIEW_NAME, course_key, kwargs)
def setUp(self): def setUp(self):
super(VideoUploadTestMixin, self).setUp() super(VideoUploadTestBase, self).setUp()
self.url = self.get_url_for_course_key(self.course.id) self.url = self.get_url_for_course_key(self.course.id)
self.test_token = "test_token" self.test_token = "test_token"
self.course.video_upload_pipeline = { self.course.video_upload_pipeline = {
...@@ -131,6 +159,11 @@ class VideoUploadTestMixin(object): ...@@ -131,6 +159,11 @@ class VideoUploadTestMixin(object):
if video["edx_video_id"] == edx_video_id if video["edx_video_id"] == edx_video_id
) )
class VideoUploadTestMixin(VideoUploadTestBase):
"""
Test cases for the video upload feature
"""
def test_anon_user(self): def test_anon_user(self):
self.client.logout() self.client.logout()
response = self.client.get(self.url) response = self.client.get(self.url)
...@@ -171,25 +204,25 @@ class VideoUploadTestMixin(object): ...@@ -171,25 +204,25 @@ class VideoUploadTestMixin(object):
class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
"""Test cases for the main video upload endpoint""" """Test cases for the main video upload endpoint"""
VIEW_NAME = "videos_handler" VIEW_NAME = 'videos_handler'
def test_get_json(self): def test_get_json(self):
response = self.client.get_json(self.url) response = self.client.get_json(self.url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response_videos = json.loads(response.content)["videos"] response_videos = json.loads(response.content)['videos']
self.assertEqual(len(response_videos), len(self.previous_uploads)) self.assertEqual(len(response_videos), len(self.previous_uploads))
for i, response_video in enumerate(response_videos): for i, response_video in enumerate(response_videos):
# Videos should be returned by creation date descending # Videos should be returned by creation date descending
original_video = self.previous_uploads[-(i + 1)] original_video = self.previous_uploads[-(i + 1)]
self.assertEqual( self.assertEqual(
set(response_video.keys()), set(response_video.keys()),
set(["edx_video_id", "client_video_id", "created", "duration", "status"]) set(['edx_video_id', 'client_video_id', 'created', 'duration', 'status', 'course_video_image_url'])
) )
dateutil.parser.parse(response_video["created"]) dateutil.parser.parse(response_video['created'])
for field in ["edx_video_id", "client_video_id", "duration"]: for field in ['edx_video_id', 'client_video_id', 'duration']:
self.assertEqual(response_video[field], original_video[field]) self.assertEqual(response_video[field], original_video[field])
self.assertEqual( self.assertEqual(
response_video["status"], response_video['status'],
convert_video_status(original_video) convert_video_status(original_video)
) )
...@@ -197,6 +230,7 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): ...@@ -197,6 +230,7 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertRegexpMatches(response["Content-Type"], "^text/html(;.*)?$") self.assertRegexpMatches(response["Content-Type"], "^text/html(;.*)?$")
self.assertIn(_get_default_video_image_url(), response.content)
# Crude check for presence of data in returned HTML # Crude check for presence of data in returned HTML
for video in self.previous_uploads: for video in self.previous_uploads:
self.assertIn(video["edx_video_id"], response.content) self.assertIn(video["edx_video_id"], response.content)
...@@ -313,26 +347,26 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): ...@@ -313,26 +347,26 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
response = json.loads(response.content) response = json.loads(response.content)
self.assertEqual(response['error'], 'The file name for %s must contain only ASCII characters.' % file_name) self.assertEqual(response['error'], 'The file name for %s must contain only ASCII characters.' % file_name)
@override_settings(AWS_ACCESS_KEY_ID="test_key_id", AWS_SECRET_ACCESS_KEY="test_secret") @override_settings(AWS_ACCESS_KEY_ID='test_key_id', AWS_SECRET_ACCESS_KEY='test_secret')
@patch("boto.s3.key.Key") @patch('boto.s3.key.Key')
@patch("boto.s3.connection.S3Connection") @patch('boto.s3.connection.S3Connection')
def test_post_success(self, mock_conn, mock_key): def test_post_success(self, mock_conn, mock_key):
files = [ files = [
{ {
"file_name": "first.mp4", 'file_name': 'first.mp4',
"content_type": "video/mp4", 'content_type': 'video/mp4',
}, },
{ {
"file_name": "second.mp4", 'file_name': 'second.mp4',
"content_type": "video/mp4", 'content_type': 'video/mp4',
}, },
{ {
"file_name": "third.mov", 'file_name': 'third.mov',
"content_type": "video/quicktime", 'content_type': 'video/quicktime',
}, },
{ {
"file_name": "fourth.mp4", 'file_name': 'fourth.mp4',
"content_type": "video/mp4", 'content_type': 'video/mp4',
}, },
] ]
...@@ -341,7 +375,7 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): ...@@ -341,7 +375,7 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
mock_key_instances = [ mock_key_instances = [
Mock( Mock(
generate_url=Mock( generate_url=Mock(
return_value="http://example.com/url_{}".format(file_info["file_name"]) return_value='http://example.com/url_{}'.format(file_info['file_name'])
) )
) )
for file_info in files for file_info in files
...@@ -351,14 +385,14 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): ...@@ -351,14 +385,14 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
response = self.client.post( response = self.client.post(
self.url, self.url,
json.dumps({"files": files}), json.dumps({'files': files}),
content_type="application/json" content_type='application/json'
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response_obj = json.loads(response.content) response_obj = json.loads(response.content)
mock_conn.assert_called_once_with(settings.AWS_ACCESS_KEY_ID, settings.AWS_SECRET_ACCESS_KEY) mock_conn.assert_called_once_with(settings.AWS_ACCESS_KEY_ID, settings.AWS_SECRET_ACCESS_KEY)
self.assertEqual(len(response_obj["files"]), len(files)) self.assertEqual(len(response_obj['files']), len(files))
self.assertEqual(mock_key.call_count, len(files)) self.assertEqual(mock_key.call_count, len(files))
for i, file_info in enumerate(files): for i, file_info in enumerate(files):
# Ensure Key was set up correctly and extract id # Ensure Key was set up correctly and extract id
...@@ -366,8 +400,8 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): ...@@ -366,8 +400,8 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
self.assertEqual(key_call_args[0], bucket) self.assertEqual(key_call_args[0], bucket)
path_match = re.match( path_match = re.match(
( (
settings.VIDEO_UPLOAD_PIPELINE["ROOT_PATH"] + settings.VIDEO_UPLOAD_PIPELINE['ROOT_PATH'] +
"/([a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})$" '/([a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})$'
), ),
key_call_args[1] key_call_args[1]
) )
...@@ -375,32 +409,32 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): ...@@ -375,32 +409,32 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
video_id = path_match.group(1) video_id = path_match.group(1)
mock_key_instance = mock_key_instances[i] mock_key_instance = mock_key_instances[i]
mock_key_instance.set_metadata.assert_any_call( mock_key_instance.set_metadata.assert_any_call(
"course_video_upload_token", 'course_video_upload_token',
self.test_token self.test_token
) )
mock_key_instance.set_metadata.assert_any_call( mock_key_instance.set_metadata.assert_any_call(
"client_video_id", 'client_video_id',
file_info["file_name"] file_info['file_name']
) )
mock_key_instance.set_metadata.assert_any_call("course_key", unicode(self.course.id)) mock_key_instance.set_metadata.assert_any_call('course_key', unicode(self.course.id))
mock_key_instance.generate_url.assert_called_once_with( mock_key_instance.generate_url.assert_called_once_with(
KEY_EXPIRATION_IN_SECONDS, KEY_EXPIRATION_IN_SECONDS,
"PUT", 'PUT',
headers={"Content-Type": file_info["content_type"]} headers={'Content-Type': file_info['content_type']}
) )
# Ensure VAL was updated # Ensure VAL was updated
val_info = get_video_info(video_id) val_info = get_video_info(video_id)
self.assertEqual(val_info["status"], "upload") self.assertEqual(val_info['status'], 'upload')
self.assertEqual(val_info["client_video_id"], file_info["file_name"]) self.assertEqual(val_info['client_video_id'], file_info['file_name'])
self.assertEqual(val_info["status"], "upload") self.assertEqual(val_info['status'], 'upload')
self.assertEqual(val_info["duration"], 0) self.assertEqual(val_info['duration'], 0)
self.assertEqual(val_info["courses"], [unicode(self.course.id)]) self.assertEqual(val_info['courses'], [{unicode(self.course.id): None}])
# Ensure response is correct # Ensure response is correct
response_file = response_obj["files"][i] response_file = response_obj['files'][i]
self.assertEqual(response_file["file_name"], file_info["file_name"]) self.assertEqual(response_file['file_name'], file_info['file_name'])
self.assertEqual(response_file["upload_url"], mock_key_instance.generate_url()) self.assertEqual(response_file['upload_url'], mock_key_instance.generate_url())
def _assert_video_removal(self, url, edx_video_id, deleted_videos): def _assert_video_removal(self, url, edx_video_id, deleted_videos):
""" """
...@@ -518,6 +552,308 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): ...@@ -518,6 +552,308 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
self.assert_video_status(url, edx_video_id, 'Failed') self.assert_video_status(url, edx_video_id, 'Failed')
@ddt.ddt
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_VIDEO_UPLOAD_PIPELINE': True})
@override_settings(VIDEO_UPLOAD_PIPELINE={'BUCKET': 'test_bucket', 'ROOT_PATH': 'test_root'})
class VideoImageTestCase(VideoUploadTestBase, CourseTestCase):
"""
Tests for video image.
"""
VIEW_NAME = "video_images_handler"
def verify_image_upload_reponse(self, course_id, edx_video_id, upload_response):
"""
Verify that image is uploaded successfully.
Arguments:
course_id: ID of course
edx_video_id: ID of video
upload_response: Upload response object
Returns:
uploaded image url
"""
self.assertEqual(upload_response.status_code, 200)
response = json.loads(upload_response.content)
val_image_url = get_course_video_image_url(course_id=course_id, edx_video_id=edx_video_id)
self.assertEqual(response['image_url'], val_image_url)
return val_image_url
def verify_error_message(self, response, error_message):
"""
Verify that image upload failure gets proper error message.
Arguments:
response: Response object.
error_message: Expected error message.
"""
self.assertEqual(response.status_code, 400)
response = json.loads(response.content)
self.assertIn('error', response)
self.assertEqual(response['error'], error_message)
@override_switch(VIDEO_IMAGE_UPLOAD_ENABLED, False)
def test_video_image_upload_disabled(self):
"""
Tests the video image upload when the feature is disabled.
"""
video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': 'test_vid_id'})
response = self.client.post(video_image_upload_url, {'file': 'dummy_file'}, format='multipart')
self.assertEqual(response.status_code, 404)
@override_switch(VIDEO_IMAGE_UPLOAD_ENABLED, True)
def test_video_image(self):
"""
Test video image is saved.
"""
edx_video_id = 'test1'
video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': edx_video_id})
with make_image_file(
dimensions=(settings.VIDEO_IMAGE_MIN_WIDTH, settings.VIDEO_IMAGE_MIN_HEIGHT),
) as image_file:
response = self.client.post(video_image_upload_url, {'file': image_file}, format='multipart')
image_url1 = self.verify_image_upload_reponse(self.course.id, edx_video_id, response)
# upload again to verify that new image is uploaded successfully
with make_image_file(
dimensions=(settings.VIDEO_IMAGE_MIN_WIDTH, settings.VIDEO_IMAGE_MIN_HEIGHT),
) as image_file:
response = self.client.post(video_image_upload_url, {'file': image_file}, format='multipart')
image_url2 = self.verify_image_upload_reponse(self.course.id, edx_video_id, response)
self.assertNotEqual(image_url1, image_url2)
@override_switch(VIDEO_IMAGE_UPLOAD_ENABLED, True)
def test_video_image_no_file(self):
"""
Test that an error error message is returned if upload request is incorrect.
"""
video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': 'test1'})
response = self.client.post(video_image_upload_url, {})
self.verify_error_message(response, 'An image file is required.')
def test_invalid_image_file_info(self):
"""
Test that when no file information is provided to validate_video_image, it gives proper error message.
"""
error = validate_video_image({})
self.assertEquals(error, 'The image must have name, content type, and size information.')
def test_currupt_image_file(self):
"""
Test that when corrupt file is provided to validate_video_image, it gives proper error message.
"""
with open(settings.MEDIA_ROOT + '/test-corrupt-image.png', 'w+') as file:
image_file = UploadedFile(
file,
content_type='image/png',
size=settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES']
)
error = validate_video_image(image_file)
self.assertEquals(error, 'There is a problem with this image file. Try to upload a different file.')
@override_switch(VIDEO_IMAGE_UPLOAD_ENABLED, True)
def test_no_video_image(self):
"""
Test image url is set to None if no video image.
"""
edx_video_id = 'test1'
get_videos_url = reverse_course_url('videos_handler', self.course.id)
video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': edx_video_id})
with make_image_file(
dimensions=(settings.VIDEO_IMAGE_MIN_WIDTH, settings.VIDEO_IMAGE_MIN_HEIGHT),
) as image_file:
self.client.post(video_image_upload_url, {'file': image_file}, format='multipart')
val_image_url = get_course_video_image_url(course_id=self.course.id, edx_video_id=edx_video_id)
response = self.client.get_json(get_videos_url)
self.assertEqual(response.status_code, 200)
response_videos = json.loads(response.content)["videos"]
for response_video in response_videos:
if response_video['edx_video_id'] == edx_video_id:
self.assertEqual(response_video['course_video_image_url'], val_image_url)
else:
self.assertEqual(response_video['course_video_image_url'], None)
@ddt.data(
# Image file type validation
(
{
'extension': '.png'
},
None
),
(
{
'extension': '.gif'
},
None
),
(
{
'extension': '.bmp'
},
None
),
(
{
'extension': '.jpg'
},
None
),
(
{
'extension': '.jpeg'
},
None
),
(
{
'extension': '.PNG'
},
None
),
(
{
'extension': '.tiff'
},
'This image file type is not supported. Supported file types are {supported_file_formats}.'.format(
supported_file_formats=settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS.keys()
)
),
# Image file size validation
(
{
'size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MAX_BYTES'] + 10
},
'This image file must be smaller than {image_max_size}.'.format(
image_max_size=settings.VIDEO_IMAGE_MAX_FILE_SIZE_MB
)
),
(
{
'size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES'] - 10
},
'This image file must be larger than {image_min_size}.'.format(
image_min_size=settings.VIDEO_IMAGE_MIN_FILE_SIZE_KB
)
),
# Image file minimum width / height
(
{
'width': 16, # 16x9
'height': 9
},
'Recommended image resolution is {image_file_max_width}x{image_file_max_height}. The minimum resolution is {image_file_min_width}x{image_file_min_height}.'.format(
image_file_max_width=settings.VIDEO_IMAGE_MAX_WIDTH,
image_file_max_height=settings.VIDEO_IMAGE_MAX_HEIGHT,
image_file_min_width=settings.VIDEO_IMAGE_MIN_WIDTH,
image_file_min_height=settings.VIDEO_IMAGE_MIN_HEIGHT
)
),
(
{
'width': settings.VIDEO_IMAGE_MIN_WIDTH - 10,
'height': settings.VIDEO_IMAGE_MIN_HEIGHT
},
'Recommended image resolution is {image_file_max_width}x{image_file_max_height}. The minimum resolution is {image_file_min_width}x{image_file_min_height}.'.format(
image_file_max_width=settings.VIDEO_IMAGE_MAX_WIDTH,
image_file_max_height=settings.VIDEO_IMAGE_MAX_HEIGHT,
image_file_min_width=settings.VIDEO_IMAGE_MIN_WIDTH,
image_file_min_height=settings.VIDEO_IMAGE_MIN_HEIGHT
)
),
(
{
'width': settings.VIDEO_IMAGE_MIN_WIDTH,
'height': settings.VIDEO_IMAGE_MIN_HEIGHT - 10
},
'Recommended image resolution is {image_file_max_width}x{image_file_max_height}. The minimum resolution is {image_file_min_width}x{image_file_min_height}.'.format(
image_file_max_width=settings.VIDEO_IMAGE_MAX_WIDTH,
image_file_max_height=settings.VIDEO_IMAGE_MAX_HEIGHT,
image_file_min_width=settings.VIDEO_IMAGE_MIN_WIDTH,
image_file_min_height=settings.VIDEO_IMAGE_MIN_HEIGHT
)
),
(
{
'width': 1200, # not 16:9, but width/height check first.
'height': 100
},
'Recommended image resolution is {image_file_max_width}x{image_file_max_height}. The minimum resolution is {image_file_min_width}x{image_file_min_height}.'.format(
image_file_max_width=settings.VIDEO_IMAGE_MAX_WIDTH,
image_file_max_height=settings.VIDEO_IMAGE_MAX_HEIGHT,
image_file_min_width=settings.VIDEO_IMAGE_MIN_WIDTH,
image_file_min_height=settings.VIDEO_IMAGE_MIN_HEIGHT
)
),
# Image file aspect ratio validation
(
{
'width': settings.VIDEO_IMAGE_MAX_WIDTH, # 1280x720
'height': settings.VIDEO_IMAGE_MAX_HEIGHT
},
None
),
(
{
'width': 850, # 16:9
'height': 478
},
None
),
(
{
'width': 940, # 1.67 ratio, applicable aspect ratio margin of .01
'height': 560
},
None
),
(
{
'width': settings.VIDEO_IMAGE_MIN_WIDTH + 100,
'height': settings.VIDEO_IMAGE_MIN_HEIGHT + 200
},
'This image file must have an aspect ratio of {video_image_aspect_ratio_text}.'.format(
video_image_aspect_ratio_text=settings.VIDEO_IMAGE_ASPECT_RATIO_TEXT
)
),
# Image file name validation
(
{
'prefix': u'nøn-åßç¡¡'
},
'The image file name can only contain letters, numbers, hyphens (-), and underscores (_).'
)
)
@ddt.unpack
@override_switch(VIDEO_IMAGE_UPLOAD_ENABLED, True)
def test_video_image_validation_message(self, image_data, error_message):
"""
Test video image validation gives proper error message.
Arguments:
image_data (Dict): Specific data to create image file.
error_message (String): Error message
"""
edx_video_id = 'test1'
video_image_upload_url = self.get_url_for_course_key(self.course.id, {'edx_video_id': edx_video_id})
with make_image_file(
dimensions=(image_data.get('width', settings.VIDEO_IMAGE_MIN_WIDTH), image_data.get('height', settings.VIDEO_IMAGE_MIN_HEIGHT)),
prefix=image_data.get('prefix', 'videoimage'),
extension=image_data.get('extension', '.png'),
force_size=image_data.get('size', settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES'])
) as image_file:
response = self.client.post(video_image_upload_url, {'file': image_file}, format='multipart')
if error_message:
self.verify_error_message(response, error_message)
else:
self.verify_image_upload_reponse(self.course.id, edx_video_id, response)
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_VIDEO_UPLOAD_PIPELINE": True}) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_VIDEO_UPLOAD_PIPELINE": True})
@override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"}) @override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"})
class VideoUrlsCsvTestCase(VideoUploadTestMixin, CourseTestCase): class VideoUrlsCsvTestCase(VideoUploadTestMixin, CourseTestCase):
......
""" """
Views related to the video upload feature Views related to the video upload feature
""" """
from contextlib import closing
import csv import csv
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
...@@ -10,19 +12,23 @@ import rfc6266 ...@@ -10,19 +12,23 @@ import rfc6266
from boto import s3 from boto import s3
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core.files.images import get_image_dimensions
from django.http import HttpResponse, HttpResponseNotFound from django.http import HttpResponse, HttpResponseNotFound
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_noop from django.utils.translation import ugettext_noop
from django.views.decorators.http import require_GET, require_http_methods from django.views.decorators.http import require_GET, require_POST, require_http_methods
from edxval.api import ( from edxval.api import (
SortDirection, SortDirection,
VideoSortField, VideoSortField,
create_video, create_video,
get_videos_for_course, get_videos_for_course,
remove_video_for_course, remove_video_for_course,
update_video_status update_video_status,
update_video_image
) )
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
from contentstore.models import VideoUploadConfig from contentstore.models import VideoUploadConfig
from contentstore.utils import reverse_course_url from contentstore.utils import reverse_course_url
...@@ -31,10 +37,17 @@ from util.json_request import JsonResponse, expect_json ...@@ -31,10 +37,17 @@ from util.json_request import JsonResponse, expect_json
from .course import get_course_and_check_access from .course import get_course_and_check_access
__all__ = ["videos_handler", "video_encodings_download"]
__all__ = ['videos_handler', 'video_encodings_download', 'video_images_handler']
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
# Waffle switches namespace for videos
WAFFLE_NAMESPACE = 'videos'
WAFFLE_SWITCHES = WaffleSwitchNamespace(name=WAFFLE_NAMESPACE)
# Waffle switch for enabling/disabling video image upload feature
VIDEO_IMAGE_UPLOAD_ENABLED = 'video_image_upload_enabled'
# Default expiration, in seconds, of one-time URLs used for uploading videos. # Default expiration, in seconds, of one-time URLs used for uploading videos.
KEY_EXPIRATION_IN_SECONDS = 86400 KEY_EXPIRATION_IN_SECONDS = 86400
...@@ -145,6 +158,84 @@ def videos_handler(request, course_key_string, edx_video_id=None): ...@@ -145,6 +158,84 @@ def videos_handler(request, course_key_string, edx_video_id=None):
return videos_post(course, request) return videos_post(course, request)
def validate_video_image(image_file):
"""
Validates video image file.
Arguments:
image_file: The selected image file.
Returns:
error (String or None): If there is error returns error message otherwise None.
"""
error = None
if not all(hasattr(image_file, attr) for attr in ['name', 'content_type', 'size']):
error = _('The image must have name, content type, and size information.')
elif image_file.content_type not in settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS.values():
error = _('This image file type is not supported. Supported file types are {supported_file_formats}.').format(
supported_file_formats=settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS.keys()
)
elif image_file.size > settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MAX_BYTES']:
error = _('This image file must be smaller than {image_max_size}.').format(
image_max_size=settings.VIDEO_IMAGE_MAX_FILE_SIZE_MB
)
elif image_file.size < settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES']:
error = _('This image file must be larger than {image_min_size}.').format(
image_min_size=settings.VIDEO_IMAGE_MIN_FILE_SIZE_KB
)
else:
try:
image_file_width, image_file_height = get_image_dimensions(image_file)
except TypeError:
return _('There is a problem with this image file. Try to upload a different file.')
image_file_aspect_ratio = abs(image_file_width / float(image_file_height) - settings.VIDEO_IMAGE_ASPECT_RATIO)
if image_file_width < settings.VIDEO_IMAGE_MIN_WIDTH or image_file_height < settings.VIDEO_IMAGE_MIN_HEIGHT:
error = _('Recommended image resolution is {image_file_max_width}x{image_file_max_height}. '
'The minimum resolution is {image_file_min_width}x{image_file_min_height}.').format(
image_file_max_width=settings.VIDEO_IMAGE_MAX_WIDTH,
image_file_max_height=settings.VIDEO_IMAGE_MAX_HEIGHT,
image_file_min_width=settings.VIDEO_IMAGE_MIN_WIDTH,
image_file_min_height=settings.VIDEO_IMAGE_MIN_HEIGHT
)
elif image_file_aspect_ratio > settings.VIDEO_IMAGE_ASPECT_RATIO_ERROR_MARGIN:
error = _('This image file must have an aspect ratio of {video_image_aspect_ratio_text}.').format(
video_image_aspect_ratio_text=settings.VIDEO_IMAGE_ASPECT_RATIO_TEXT
)
else:
try:
image_file.name.encode('ascii')
except UnicodeEncodeError:
error = _('The image file name can only contain letters, numbers, hyphens (-), and underscores (_).')
return error
@expect_json
@login_required
@require_POST
def video_images_handler(request, course_key_string, edx_video_id=None):
# respond with a 404 if image upload is not enabled.
if not WAFFLE_SWITCHES.is_enabled(VIDEO_IMAGE_UPLOAD_ENABLED):
return HttpResponseNotFound()
if 'file' not in request.FILES:
return JsonResponse({'error': _(u'An image file is required.')}, status=400)
image_file = request.FILES['file']
error = validate_video_image(image_file)
if error:
return JsonResponse({'error': error}, status=400)
with closing(image_file):
image_url = update_video_image(edx_video_id, course_key_string, image_file, image_file.name)
LOGGER.info(
'VIDEOS: Video image uploaded for edx_video_id [%s] in course [%s]', edx_video_id, course_key_string
)
return JsonResponse({'image_url': image_url})
@login_required @login_required
@require_GET @require_GET
def video_encodings_download(request, course_key_string): def video_encodings_download(request, course_key_string):
...@@ -296,17 +387,37 @@ def _get_videos(course): ...@@ -296,17 +387,37 @@ def _get_videos(course):
return videos return videos
def _get_default_video_image_url():
"""
Returns default video image url
"""
return staticfiles_storage.url(settings.VIDEO_IMAGE_DEFAULT_FILENAME)
def _get_index_videos(course): def _get_index_videos(course):
""" """
Returns the information about each video upload required for the video list Returns the information about each video upload required for the video list
""" """
return list( course_id = unicode(course.id)
{ attrs = ['edx_video_id', 'client_video_id', 'created', 'duration', 'status', 'courses']
attr: video[attr]
for attr in ["edx_video_id", "client_video_id", "created", "duration", "status"] def _get_values(video):
} """
for video in _get_videos(course) Get data for predefined video attributes.
) """
values = {}
for attr in attrs:
if attr == 'courses':
course = filter(lambda c: course_id in c, video['courses'])
(__, values['course_video_image_url']), = course[0].items()
else:
values[attr] = video[attr]
return values
return [
_get_values(video) for video in _get_videos(course)
]
def videos_index_html(course): def videos_index_html(course):
...@@ -314,15 +425,25 @@ def videos_index_html(course): ...@@ -314,15 +425,25 @@ def videos_index_html(course):
Returns an HTML page to display previous video uploads and allow new ones Returns an HTML page to display previous video uploads and allow new ones
""" """
return render_to_response( return render_to_response(
"videos_index.html", 'videos_index.html',
{ {
"context_course": course, 'context_course': course,
"video_handler_url": reverse_course_url("videos_handler", unicode(course.id)), 'image_upload_url': reverse_course_url('video_images_handler', unicode(course.id)),
"encodings_download_url": reverse_course_url("video_encodings_download", unicode(course.id)), 'video_handler_url': reverse_course_url('videos_handler', unicode(course.id)),
"previous_uploads": _get_index_videos(course), 'encodings_download_url': reverse_course_url('video_encodings_download', unicode(course.id)),
"concurrent_upload_limit": settings.VIDEO_UPLOAD_PIPELINE.get("CONCURRENT_UPLOAD_LIMIT", 0), 'default_video_image_url': _get_default_video_image_url(),
"video_supported_file_formats": VIDEO_SUPPORTED_FILE_FORMATS.keys(), 'previous_uploads': _get_index_videos(course),
"video_upload_max_file_size": VIDEO_UPLOAD_MAX_FILE_SIZE_GB 'concurrent_upload_limit': settings.VIDEO_UPLOAD_PIPELINE.get('CONCURRENT_UPLOAD_LIMIT', 0),
'video_supported_file_formats': VIDEO_SUPPORTED_FILE_FORMATS.keys(),
'video_upload_max_file_size': VIDEO_UPLOAD_MAX_FILE_SIZE_GB,
'video_image_settings': {
'video_image_upload_enabled': WAFFLE_SWITCHES.is_enabled(VIDEO_IMAGE_UPLOAD_ENABLED),
'max_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MAX_BYTES'],
'min_size': settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES'],
'max_width': settings.VIDEO_IMAGE_MAX_WIDTH,
'max_height': settings.VIDEO_IMAGE_MAX_HEIGHT,
'supported_file_formats': settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS
}
} }
) )
...@@ -331,12 +452,13 @@ def videos_index_json(course): ...@@ -331,12 +452,13 @@ def videos_index_json(course):
""" """
Returns JSON in the following format: Returns JSON in the following format:
{ {
"videos": [{ 'videos': [{
"edx_video_id": "aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa", 'edx_video_id': 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa',
"client_video_id": "video.mp4", 'client_video_id': 'video.mp4',
"created": "1970-01-01T00:00:00Z", 'created': '1970-01-01T00:00:00Z',
"duration": 42.5, 'duration': 42.5,
"status": "upload" 'status': 'upload',
'course_video_image_url': 'https://video/images/1234.jpg'
}] }]
} }
""" """
...@@ -364,29 +486,29 @@ def videos_post(course, request): ...@@ -364,29 +486,29 @@ def videos_post(course, request):
The returned array corresponds exactly to the input array. The returned array corresponds exactly to the input array.
""" """
error = None error = None
if "files" not in request.json: if 'files' not in request.json:
error = "Request object is not JSON or does not contain 'files'" error = "Request object is not JSON or does not contain 'files'"
elif any( elif any(
"file_name" not in file or "content_type" not in file 'file_name' not in file or 'content_type' not in file
for file in request.json["files"] for file in request.json['files']
): ):
error = "Request 'files' entry does not contain 'file_name' and 'content_type'" error = "Request 'files' entry does not contain 'file_name' and 'content_type'"
elif any( elif any(
file['content_type'] not in VIDEO_SUPPORTED_FILE_FORMATS.values() file['content_type'] not in VIDEO_SUPPORTED_FILE_FORMATS.values()
for file in request.json["files"] for file in request.json['files']
): ):
error = "Request 'files' entry contain unsupported content_type" error = "Request 'files' entry contain unsupported content_type"
if error: if error:
return JsonResponse({"error": error}, status=400) return JsonResponse({'error': error}, status=400)
bucket = storage_service_bucket() bucket = storage_service_bucket()
course_video_upload_token = course.video_upload_pipeline["course_video_upload_token"] course_video_upload_token = course.video_upload_pipeline['course_video_upload_token']
req_files = request.json["files"] req_files = request.json['files']
resp_files = [] resp_files = []
for req_file in req_files: for req_file in req_files:
file_name = req_file["file_name"] file_name = req_file['file_name']
try: try:
file_name.encode('ascii') file_name.encode('ascii')
...@@ -397,30 +519,30 @@ def videos_post(course, request): ...@@ -397,30 +519,30 @@ def videos_post(course, request):
edx_video_id = unicode(uuid4()) edx_video_id = unicode(uuid4())
key = storage_service_key(bucket, file_name=edx_video_id) key = storage_service_key(bucket, file_name=edx_video_id)
for metadata_name, value in [ for metadata_name, value in [
("course_video_upload_token", course_video_upload_token), ('course_video_upload_token', course_video_upload_token),
("client_video_id", file_name), ('client_video_id', file_name),
("course_key", unicode(course.id)), ('course_key', unicode(course.id)),
]: ]:
key.set_metadata(metadata_name, value) key.set_metadata(metadata_name, value)
upload_url = key.generate_url( upload_url = key.generate_url(
KEY_EXPIRATION_IN_SECONDS, KEY_EXPIRATION_IN_SECONDS,
"PUT", 'PUT',
headers={"Content-Type": req_file["content_type"]} headers={'Content-Type': req_file['content_type']}
) )
# persist edx_video_id in VAL # persist edx_video_id in VAL
create_video({ create_video({
"edx_video_id": edx_video_id, 'edx_video_id': edx_video_id,
"status": "upload", 'status': 'upload',
"client_video_id": file_name, 'client_video_id': file_name,
"duration": 0, 'duration': 0,
"encoded_videos": [], 'encoded_videos': [],
"courses": [course.id] 'courses': [unicode(course.id)]
}) })
resp_files.append({"file_name": file_name, "upload_url": upload_url, "edx_video_id": edx_video_id}) resp_files.append({'file_name': file_name, 'upload_url': upload_url, 'edx_video_id': edx_video_id})
return JsonResponse({"files": resp_files}, status=200) return JsonResponse({'files': resp_files}, status=200)
def storage_service_bucket(): def storage_service_bucket():
......
...@@ -440,6 +440,10 @@ ADVANCED_PROBLEM_TYPES = ENV_TOKENS.get('ADVANCED_PROBLEM_TYPES', ADVANCED_PROBL ...@@ -440,6 +440,10 @@ ADVANCED_PROBLEM_TYPES = ENV_TOKENS.get('ADVANCED_PROBLEM_TYPES', ADVANCED_PROBL
VIDEO_UPLOAD_PIPELINE = ENV_TOKENS.get('VIDEO_UPLOAD_PIPELINE', VIDEO_UPLOAD_PIPELINE) VIDEO_UPLOAD_PIPELINE = ENV_TOKENS.get('VIDEO_UPLOAD_PIPELINE', VIDEO_UPLOAD_PIPELINE)
################ VIDEO IMAGE STORAGE ###############
VIDEO_IMAGE_SETTINGS = ENV_TOKENS.get('VIDEO_IMAGE_SETTINGS', VIDEO_IMAGE_SETTINGS)
################ PUSH NOTIFICATIONS ############### ################ PUSH NOTIFICATIONS ###############
PARSE_KEYS = AUTH_TOKENS.get("PARSE_KEYS", {}) PARSE_KEYS = AUTH_TOKENS.get("PARSE_KEYS", {})
......
...@@ -103,6 +103,8 @@ from lms.envs.common import ( ...@@ -103,6 +103,8 @@ from lms.envs.common import (
CONTACT_EMAIL, CONTACT_EMAIL,
DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH, DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH,
# Video Image settings
VIDEO_IMAGE_SETTINGS,
) )
from path import Path as path from path import Path as path
from warnings import simplefilter from warnings import simplefilter
...@@ -1344,3 +1346,24 @@ PROFILE_IMAGE_SIZES_MAP = { ...@@ -1344,3 +1346,24 @@ PROFILE_IMAGE_SIZES_MAP = {
'medium': 50, 'medium': 50,
'small': 30 'small': 30
} }
###################### VIDEO IMAGE STORAGE ######################
VIDEO_IMAGE_DEFAULT_FILENAME = 'images/video-images/default_video_image.png'
VIDEO_IMAGE_SUPPORTED_FILE_FORMATS = {
'.bmp': 'image/bmp',
'.bmp2': 'image/x-ms-bmp', # PIL gives x-ms-bmp format
'.gif': 'image/gif',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png'
}
VIDEO_IMAGE_MAX_FILE_SIZE_MB = '2 MB'
VIDEO_IMAGE_MIN_FILE_SIZE_KB = '2 KB'
VIDEO_IMAGE_MAX_WIDTH = 1280
VIDEO_IMAGE_MAX_HEIGHT = 720
VIDEO_IMAGE_MIN_WIDTH = 640
VIDEO_IMAGE_MIN_HEIGHT = 360
VIDEO_IMAGE_ASPECT_RATIO = 16 / 9.0
VIDEO_IMAGE_ASPECT_RATIO_TEXT = '16:9'
VIDEO_IMAGE_ASPECT_RATIO_ERROR_MARGIN = 0.1
...@@ -335,3 +335,15 @@ FEATURES['CUSTOM_COURSES_EDX'] = True ...@@ -335,3 +335,15 @@ FEATURES['CUSTOM_COURSES_EDX'] = True
# API access management -- needed for simple-history to run. # API access management -- needed for simple-history to run.
INSTALLED_APPS += ('openedx.core.djangoapps.api_admin',) INSTALLED_APPS += ('openedx.core.djangoapps.api_admin',)
########################## VIDEO IMAGE STORAGE ############################
VIDEO_IMAGE_SETTINGS = dict(
VIDEO_IMAGE_MAX_BYTES=2 * 1024 * 1024, # 2 MB
VIDEO_IMAGE_MIN_BYTES=2 * 1024, # 2 KB
STORAGE_KWARGS=dict(
location=MEDIA_ROOT,
base_url=MEDIA_URL,
),
DIRECTORY_PREFIX='video-images/',
)
VIDEO_IMAGE_DEFAULT_FILENAME = 'default_video_image.png'
...@@ -258,6 +258,7 @@ ...@@ -258,6 +258,7 @@
'js/spec/utils/module_spec', 'js/spec/utils/module_spec',
'js/spec/views/active_video_upload_list_spec', 'js/spec/views/active_video_upload_list_spec',
'js/spec/views/previous_video_upload_spec', 'js/spec/views/previous_video_upload_spec',
'js/spec/views/video_thumbnail_spec',
'js/spec/views/previous_video_upload_list_spec', 'js/spec/views/previous_video_upload_list_spec',
'js/spec/views/assets_spec', 'js/spec/views/assets_spec',
'js/spec/views/baseview_spec', 'js/spec/views/baseview_spec',
......
...@@ -5,13 +5,16 @@ define([ ...@@ -5,13 +5,16 @@ define([
'use strict'; 'use strict';
var VideosIndexFactory = function( var VideosIndexFactory = function(
$contentWrapper, $contentWrapper,
videoImageUploadURL,
videoHandlerUrl, videoHandlerUrl,
encodingsDownloadUrl, encodingsDownloadUrl,
defaultVideoImageURL,
concurrentUploadLimit, concurrentUploadLimit,
uploadButton, uploadButton,
previousUploads, previousUploads,
videoSupportedFileFormats, videoSupportedFileFormats,
videoUploadMaxFileSizeInGB videoUploadMaxFileSizeInGB,
videoImageSettings
) { ) {
var activeView = new ActiveVideoUploadListView({ var activeView = new ActiveVideoUploadListView({
postUrl: videoHandlerUrl, postUrl: videoHandlerUrl,
...@@ -19,6 +22,7 @@ define([ ...@@ -19,6 +22,7 @@ define([
uploadButton: uploadButton, uploadButton: uploadButton,
videoSupportedFileFormats: videoSupportedFileFormats, videoSupportedFileFormats: videoSupportedFileFormats,
videoUploadMaxFileSizeInGB: videoUploadMaxFileSizeInGB, videoUploadMaxFileSizeInGB: videoUploadMaxFileSizeInGB,
videoImageSettings: videoImageSettings,
onFileUploadDone: function(activeVideos) { onFileUploadDone: function(activeVideos) {
$.ajax({ $.ajax({
url: videoHandlerUrl, url: videoHandlerUrl,
...@@ -34,18 +38,24 @@ define([ ...@@ -34,18 +38,24 @@ define([
isActive[0].get('status') === ActiveVideoUpload.STATUS_COMPLETE; isActive[0].get('status') === ActiveVideoUpload.STATUS_COMPLETE;
}), }),
updatedView = new PreviousVideoUploadListView({ updatedView = new PreviousVideoUploadListView({
videoImageUploadURL: videoImageUploadURL,
defaultVideoImageURL: defaultVideoImageURL,
videoHandlerUrl: videoHandlerUrl, videoHandlerUrl: videoHandlerUrl,
collection: updatedCollection, collection: updatedCollection,
encodingsDownloadUrl: encodingsDownloadUrl encodingsDownloadUrl: encodingsDownloadUrl,
videoImageSettings: videoImageSettings
}); });
$contentWrapper.find('.wrapper-assets').replaceWith(updatedView.render().$el); $contentWrapper.find('.wrapper-assets').replaceWith(updatedView.render().$el);
}); });
} }
}), }),
previousView = new PreviousVideoUploadListView({ previousView = new PreviousVideoUploadListView({
videoImageUploadURL: videoImageUploadURL,
defaultVideoImageURL: defaultVideoImageURL,
videoHandlerUrl: videoHandlerUrl, videoHandlerUrl: videoHandlerUrl,
collection: new Backbone.Collection(previousUploads), collection: new Backbone.Collection(previousUploads),
encodingsDownloadUrl: encodingsDownloadUrl encodingsDownloadUrl: encodingsDownloadUrl,
videoImageSettings: videoImageSettings
}); });
$contentWrapper.append(activeView.render().$el); $contentWrapper.append(activeView.render().$el);
$contentWrapper.append(previousView.render().$el); $contentWrapper.append(previousView.render().$el);
......
...@@ -25,7 +25,8 @@ define( ...@@ -25,7 +25,8 @@ define(
); );
var view = new PreviousVideoUploadListView({ var view = new PreviousVideoUploadListView({
collection: collection, collection: collection,
videoHandlerUrl: videoHandlerUrl videoHandlerUrl: videoHandlerUrl,
videoImageSettings: {}
}); });
return view.render().$el; return view.render().$el;
}, },
...@@ -43,10 +44,10 @@ define( ...@@ -43,10 +44,10 @@ define(
$el = render(numVideos), $el = render(numVideos),
firstVideoId = 'dummy_id_0', firstVideoId = 'dummy_id_0',
requests = AjaxHelpers.requests(test), requests = AjaxHelpers.requests(test),
firstVideoSelector = '.js-table-body tr:first-child'; firstVideoSelector = '.js-table-body .video-row:first-child';
// total number of videos should be 5 before remove // total number of videos should be 5 before remove
expect($el.find('.js-table-body tr').length).toEqual(numVideos); expect($el.find('.js-table-body .video-row').length).toEqual(numVideos);
// get first video element // get first video element
firstVideo = $el.find(firstVideoSelector); firstVideo = $el.find(firstVideoSelector);
...@@ -71,7 +72,7 @@ define( ...@@ -71,7 +72,7 @@ define(
} }
// verify total number of videos after Remove/Cancel // verify total number of videos after Remove/Cancel
expect($el.find('.js-table-body tr').length).toEqual(numVideos); expect($el.find('.js-table-body .video-row').length).toEqual(numVideos);
// verify first video id after Remove/Cancel // verify first video id after Remove/Cancel
firstVideo = $el.find(firstVideoSelector); firstVideo = $el.find(firstVideoSelector);
...@@ -81,13 +82,13 @@ define( ...@@ -81,13 +82,13 @@ define(
it('should render an empty collection', function() { it('should render an empty collection', function() {
var $el = render(0); var $el = render(0);
expect($el.find('.js-table-body').length).toEqual(1); expect($el.find('.js-table-body').length).toEqual(1);
expect($el.find('.js-table-body tr').length).toEqual(0); expect($el.find('.js-table-body .video-row').length).toEqual(0);
}); });
it('should render a non-empty collection', function() { it('should render a non-empty collection', function() {
var $el = render(5); var $el = render(5);
expect($el.find('.js-table-body').length).toEqual(1); expect($el.find('.js-table-body').length).toEqual(1);
expect($el.find('.js-table-body tr').length).toEqual(5); expect($el.find('.js-table-body .video-row').length).toEqual(5);
}); });
it('removes video upon click on Remove button', function() { it('removes video upon click on Remove button', function() {
......
...@@ -14,7 +14,8 @@ define( ...@@ -14,7 +14,8 @@ define(
}, },
view = new PreviousVideoUploadView({ view = new PreviousVideoUploadView({
model: new Backbone.Model($.extend({}, defaultData, modelData)), model: new Backbone.Model($.extend({}, defaultData, modelData)),
videoHandlerUrl: '/videos/course-v1:org.0+course_0+Run_0' videoHandlerUrl: '/videos/course-v1:org.0+course_0+Run_0',
videoImageSettings: {}
}); });
return view.render().$el; return view.render().$el;
}; };
...@@ -30,24 +31,6 @@ define( ...@@ -30,24 +31,6 @@ define(
expect($el.find('.name-col').text()).toEqual(testName); expect($el.find('.name-col').text()).toEqual(testName);
}); });
_.each(
[
{desc: 'zero as pending', seconds: 0, expected: 'Pending'},
{desc: 'less than one second as zero', seconds: 0.75, expected: '0:00'},
{desc: 'with minutes and without seconds', seconds: 900, expected: '15:00'},
{desc: 'with seconds and without minutes', seconds: 15, expected: '0:15'},
{desc: 'with minutes and seconds', seconds: 915, expected: '15:15'},
{desc: 'with seconds padded', seconds: 5, expected: '0:05'},
{desc: 'longer than an hour as many minutes', seconds: 7425, expected: '123:45'}
],
function(caseInfo) {
it('should render duration ' + caseInfo.desc, function() {
var $el = render({duration: caseInfo.seconds});
expect($el.find('.duration-col').text()).toEqual(caseInfo.expected);
});
}
);
it('should render created timestamp correctly', function() { it('should render created timestamp correctly', function() {
var fakeDate = 'fake formatted date'; var fakeDate = 'fake formatted date';
spyOn(Date.prototype, 'toLocaleString').and.callFake( spyOn(Date.prototype, 'toLocaleString').and.callFake(
......
define(
['jquery', 'underscore', 'backbone', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'js/views/video_thumbnail', 'js/views/previous_video_upload_list', 'common/js/spec_helpers/template_helpers'],
function($, _, Backbone, AjaxHelpers, VideoThumbnailView, PreviousVideoUploadListView, TemplateHelpers) {
'use strict';
describe('VideoThumbnailView', function() {
var IMAGE_UPLOAD_URL = '/videos/upload/image',
UPLOADED_IMAGE_URL = 'images/upload_success.jpg',
VIDEO_IMAGE_MAX_BYTES = 2 * 1024 * 1024,
VIDEO_IMAGE_MIN_BYTES = 2 * 1024,
VIDEO_IMAGE_MAX_WIDTH = 1280,
VIDEO_IMAGE_MAX_HEIGHT = 720,
VIDEO_IMAGE_SUPPORTED_FILE_FORMATS = {
'.bmp': 'image/bmp',
'.bmp2': 'image/x-ms-bmp', // PIL gives x-ms-bmp format
'.gif': 'image/gif',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png'
},
videoListView,
videoThumbnailView,
$videoListEl,
$videoThumbnailEl,
createVideoListView,
createFakeImageFile,
verifyStateInfo;
/**
* Creates a list of video records.
*
* @param {Boolean} videoImageUploadEnabled Boolean indicating if the feature is enabled.
* @param {Object} modelData Model data for video records.
* @param {Integer} numVideos Number of video elements to create.
* @param {Integer} videoViewIndex Index of video on which videoThumbnailView would be based.
*/
createVideoListView = function(videoImageUploadEnabled, modelData, numVideos, videoViewIndex) {
var modelData = modelData || {}, // eslint-disable-line no-redeclare
numVideos = numVideos || 1, // eslint-disable-line no-redeclare
videoViewIndex = videoViewIndex || 0, // eslint-disable-line no-redeclare,
defaultData = {
client_video_id: 'foo.mp4',
duration: 42,
created: '2014-11-25T23:13:05',
edx_video_id: 'dummy_id',
status: 'uploading'
},
collection = new Backbone.Collection(_.map(_.range(numVideos), function(num, index) {
return new Backbone.Model(
_.extend({}, defaultData, {edx_video_id: 'dummy_id_' + index}, modelData)
);
}));
videoListView = new PreviousVideoUploadListView({
collection: collection,
videoHandlerUrl: '/videos/course-v1:org.0+course_0+Run_0',
videoImageUploadURL: IMAGE_UPLOAD_URL,
videoImageSettings: {
max_size: VIDEO_IMAGE_MAX_BYTES,
min_size: VIDEO_IMAGE_MIN_BYTES,
max_width: VIDEO_IMAGE_MAX_WIDTH,
max_height: VIDEO_IMAGE_MAX_HEIGHT,
supported_file_formats: VIDEO_IMAGE_SUPPORTED_FILE_FORMATS,
video_image_upload_enabled: videoImageUploadEnabled
}
});
$videoListEl = videoListView.render().$el;
if (videoImageUploadEnabled) {
videoThumbnailView = videoListView.itemViews[videoViewIndex].videoThumbnailView;
$videoThumbnailEl = videoThumbnailView.render().$el;
}
return videoListView;
};
createFakeImageFile = function(size, type) {
var size = size || VIDEO_IMAGE_MIN_BYTES, // eslint-disable-line no-redeclare
type = type || 'image/jpeg'; // eslint-disable-line no-redeclare
return new Blob([Array(size + 1).join('i')], {type: type});
};
verifyStateInfo = function($thumbnail, state, onHover, additionalSRText) {
var beforeIcon,
beforeText;
// Verify hover message, save the text before hover to verify later
if (onHover) {
beforeIcon = $thumbnail.find('.action-icon').html().trim();
beforeText = $thumbnail.find('.action-text').html().trim();
$thumbnail.trigger('mouseover');
}
if (additionalSRText) {
expect(
$thumbnail.find('.thumbnail-action .action-text-sr').text().trim()
).toEqual(additionalSRText);
}
if (state !== 'error') {
expect($thumbnail.find('.action-icon').html().trim()).toEqual(
videoThumbnailView.actionsInfo[state].icon
);
}
expect($thumbnail.find('.action-text').html().trim()).toEqual(
videoThumbnailView.actionsInfo[state].text
);
// Verify if messages are restored after focus moved away
if (onHover) {
$thumbnail.trigger('mouseout');
expect($thumbnail.find('.action-icon').html().trim()).toEqual(beforeIcon);
expect($thumbnail.find('.action-text').html().trim()).toEqual(beforeText);
}
};
beforeEach(function() {
setFixtures('<div id="page-prompt"></div><div id="page-notification"></div>');
TemplateHelpers.installTemplate('video-thumbnail');
TemplateHelpers.installTemplate('previous-video-upload-list');
createVideoListView(true);
});
it('Verifies that the ThumbnailView is not initialized on disabling the feature', function() {
createVideoListView(false);
expect(videoListView.itemViews[0].videoThumbnailView).toEqual(undefined);
});
it('renders as expected', function() {
expect($videoThumbnailEl.find('.thumbnail-wrapper')).toExist();
expect($videoThumbnailEl.find('.upload-image-input')).toExist();
});
it('does not show duration if not available', function() {
createVideoListView(true, {duration: 0});
expect($videoThumbnailEl.find('.thumbnail-wrapper .video-duration')).not.toExist();
});
it('shows the duration if available', function() {
var $duration = $videoThumbnailEl.find('.thumbnail-wrapper .video-duration');
expect($duration).toExist();
expect($duration.find('.duration-text-machine').text().trim()).toEqual('0:42');
expect($duration.find('.duration-text-human').text().trim()).toEqual('Video duration is 42 seconds');
});
it('calculates duration correctly', function() {
var durations = [
{duration: -1},
{duration: 0},
{duration: 0.75, machine: '0:00', humanize: ''},
{duration: 5, machine: '0:05', humanize: 'Video duration is 5 seconds'},
{duration: 103, machine: '1:43', humanize: 'Video duration is 1 minute and 43 seconds'},
{duration: 120, machine: '2:00', humanize: 'Video duration is 2 minutes'},
{duration: 500, machine: '8:20', humanize: 'Video duration is 8 minutes and 20 seconds'},
{duration: 7425, machine: '123:45', humanize: 'Video duration is 123 minutes and 45 seconds'}
],
expectedDuration;
durations.forEach(function(item) {
expectedDuration = videoThumbnailView.getDuration(item.duration);
if (item.duration <= 0) {
expect(expectedDuration).toEqual(null);
} else {
expect(expectedDuration.machine).toEqual(item.machine);
expect(expectedDuration.humanize).toEqual(item.humanize);
}
});
});
it('can upload image', function() {
var videoViewIndex = 0,
$thumbnail = $videoThumbnailEl.find('.thumbnail-wrapper'),
requests = AjaxHelpers.requests(this),
additionalSRText = videoThumbnailView.getSRText();
videoThumbnailView.chooseFile();
verifyStateInfo($thumbnail, 'upload');
verifyStateInfo($thumbnail, 'requirements', true, additionalSRText);
// Add image to upload queue and send POST request to upload image
$videoThumbnailEl.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile()]});
verifyStateInfo($thumbnail, 'progress');
// Verify if POST request received for image upload
AjaxHelpers.expectRequest(
requests,
'POST',
IMAGE_UPLOAD_URL + '/dummy_id_' + videoViewIndex,
new FormData()
);
// Send successful upload response
AjaxHelpers.respondWithJson(requests, {image_url: UPLOADED_IMAGE_URL});
verifyStateInfo($thumbnail, 'edit', true);
// Verify uploaded image src
expect($thumbnail.find('img').attr('src')).toEqual(UPLOADED_IMAGE_URL);
});
it('shows error state correctly', function() {
var $thumbnail = $videoThumbnailEl.find('.thumbnail-wrapper'),
requests = AjaxHelpers.requests(this);
videoThumbnailView.chooseFile();
// Add image to upload queue and send POST request to upload image
$videoThumbnailEl.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile()]});
AjaxHelpers.respondWithError(requests, 400);
verifyStateInfo($thumbnail, 'edit');
});
it('calls readMessage with correct message', function() {
var errorMessage = 'Image upload failed. This image file type is not supported. Supported file ' +
'types are ' + videoThumbnailView.getVideoImageSupportedFileFormats().humanize + '.',
successData = {
files: [createFakeImageFile()],
submit: function() {}
},
failureData = {
jqXHR: {
responseText: JSON.stringify({
error: errorMessage
})
}
};
spyOn(videoThumbnailView, 'readMessages');
videoThumbnailView.imageSelected({}, successData);
expect(videoThumbnailView.readMessages).toHaveBeenCalledWith(['Video image upload started']);
videoThumbnailView.imageUploadSucceeded({}, {result: {image_url: UPLOADED_IMAGE_URL}});
expect(videoThumbnailView.readMessages).toHaveBeenCalledWith(['Video image upload completed']);
videoThumbnailView.imageUploadFailed({}, failureData);
expect(videoThumbnailView.readMessages).toHaveBeenCalledWith(
['Could not upload the video image file', errorMessage]
);
});
it('should show error message in case of server error', function() {
var requests = AjaxHelpers.requests(this);
videoThumbnailView.chooseFile();
// Add image to upload queue and send POST request to upload image
$videoThumbnailEl.find('.upload-image-input').fileupload('add', {files: [createFakeImageFile()]});
AjaxHelpers.respondWithError(requests);
// Verify error message is present
expect($videoListEl.find('.thumbnail-error-wrapper')).toExist();
});
it('should show error message when file is smaller than minimum size', function() {
videoThumbnailView.chooseFile();
// Add image to upload queue and send POST request to upload image
$videoThumbnailEl.find('.upload-image-input')
.fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MIN_BYTES - 10)]});
// Verify error message
expect($videoListEl.find('.thumbnail-error-wrapper').find('.action-text').html()
.trim()).toEqual(
'Image upload failed. The selected image must be larger than ' +
videoThumbnailView.getVideoImageMinSize().humanize + '.'
);
});
it('should show error message when file is larger than maximum size', function() {
videoThumbnailView.chooseFile();
// Add image to upload queue and send POST request to upload image
$videoThumbnailEl.find('.upload-image-input')
.fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MAX_BYTES + 10)]});
// Verify error message
expect($videoListEl.find('.thumbnail-error-wrapper').find('.action-text').html()
.trim()).toEqual(
'Image upload failed. The selected image must be smaller than ' +
videoThumbnailView.getVideoImageMaxSize().humanize + '.'
);
});
it('should not show error message when file size is equals to minimum file size', function() {
videoThumbnailView.chooseFile();
// Add image to upload queue and send POST request to upload image
$videoThumbnailEl.find('.upload-image-input')
.fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MIN_BYTES)]});
// Verify error not present.
expect($videoListEl.find('.thumbnail-error-wrapper')).not.toExist();
});
it('should not show error message when file size is equals to maximum file size', function() {
videoThumbnailView.chooseFile();
// Add image to upload queue and send POST request to upload image
$videoThumbnailEl.find('.upload-image-input')
.fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MAX_BYTES)]});
// Verify error not present.
expect($videoListEl.find('.thumbnail-error-wrapper')).not.toExist();
});
it('should show error message when file has unsupported content type', function() {
videoThumbnailView.chooseFile();
// Add image to upload queue and send POST request to upload image
$videoThumbnailEl.find('.upload-image-input')
.fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MIN_BYTES, 'mov/mp4')]});
// Verify error message
expect($videoListEl.find('.thumbnail-error-wrapper').find('.action-text').html()
.trim()).toEqual(
'Image upload failed. This image file type is not supported. Supported file types are ' +
videoThumbnailView.getVideoImageSupportedFileFormats().humanize + '.'
);
});
it('should not show error message when file has supported content type', function() {
videoThumbnailView.chooseFile();
// Add image to upload queue and send POST request to upload image
$videoThumbnailEl.find('.upload-image-input')
.fileupload('add', {files: [createFakeImageFile(VIDEO_IMAGE_MIN_BYTES)]});
// Verify error message is not present
expect($videoListEl.find('.thumbnail-error-wrapper')).not.toExist();
});
});
}
);
define( define(
['underscore', 'gettext', 'js/utils/date_utils', 'js/views/baseview', 'common/js/components/views/feedback_prompt', ['underscore', 'gettext', 'js/utils/date_utils', 'js/views/baseview', 'common/js/components/views/feedback_prompt',
'common/js/components/views/feedback_notification', 'common/js/components/utils/view_utils', 'common/js/components/views/feedback_notification', 'js/views/video_thumbnail',
'edx-ui-toolkit/js/utils/html-utils', 'text!templates/previous-video-upload.underscore'], 'common/js/components/utils/view_utils', 'edx-ui-toolkit/js/utils/html-utils',
function(_, gettext, DateUtils, BaseView, PromptView, NotificationView, ViewUtils, HtmlUtils, 'text!templates/previous-video-upload.underscore'],
function(_, gettext, DateUtils, BaseView, PromptView, NotificationView, VideoThumbnailView, ViewUtils, HtmlUtils,
previousVideoUploadTemplate) { previousVideoUploadTemplate) {
'use strict'; 'use strict';
var PreviousVideoUploadView = BaseView.extend({ var PreviousVideoUploadView = BaseView.extend({
tagName: 'tr', tagName: 'div',
className: 'video-row',
events: { events: {
'click .remove-video-button.action-button': 'removeVideo' 'click .remove-video-button.action-button': 'removeVideo'
...@@ -16,22 +19,21 @@ define( ...@@ -16,22 +19,21 @@ define(
initialize: function(options) { initialize: function(options) {
this.template = HtmlUtils.template(previousVideoUploadTemplate); this.template = HtmlUtils.template(previousVideoUploadTemplate);
this.videoHandlerUrl = options.videoHandlerUrl; this.videoHandlerUrl = options.videoHandlerUrl;
}, this.videoImageUploadEnabled = options.videoImageSettings.video_image_upload_enabled;
renderDuration: function(seconds) {
var minutes = Math.floor(seconds / 60);
var seconds = Math.floor(seconds - minutes * 60);
return minutes + ':' + (seconds < 10 ? '0' : '') + seconds; if (this.videoImageUploadEnabled) {
this.videoThumbnailView = new VideoThumbnailView({
model: this.model,
imageUploadURL: options.videoImageUploadURL,
defaultVideoImageURL: options.defaultVideoImageURL,
videoImageSettings: options.videoImageSettings
});
}
}, },
render: function() { render: function() {
var duration = this.model.get('duration');
var renderedAttributes = { var renderedAttributes = {
// Translators: This is listed as the duration for a video videoImageUploadEnabled: this.videoImageUploadEnabled,
// that has not yet reached the point in its processing by
// the servers where its duration is determined.
duration: duration > 0 ? this.renderDuration(duration) : gettext('Pending'),
created: DateUtils.renderDate(this.model.get('created')), created: DateUtils.renderDate(this.model.get('created')),
status: this.model.get('status') status: this.model.get('status')
}; };
...@@ -41,12 +43,15 @@ define( ...@@ -41,12 +43,15 @@ define(
_.extend({}, this.model.attributes, renderedAttributes) _.extend({}, this.model.attributes, renderedAttributes)
) )
); );
if (this.videoImageUploadEnabled) {
this.videoThumbnailView.setElement(this.$('.thumbnail-col')).render();
}
return this; return this;
}, },
removeVideo: function(event) { removeVideo: function(event) {
var videoView = this; var videoView = this;
event.preventDefault(); event.preventDefault();
ViewUtils.confirmThenRunOperation( ViewUtils.confirmThenRunOperation(
......
...@@ -9,9 +9,13 @@ define( ...@@ -9,9 +9,13 @@ define(
initialize: function(options) { initialize: function(options) {
this.template = this.loadTemplate('previous-video-upload-list'); this.template = this.loadTemplate('previous-video-upload-list');
this.encodingsDownloadUrl = options.encodingsDownloadUrl; this.encodingsDownloadUrl = options.encodingsDownloadUrl;
this.videoImageUploadEnabled = options.videoImageSettings.video_image_upload_enabled;
this.itemViews = this.collection.map(function(model) { this.itemViews = this.collection.map(function(model) {
return new PreviousVideoUploadView({ return new PreviousVideoUploadView({
videoImageUploadURL: options.videoImageUploadURL,
defaultVideoImageURL: options.defaultVideoImageURL,
videoHandlerUrl: options.videoHandlerUrl, videoHandlerUrl: options.videoHandlerUrl,
videoImageSettings: options.videoImageSettings,
model: model model: model
}); });
}); });
...@@ -20,7 +24,10 @@ define( ...@@ -20,7 +24,10 @@ define(
render: function() { render: function() {
var $el = this.$el, var $el = this.$el,
$tabBody; $tabBody;
$el.html(this.template({encodingsDownloadUrl: this.encodingsDownloadUrl})); $el.html(this.template({
encodingsDownloadUrl: this.encodingsDownloadUrl,
videoImageUploadEnabled: this.videoImageUploadEnabled
}));
$tabBody = $el.find('.js-table-body'); $tabBody = $el.find('.js-table-body');
_.each(this.itemViews, function(view) { _.each(this.itemViews, function(view) {
$tabBody.append(view.render().$el); $tabBody.append(view.render().$el);
......
define(
['underscore', 'gettext', 'moment', 'js/utils/date_utils', 'js/views/baseview',
'common/js/components/utils/view_utils', 'edx-ui-toolkit/js/utils/html-utils',
'edx-ui-toolkit/js/utils/string-utils', 'text!templates/video-thumbnail.underscore',
'text!templates/video-thumbnail-error.underscore'],
function(_, gettext, moment, DateUtils, BaseView, ViewUtils, HtmlUtils, StringUtils, VideoThumbnailTemplate,
VideoThumbnailErrorTemplate) {
'use strict';
var VideoThumbnailView = BaseView.extend({
events: {
'click .thumbnail-wrapper': 'chooseFile',
'mouseover .thumbnail-wrapper': 'showHoverState',
'mouseout .thumbnail-wrapper': 'hideHoverState',
'focus .thumbnail-wrapper': 'showHoverState',
'blur .thumbnail-wrapper': 'hideHoverState'
},
initialize: function(options) {
this.template = HtmlUtils.template(VideoThumbnailTemplate);
this.thumbnailErrorTemplate = HtmlUtils.template(VideoThumbnailErrorTemplate);
this.imageUploadURL = options.imageUploadURL;
this.defaultVideoImageURL = options.defaultVideoImageURL;
this.action = this.model.get('course_video_image_url') ? 'edit' : 'upload';
this.videoImageSettings = options.videoImageSettings;
this.actionsInfo = {
upload: {
name: 'upload',
icon: '',
text: gettext('Add Thumbnail')
},
edit: {
name: 'edit',
actionText: gettext('Edit Thumbnail'),
icon: '<span class="icon fa fa-pencil" aria-hidden="true"></span>',
text: HtmlUtils.interpolateHtml(
// Translators: This is a 2 part text which tells the image requirements.
gettext('{InstructionsSpanStart}{videoImageResoultion}{lineBreak} {videoImageSupportedFileFormats}{spanEnd}'), // eslint-disable-line max-len
{
videoImageResoultion: this.getVideoImageResolution(),
videoImageSupportedFileFormats: this.getVideoImageSupportedFileFormats().humanize,
lineBreak: HtmlUtils.HTML('<br>'),
InstructionsSpanStart: HtmlUtils.HTML('<span class="requirements-instructions">'),
spanEnd: HtmlUtils.HTML('</span>')
}
).toString()
},
error: {
name: 'error',
icon: '',
text: gettext('Image upload failed')
},
progress: {
name: 'progress-action',
icon: '<span class="icon fa fa-spinner fa-pulse fa-spin" aria-hidden="true"></span>',
text: gettext('Uploading')
},
requirements: {
name: 'requirements',
icon: '',
text: HtmlUtils.interpolateHtml(
// Translators: This is a 3 part text which tells the image requirements.
gettext('{ReqTextSpanStart}Requirements{spanEnd}{lineBreak}{InstructionsSpanStart}{videoImageResoultion}{lineBreak} {videoImageSupportedFileFormats}{spanEnd}'), // eslint-disable-line max-len
{
videoImageResoultion: this.getVideoImageResolution(),
videoImageSupportedFileFormats: this.getVideoImageSupportedFileFormats().humanize,
lineBreak: HtmlUtils.HTML('<br>'),
ReqTextSpanStart: HtmlUtils.HTML('<span class="requirements-text">'),
InstructionsSpanStart: HtmlUtils.HTML('<span class="requirements-instructions">'),
spanEnd: HtmlUtils.HTML('</span>')
}
).toString()
}
};
_.bindAll(
this, 'render', 'chooseFile', 'imageSelected', 'imageUploadSucceeded', 'imageUploadFailed',
'showHoverState', 'hideHoverState'
);
},
render: function() {
HtmlUtils.setHtml(
this.$el,
this.template({
action: this.action,
imageAltText: this.getImageAltText(),
videoId: this.model.get('edx_video_id'),
actionInfo: this.actionsInfo[this.action],
thumbnailURL: this.model.get('course_video_image_url') || this.defaultVideoImageURL,
duration: this.getDuration(this.model.get('duration')),
videoImageSupportedFileFormats: this.getVideoImageSupportedFileFormats(),
videoImageMaxSize: this.getVideoImageMaxSize(),
videoImageResolution: this.getVideoImageResolution()
})
);
this.hideHoverState();
return this;
},
getVideoImageSupportedFileFormats: function() {
var supportedFormats = _.reject(_.keys(this.videoImageSettings.supported_file_formats), function(item) {
// Don't show redundant extensions to end user.
return item === '.bmp2' || item === '.jpeg';
}).sort();
return {
humanize: supportedFormats.slice(0, -1).join(', ') + ' or ' + supportedFormats.slice(-1),
machine: _.values(this.videoImageSettings.supported_file_formats)
};
},
getVideoImageMaxSize: function() {
return {
humanize: this.videoImageSettings.max_size / (1024 * 1024) + ' MB',
machine: this.videoImageSettings.max_size
};
},
getVideoImageMinSize: function() {
return {
humanize: this.videoImageSettings.min_size / 1024 + ' KB',
machine: this.videoImageSettings.min_size
};
},
getVideoImageResolution: function() {
return StringUtils.interpolate(
// Translators: message will be like 1280x720 pixels
gettext('{maxWidth}x{maxHeight} pixels'),
{maxWidth: this.videoImageSettings.max_width, maxHeight: this.videoImageSettings.max_height}
);
},
getImageAltText: function() {
return StringUtils.interpolate(
// Translators: message will be like Thumbnail for Arrow.mp4
gettext('Thumbnail for {videoName}'),
{videoName: this.model.get('client_video_id')}
);
},
getSRText: function() {
return StringUtils.interpolate(
// Translators: message will be like Add Thumbnail - Arrow.mp4
gettext('Add Thumbnail - {videoName}'),
{videoName: this.model.get('client_video_id')}
);
},
getDuration: function(durationSeconds) {
if (durationSeconds <= 0) {
return null;
}
return {
humanize: this.getDurationTextHuman(durationSeconds),
machine: this.getDurationTextMachine(durationSeconds)
};
},
getDurationTextHuman: function(durationSeconds) {
var humanize = this.getHumanizeDuration(durationSeconds);
// This case is specifically to handle values between 0 and 1 seconds excluding upper bound
if (humanize.length === 0) {
return '';
}
return StringUtils.interpolate(
// Translators: humanizeDuration will be like 10 minutes, an hour and 20 minutes etc
gettext('Video duration is {humanizeDuration}'),
{
humanizeDuration: humanize
}
);
},
getHumanizeDuration: function(durationSeconds) {
var minutes,
seconds,
minutesText = null,
secondsText = null;
minutes = Math.trunc(moment.duration(durationSeconds, 'seconds').asMinutes());
seconds = moment.duration(durationSeconds, 'seconds').seconds();
if (minutes) {
minutesText = minutes > 1 ? gettext('minutes') : gettext('minute');
minutesText = StringUtils.interpolate(
// Translators: message will be like 15 minutes, 1 minute
gettext('{minutes} {unit}'),
{minutes: minutes, unit: minutesText}
);
}
if (seconds) {
secondsText = seconds > 1 ? gettext('seconds') : gettext('second');
secondsText = StringUtils.interpolate(
// Translators: message will be like 20 seconds, 1 second
gettext('{seconds} {unit}'),
{seconds: seconds, unit: secondsText}
);
}
// Translators: `and` will be used to combine both miuntes and seconds like `13 minutes and 45 seconds`
return _.filter([minutesText, secondsText]).join(gettext(' and '));
},
getDurationTextMachine: function(durationSeconds) {
var minutes = Math.floor(durationSeconds / 60),
seconds = Math.floor(durationSeconds - minutes * 60);
return minutes + ':' + (seconds < 10 ? '0' : '') + seconds;
},
chooseFile: function() {
this.$('.upload-image-input').fileupload({
url: this.imageUploadURL + '/' + encodeURIComponent(this.model.get('edx_video_id')),
add: this.imageSelected,
done: this.imageUploadSucceeded,
fail: this.imageUploadFailed
});
},
imageSelected: function(event, data) {
var errorMessage;
// If an error is already present above the video element, remove it.
this.clearErrorMessage(this.model.get('edx_video_id'));
errorMessage = this.validateImageFile(data.files[0]);
if (!errorMessage) {
// Do not trigger global AJAX error handler
data.global = false; // eslint-disable-line no-param-reassign
this.readMessages([gettext('Video image upload started')]);
this.showUploadInProgressMessage();
data.submit();
} else {
this.showErrorMessage(errorMessage);
}
},
imageUploadSucceeded: function(event, data) {
this.action = 'edit';
this.setActionInfo(this.action, false);
this.$('img').attr('src', data.result.image_url);
this.readMessages([gettext('Video image upload completed')]);
},
imageUploadFailed: function(event, data) {
var errorText = JSON.parse(data.jqXHR.responseText).error;
this.showErrorMessage(errorText);
},
showUploadInProgressMessage: function() {
this.action = 'progress';
this.setActionInfo(this.action, true);
},
showHoverState: function() {
if (this.action === 'upload') {
this.setActionInfo('requirements', true, this.getSRText());
} else if (this.action === 'edit') {
this.setActionInfo(this.action, true);
}
this.$('.thumbnail-wrapper').addClass('focused');
},
hideHoverState: function() {
if (this.action === 'upload') {
this.setActionInfo(this.action, true);
} else if (this.action === 'edit') {
this.setActionInfo(this.action, false);
}
this.$('.thumbnail-wrapper').removeClass('focused');
},
setActionInfo: function(action, showText, additionalSRText) {
var hasError = this.$('.thumbnail-wrapper').hasClass('error');
this.$('.thumbnail-action').toggle(showText);
HtmlUtils.setHtml(
this.$('.thumbnail-action .action-icon'),
HtmlUtils.HTML(this.actionsInfo[action].icon)
);
HtmlUtils.setHtml(
this.$('.thumbnail-action .action-text'),
HtmlUtils.HTML(this.actionsInfo[action].text)
);
this.$('.thumbnail-action .action-text-sr').text(additionalSRText || '');
this.$('.thumbnail-wrapper').attr('class', 'thumbnail-wrapper {action}'.replace('{action}', action));
this.$('.thumbnail-action .action-icon')
.attr('class', 'action-icon {action}'.replace('{action}', action));
// Add error class if it was already present.
if (hasError) {
this.$('.thumbnail-wrapper').addClass('error');
}
// Don't show edit-container layout on progress action.
if (action === 'progress') {
this.$('.thumbnail-action .edit-container').toggle(false);
} else if (action === 'edit') {
this.$('.thumbnail-action .edit-container').toggle(true);
HtmlUtils.setHtml(
this.$('.thumbnail-action .edit-container .action-icon'),
HtmlUtils.HTML(this.actionsInfo[action].icon)
);
HtmlUtils.setHtml(
this.$('.thumbnail-action .edit-container .edit-action-text'),
HtmlUtils.HTML(this.actionsInfo[action].actionText)
);
}
},
validateImageFile: function(imageFile) {
var errorMessage = '';
if (!_.contains(this.getVideoImageSupportedFileFormats().machine, imageFile.type)) {
errorMessage = StringUtils.interpolate(
// Translators: supportedFileFormats will be like .bmp, gif, .jpg or .png.
gettext(
'This image file type is not supported. Supported file types are {supportedFileFormats}.'
),
{
supportedFileFormats: this.getVideoImageSupportedFileFormats().humanize
}
);
} else if (imageFile.size > this.getVideoImageMaxSize().machine) {
errorMessage = StringUtils.interpolate(
// Translators: maxFileSizeInMB will be like 2 MB.
gettext('The selected image must be smaller than {maxFileSizeInMB}.'),
{
maxFileSizeInMB: this.getVideoImageMaxSize().humanize
}
);
} else if (imageFile.size < this.getVideoImageMinSize().machine) {
errorMessage = StringUtils.interpolate(
// Translators: minFileSizeInKB will be like 2 KB.
gettext('The selected image must be larger than {minFileSizeInKB}.'),
{
minFileSizeInKB: this.getVideoImageMinSize().humanize
}
);
}
return errorMessage;
},
clearErrorMessage: function(videoId) {
var $thumbnailWrapperEl = $('.thumbnail-error-wrapper[data-video-id="' + videoId + '"]');
if ($thumbnailWrapperEl.length) {
$thumbnailWrapperEl.remove();
}
// Remove error class from thumbnail wrapper as well.
$('.thumbnail-wrapper').removeClass('error');
},
showErrorMessage: function(errorText) {
var videoId = this.model.get('edx_video_id'),
$parentRowEl = $(this.$el.parent());
// If image url is not this.defaultVideoImageURL then it means image is uploaded
// so we should treat it as edit action otherwise default upload action.
this.action = this.$('.thumbnail-wrapper img').attr('src') !== this.defaultVideoImageURL
? 'edit' : 'upload';
this.setActionInfo(this.action, true);
this.readMessages([gettext('Could not upload the video image file'), errorText]);
errorText = gettext('Image upload failed. ') + errorText; // eslint-disable-line no-param-reassign
// Add error wrapper html to current video element row.
$parentRowEl.before( // xss-lint: disable=javascript-jquery-insertion
HtmlUtils.ensureHtml(
this.thumbnailErrorTemplate({videoId: videoId, errorText: errorText})
).toString()
);
this.$el.find('.thumbnail-wrapper').addClass('error');
},
readMessages: function(messages) {
if ($(window).prop('SR') !== undefined) {
$(window).prop('SR').readTexts(messages);
}
}
});
return VideoThumbnailView;
}
);
...@@ -36,7 +36,7 @@ ...@@ -36,7 +36,7 @@
font-size: 80%; font-size: 80%;
word-wrap: break-word; word-wrap: break-word;
th { th, .video-head-col {
@extend %t-copy-sub2; @extend %t-copy-sub2;
background-color: $gray-l5; background-color: $gray-l5;
padding: 0 ($baseline/2) ($baseline*0.75) ($baseline/2); padding: 0 ($baseline/2) ($baseline*0.75) ($baseline/2);
...@@ -60,18 +60,18 @@ ...@@ -60,18 +60,18 @@
} }
} }
td { td, .video-col {
padding: ($baseline/2); padding: ($baseline/2);
vertical-align: middle; vertical-align: middle;
text-align: left; text-align: left;
} }
tbody { tbody, .js-table-body {
box-shadow: 0 2px 2px $shadow-l1; box-shadow: 0 2px 2px $shadow-l1;
border: 1px solid $gray-l4; border: 1px solid $gray-l4;
background: $white; background: $white;
tr { tr, .video-row {
@include transition(all $tmg-f2 ease-in-out 0s); @include transition(all $tmg-f2 ease-in-out 0s);
border-top: 1px solid $gray-l4; border-top: 1px solid $gray-l4;
...@@ -106,7 +106,7 @@ ...@@ -106,7 +106,7 @@
} }
&:hover { &:hover {
background-color: $blue-l5; background-color: $blue-l5 !important;
.date-col, .date-col,
.embed-col, .embed-col,
......
...@@ -147,6 +147,11 @@ ...@@ -147,6 +147,11 @@
} }
.assets-library { .assets-library {
.js-table-body .video-id-col {
word-break: break-all;
}
.assets-title { .assets-title {
display: inline-block; display: inline-block;
width: flex-grid(5, 9); width: flex-grid(5, 9);
...@@ -163,4 +168,185 @@ ...@@ -163,4 +168,185 @@
@extend %actions-list; @extend %actions-list;
} }
} }
.video-table {
.video-row {
display: table;
table-layout: fixed;
width: 100%;
.video-col {
display: table-cell;
}
.name-col {
width: 25%;
}
.thumbnail-col, .video-id-col {
width: 15%;
}
.date-col, .status-col {
width: 10%;
}
.actions-col {
width: 5%;
}
.video-head-col.thumbnail-col {
width: 17% !important;
}
}
}
.thumbnail-error-wrapper {
display: table-row;
white-space: nowrap;
color: $red;
.icon {
margin: ($baseline*0.75) ($baseline/4) 0 ($baseline/2);
}
}
$thumbnail-width: ($baseline*7.5);
$thumbnail-height: ($baseline*5);
.thumbnail-wrapper {
position: relative;
max-width: $thumbnail-width;
max-height: $thumbnail-height;
img {
width: $thumbnail-width;
height: $thumbnail-height;
}
* {
cursor: pointer;
}
&.upload,
&.requirements {
border: 1px dashed $gray-l3;
}
&.requirements {
.requirements-text {
font-weight: 600;
}
.requirements-instructions {
font-size: 15px;
font-family: "Open Sans";
text-align: left;
color: $gray-d2;
line-height: 1.5;
}
.video-duration {
opacity: 0;
}
}
&.edit {
background: black;
&:hover,
&:focus,
&.focused {
img, .video-duration {
@include transition(all 0.3s linear);
opacity: 0.1;
}
}
}
&.progress {
background: white;
img {
@include transition(all 0.5s linear);
opacity: 0.15;
}
.action-icon {
display: block;
}
}
&.upload .thumbnail-action {
color: $blue;
}
&.progress .thumbnail-action {
.action-icon {
@include font-size(20);
}
}
&.edit {
background-color: #4e4e4e;
}
&.edit .thumbnail-action .action-icon.edit {
display: none;
}
&.edit .thumbnail-action .edit-container {
background-color: $white;
padding: ($baseline/4);
border-radius: ($baseline/5);
margin-top: ($baseline/2);
display: none;
}
&.edit .action-text {
color: $white;
}
.thumbnail-action {
@include font-size(14);
}
.thumbnail-overlay > :not(.upload-image-input) {
position: absolute;
text-align: center;
top: 50%;
left: 5px;;
right: 5px;
@include transform(translateY(-50%));
z-index: 1;
}
.upload-image-input {
position: absolute;
top: 0;
left: 0;
right: 0;
opacity: 0;
z-index: 6;
width: $thumbnail-width;
height: $thumbnail-height;
}
.video-duration {
position: absolute;
text-align: center;
bottom: 1px;
@include right(1px);
width: auto;
min-width: 25%;
color: white;
padding: ($baseline/10) ($baseline/5);
background-color: black;
}
&.focused {
box-shadow: 0 0 ($baseline/5) 1px $blue;
}
&.error {
box-shadow: 0 0 ($baseline/5) 1px $red;
}
}
} }
...@@ -5,17 +5,19 @@ ...@@ -5,17 +5,19 @@
<%- gettext("Download available encodings (.csv)") %> <%- gettext("Download available encodings (.csv)") %>
</a> </a>
</div> </div>
<table class="assets-table"> <div class="assets-table video-table">
<thead> <div class="js-table-head">
<tr> <div class="video-row">
<th><%- gettext("Name") %></th> <% if (videoImageUploadEnabled) { %>
<th><%- gettext("Duration") %></th> <div class="video-head-col video-col thumbnail-col"><%- gettext("Thumbnail") %></div>
<th><%- gettext("Date Added") %></th> <% } %>
<th><%- gettext("Video ID") %></th> <div class="video-head-col video-col name-col"><%- gettext("Name") %></div>
<th><%- gettext("Status") %></th> <div class="video-head-col video-col date-col"><%- gettext("Date Added") %></div>
<th><%- gettext("Action") %></th> <div class="video-head-col video-col video-id-col"><%- gettext("Video ID") %></div>
</tr> <div class="video-head-col video-col status-col"><%- gettext("Status") %></div>
</thead> <div class="video-head-col video-col actions-col"><%- gettext("Action") %></div>
<tbody class="js-table-body"></tbody> </div>
</table> </div>
<div class="js-table-body"></div>
</div>
</div> </div>
<td class="name-col"><%- client_video_id %></td> <div class="video-row-container">
<td class="duration-col"><%- duration %></td> <% if (videoImageUploadEnabled) { %>
<td class="date-col"><%- created %></td> <div class="video-col thumbnail-col"></div>
<td class="video-id-col"><%- edx_video_id %></td> <% } %>
<td class="status-col"><%- status %></td> <div class="video-col name-col"><%- client_video_id %></div>
<td class="actions-col"> <div class="video-col date-col"><%- created %></div>
<ul class="actions-list"> <div class="video-col video-id-col"><%- edx_video_id %></div>
<li class="action-item action-remove"> <div class="video-col status-col"><%- status %></div>
<a href="#" data-tooltip="<%- gettext('Remove this video') %>" class="remove-video-button action-button"> <div class="video-col actions-col">
<span class="icon fa fa-times-circle" aria-hidden="true"></span> <ul class="actions-list">
<span class="sr"><%- StringUtils.interpolate(gettext("Remove {video_name} video"), {video_name: client_video_id}) %></span> <li class="action-item action-remove">
</a> <a href="#" data-tooltip="<%- gettext('Remove this video') %>" class="remove-video-button action-button">
</li> <span class="icon fa fa-times-circle" aria-hidden="true"></span>
</ul> <span class="sr"><%- StringUtils.interpolate(gettext("Remove {video_name} video"), {video_name: client_video_id}) %></span>
</td> </a>
</li>
</ul>
</div>
</div>
<div class="thumbnail-error-wrapper thumbnail-error" data-video-id="<%- videoId %>">
<span class="icon fa fa-exclamation-triangle" aria-hidden="true"></span>
<span class="action-text"><%- errorText %></span>
</div>
<div class="thumbnail-wrapper <%- action === 'upload' ? 'upload' : '' %>" tabindex="-1">
<img src="<%- thumbnailURL %>" alt="<%- imageAltText %>">
<div class="thumbnail-overlay">
<input id="thumb-<%- videoId %>" class="upload-image-input" type="file" name="file" accept=".bmp, .jpg, .jpeg, .png, .gif"/>
<label for="thumb-<%- videoId %>" class="thumbnail-action">
<span class="main-icon action-icon <%- actionInfo.name %>" aria-hidden="true"><%- actionInfo.icon %></span>
<span class="action-text-sr sr"></span>
<span class="action-text"><%- actionInfo.text %></span>
<div class="edit-container">
<span class="action-icon" aria-hidden="true"><%- actionInfo.icon %></span>
<span class="edit-action-text"><%- actionInfo.actionText %></span>
</div>
</label>
<span class="requirements-text-sr sr">
<%- edx.StringUtils.interpolate(
gettext("Recommended image resolution is {imageResolution}, maximum image file size should be {maxFileSize} and format must be one of {supportedImageFormats}."),
{imageResolution: videoImageResolution, maxFileSize: videoImageMaxSize.humanize, supportedImageFormats: videoImageSupportedFileFormats.humanize}
) %>
</span>
</div>
<% if(duration) { %>
<div class="video-duration">
<span class="duration-text-human sr"><%- duration.humanize %></span>
<span class="duration-text-machine" aria-hidden="true"><%- duration.machine %></span>
</div>
<% } %>
</div>
...@@ -29,13 +29,16 @@ ...@@ -29,13 +29,16 @@
var $contentWrapper = $(".content-primary"); var $contentWrapper = $(".content-primary");
VideosIndexFactory( VideosIndexFactory(
$contentWrapper, $contentWrapper,
"${image_upload_url | n, js_escaped_string}",
"${video_handler_url | n, js_escaped_string}", "${video_handler_url | n, js_escaped_string}",
"${encodings_download_url | n, js_escaped_string}", "${encodings_download_url | n, js_escaped_string}",
"${default_video_image_url | n, js_escaped_string}",
${concurrent_upload_limit | n, dump_js_escaped_json}, ${concurrent_upload_limit | n, dump_js_escaped_json},
$(".nav-actions .upload-button"), $(".nav-actions .upload-button"),
$contentWrapper.data("previous-uploads"), $contentWrapper.data("previous-uploads"),
${video_supported_file_formats | n, dump_js_escaped_json}, ${video_supported_file_formats | n, dump_js_escaped_json},
${video_upload_max_file_size | n, dump_js_escaped_json} ${video_upload_max_file_size | n, dump_js_escaped_json},
${video_image_settings | n, dump_js_escaped_json}
); );
}); });
</%block> </%block>
......
from django.conf import settings from django.conf import settings
from django.conf.urls import include, patterns, url from django.conf.urls import include, patterns, url
from django.conf.urls.static import static
# There is a course creators admin table. # There is a course creators admin table.
from ratelimitbackend import admin from ratelimitbackend import admin
...@@ -112,6 +113,7 @@ urlpatterns += patterns( ...@@ -112,6 +113,7 @@ urlpatterns += patterns(
url(r'^textbooks/{}$'.format(settings.COURSE_KEY_PATTERN), 'textbooks_list_handler'), url(r'^textbooks/{}$'.format(settings.COURSE_KEY_PATTERN), 'textbooks_list_handler'),
url(r'^textbooks/{}/(?P<textbook_id>\d[^/]*)$'.format(settings.COURSE_KEY_PATTERN), 'textbooks_detail_handler'), url(r'^textbooks/{}/(?P<textbook_id>\d[^/]*)$'.format(settings.COURSE_KEY_PATTERN), 'textbooks_detail_handler'),
url(r'^videos/{}(?:/(?P<edx_video_id>[-\w]+))?$'.format(settings.COURSE_KEY_PATTERN), 'videos_handler'), url(r'^videos/{}(?:/(?P<edx_video_id>[-\w]+))?$'.format(settings.COURSE_KEY_PATTERN), 'videos_handler'),
url(r'^video_images/{}(?:/(?P<edx_video_id>[-\w]+))?$'.format(settings.COURSE_KEY_PATTERN), 'video_images_handler'),
url(r'^video_encodings_download/{}$'.format(settings.COURSE_KEY_PATTERN), 'video_encodings_download'), url(r'^video_encodings_download/{}$'.format(settings.COURSE_KEY_PATTERN), 'video_encodings_download'),
url(r'^group_configurations/{}$'.format(settings.COURSE_KEY_PATTERN), 'group_configurations_list_handler'), url(r'^group_configurations/{}$'.format(settings.COURSE_KEY_PATTERN), 'group_configurations_list_handler'),
url(r'^group_configurations/{}/(?P<group_configuration_id>\d+)(/)?(?P<group_id>\d+)?$'.format( url(r'^group_configurations/{}/(?P<group_configuration_id>\d+)(/)?(?P<group_id>\d+)?$'.format(
...@@ -189,6 +191,11 @@ if settings.DEBUG: ...@@ -189,6 +191,11 @@ if settings.DEBUG:
except ImportError: except ImportError:
pass pass
urlpatterns += static(
settings.VIDEO_IMAGE_SETTINGS['STORAGE_KWARGS']['base_url'],
document_root=settings.VIDEO_IMAGE_SETTINGS['STORAGE_KWARGS']['location']
)
if 'debug_toolbar' in settings.INSTALLED_APPS: if 'debug_toolbar' in settings.INSTALLED_APPS:
import debug_toolbar import debug_toolbar
urlpatterns += ( urlpatterns += (
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<div <div
id="video_id" id="video_id"
class="video closed" class="video closed"
data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": ["/base/fixtures/hls/hls.m3u8", "/base/fixtures/test.mp4","/base/fixtures/test.webm"], "speed": "1.5", "startTime": "", "streams": "", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "/base/fixtures/youtube_iframe_api.js", "ytImageUrl": "", "ytTestTimeout": "1500", "ytMetadataUrl": "www.googleapis.com/youtube/v3/videos/", "source": ""}' data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": ["/base/fixtures/hls/hls.m3u8", "/base/fixtures/test.mp4","/base/fixtures/test.webm"], "speed": "1.5", "startTime": "", "streams": "", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "/base/fixtures/youtube_iframe_api.js", "ytImageUrl": "", "ytTestTimeout": "1500", "ytMetadataUrl": "www.googleapis.com/youtube/v3/videos/", "source": "", "poster": "/media/video-images/poster.png"}'
> >
<div class="focus_grabber first"></div> <div class="focus_grabber first"></div>
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<div <div
id="video_id" id="video_id"
class="video closed" class="video closed"
data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": ["/base/fixtures/test.mp4","/base/fixtures/test.webm","/base/fixtures/test.ogv"], "speed": "1.5", "startTime": "", "streams": "", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "/base/fixtures/youtube_iframe_api.js", "ytImageUrl": "", "ytTestTimeout": "1500", "ytMetadataUrl": "www.googleapis.com/youtube/v3/videos/", "source": "", "html5_sources": ["http://youtu.be/3_yD_cEKoCk.mp4"]}' data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": ["/base/fixtures/test.mp4","/base/fixtures/test.webm","/base/fixtures/test.ogv"], "speed": "1.5", "startTime": "", "streams": "", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "/base/fixtures/youtube_iframe_api.js", "ytImageUrl": "", "ytTestTimeout": "1500", "ytMetadataUrl": "www.googleapis.com/youtube/v3/videos/", "source": "", "html5_sources": ["http://youtu.be/3_yD_cEKoCk.mp4"], "poster": "/media/video-images/poster.png"}'
> >
<div class="focus_grabber first"></div> <div class="focus_grabber first"></div>
......
...@@ -4,7 +4,8 @@ ...@@ -4,7 +4,8 @@
var state, var state,
oldOTBD, oldOTBD,
playbackRates = [0.75, 1.0, 1.25, 1.5], playbackRates = [0.75, 1.0, 1.25, 1.5],
describeInfo; describeInfo,
POSTER_URL = '/media/video-images/poster.png';
beforeEach(function() { beforeEach(function() {
oldOTBD = window.onTouchBasedDevice; oldOTBD = window.onTouchBasedDevice;
...@@ -320,6 +321,15 @@ ...@@ -320,6 +321,15 @@
}).done(done); }).done(done);
}); });
}); });
describe('poster', function() {
it('has url in player config', function() {
expect(state.videoPlayer.player.config.poster).toEqual(POSTER_URL);
expect(state.videoPlayer.player.videoEl).toHaveAttrs({
poster: POSTER_URL
});
});
});
}); });
describe('non-hls encoding', function() { describe('non-hls encoding', function() {
...@@ -338,6 +348,28 @@ ...@@ -338,6 +348,28 @@
jasmine.getEnv().describe(describeInfo.description, describeInfo.specDefinitions); jasmine.getEnv().describe(describeInfo.description, describeInfo.specDefinitions);
}); });
it('does not show poster for html5 video if url is not present', function() {
state = jasmine.initializePlayer(
'video_html5.html',
{
poster: null
}
);
expect(state.videoPlayer.player.config.poster).toEqual(null);
expect(state.videoPlayer.player.videoEl).not.toHaveAttr('poster');
});
it('does not show poster for hls video if url is not present', function() {
state = jasmine.initializePlayer(
'video_hls.html',
{
poster: null
}
);
expect(state.videoPlayer.player.config.poster).toEqual(null);
expect(state.videoPlayer.player.videoEl).not.toHaveAttr('poster');
});
it('native controls are used on iPhone', function() { it('native controls are used on iPhone', function() {
window.onTouchBasedDevice.and.returnValue(['iPhone']); window.onTouchBasedDevice.and.returnValue(['iPhone']);
......
...@@ -44,8 +44,11 @@ function(_) { ...@@ -44,8 +44,11 @@ function(_) {
* // video format of the source. Supported * // video format of the source. Supported
* // video formats are: 'mp4', 'webm', and * // video formats are: 'mp4', 'webm', and
* // 'ogg'. * // 'ogg'.
* poster: Video poster URL
* *
* events: { // Object's properties identify the * browserIsSafari: Flag to tell if current browser is Safari
*
* events: { // Object's properties identify the
* // events that the API fires, and the * // events that the API fires, and the
* // functions (event listeners) that the * // functions (event listeners) that the
* // API will call when those events occur. * // API will call when those events occur.
...@@ -320,6 +323,11 @@ function(_) { ...@@ -320,6 +323,11 @@ function(_) {
this.videoEl.prop('controls', true); this.videoEl.prop('controls', true);
} }
// Set video poster
if (this.config.poster) {
this.videoEl.prop('poster', this.config.poster);
}
// Place the <video> element on the page. // Place the <video> element on the page.
this.videoEl.appendTo(el.find('.video-player > div:first-child')); this.videoEl.appendTo(el.find('.video-player > div:first-child'));
}; };
......
...@@ -162,6 +162,7 @@ function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _) { ...@@ -162,6 +162,7 @@ function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _) {
commonPlayerConfig = { commonPlayerConfig = {
playerVars: state.videoPlayer.playerVars, playerVars: state.videoPlayer.playerVars,
videoSources: state.config.sources, videoSources: state.config.sources,
poster: state.config.poster,
browserIsSafari: state.browserIsSafari, browserIsSafari: state.browserIsSafari,
events: { events: {
onReady: state.videoPlayer.onReady, onReady: state.videoPlayer.onReady,
......
...@@ -18,7 +18,7 @@ import datetime ...@@ -18,7 +18,7 @@ import datetime
from uuid import uuid4 from uuid import uuid4
from lxml import etree from lxml import etree
from mock import ANY, Mock, patch from mock import ANY, Mock, patch, MagicMock
import ddt import ddt
from django.conf import settings from django.conf import settings
...@@ -673,7 +673,7 @@ class VideoExportTestCase(VideoDescriptorTestBase): ...@@ -673,7 +673,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
""" """
Test that we write the correct XML on export. Test that we write the correct XML on export.
""" """
def mock_val_export(edx_video_id): def mock_val_export(edx_video_id, course_id):
"""Mock edxval.api.export_to_xml""" """Mock edxval.api.export_to_xml"""
return etree.Element( return etree.Element(
'video_asset', 'video_asset',
...@@ -695,6 +695,7 @@ class VideoExportTestCase(VideoDescriptorTestBase): ...@@ -695,6 +695,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
self.descriptor.download_video = True self.descriptor.download_video = True
self.descriptor.transcripts = {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'} self.descriptor.transcripts = {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'}
self.descriptor.edx_video_id = 'test_edx_video_id' self.descriptor.edx_video_id = 'test_edx_video_id'
self.descriptor.runtime.course_id = MagicMock()
xml = self.descriptor.definition_to_xml(None) # We don't use the `resource_fs` parameter xml = self.descriptor.definition_to_xml(None) # We don't use the `resource_fs` parameter
parser = etree.XMLParser(remove_blank_text=True) parser = etree.XMLParser(remove_blank_text=True)
...@@ -718,6 +719,7 @@ class VideoExportTestCase(VideoDescriptorTestBase): ...@@ -718,6 +719,7 @@ class VideoExportTestCase(VideoDescriptorTestBase):
mock_val_api.ValVideoNotFoundError = _MockValVideoNotFoundError mock_val_api.ValVideoNotFoundError = _MockValVideoNotFoundError
mock_val_api.export_to_xml = Mock(side_effect=mock_val_api.ValVideoNotFoundError) mock_val_api.export_to_xml = Mock(side_effect=mock_val_api.ValVideoNotFoundError)
self.descriptor.edx_video_id = 'test_edx_video_id' self.descriptor.edx_video_id = 'test_edx_video_id'
self.descriptor.runtime.course_id = MagicMock()
xml = self.descriptor.definition_to_xml(None) xml = self.descriptor.definition_to_xml(None)
parser = etree.XMLParser(remove_blank_text=True) parser = etree.XMLParser(remove_blank_text=True)
......
...@@ -310,7 +310,10 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, ...@@ -310,7 +310,10 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
'streams': self.youtube_streams, 'streams': self.youtube_streams,
'sub': self.sub, 'sub': self.sub,
'sources': sources, 'sources': sources,
'poster': edxval_api and edxval_api.get_course_video_image_url(
course_id=self.runtime.course_id.for_branch(None),
edx_video_id=self.edx_video_id.strip()
),
# This won't work when we move to data that # This won't work when we move to data that
# isn't on the filesystem # isn't on the filesystem
'captionDataDir': getattr(self, 'data_dir', None), 'captionDataDir': getattr(self, 'data_dir', None),
...@@ -653,7 +656,10 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler ...@@ -653,7 +656,10 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
if self.edx_video_id and edxval_api: if self.edx_video_id and edxval_api:
try: try:
xml.append(edxval_api.export_to_xml(self.edx_video_id)) xml.append(edxval_api.export_to_xml(
self.edx_video_id,
unicode(self.runtime.course_id.for_branch(None)))
)
except edxval_api.ValVideoNotFoundError: except edxval_api.ValVideoNotFoundError:
pass pass
......
...@@ -50,32 +50,33 @@ class TestVideoYouTube(TestVideo): ...@@ -50,32 +50,33 @@ class TestVideoYouTube(TestVideo):
'handout': None, 'handout': None,
'id': self.item_descriptor.location.html_id(), 'id': self.item_descriptor.location.html_id(),
'metadata': json.dumps(OrderedDict({ 'metadata': json.dumps(OrderedDict({
"saveStateUrl": self.item_descriptor.xmodule_runtime.ajax_url + "/save_user_state", 'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
"autoplay": False, 'autoplay': False,
"streams": "0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg", 'streams': '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg',
"sub": "a_sub_file.srt.sjson", 'sub': 'a_sub_file.srt.sjson',
"sources": sources, 'sources': sources,
"captionDataDir": None, 'poster': None,
"showCaptions": "true", 'captionDataDir': None,
"generalSpeed": 1.0, 'showCaptions': 'true',
"speed": None, 'generalSpeed': 1.0,
"savedVideoPosition": 0.0, 'speed': None,
"start": 3603.0, 'savedVideoPosition': 0.0,
"end": 3610.0, 'start': 3603.0,
"transcriptLanguage": "en", 'end': 3610.0,
"transcriptLanguages": OrderedDict({"en": "English", "uk": u"Українська"}), 'transcriptLanguage': 'en',
"ytTestTimeout": 1500, 'transcriptLanguages': OrderedDict({'en': 'English', 'uk': u'Українська'}),
"ytApiUrl": "https://www.youtube.com/iframe_api", 'ytTestTimeout': 1500,
"ytMetadataUrl": "https://www.googleapis.com/youtube/v3/videos/", 'ytApiUrl': 'https://www.youtube.com/iframe_api',
"ytKey": None, 'ytMetadataUrl': 'https://www.googleapis.com/youtube/v3/videos/',
"transcriptTranslationUrl": self.item_descriptor.xmodule_runtime.handler_url( 'ytKey': None,
'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__' self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'), ).rstrip('/?'),
"transcriptAvailableTranslationsUrl": self.item_descriptor.xmodule_runtime.handler_url( 'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations' self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'), ).rstrip('/?'),
"autohideHtml5": False, 'autohideHtml5': False,
"recordedYoutubeIsAvailable": True, 'recordedYoutubeIsAvailable': True,
})), })),
'track': None, 'track': None,
'transcript_download_format': u'srt', 'transcript_download_format': u'srt',
...@@ -129,32 +130,33 @@ class TestVideoNonYouTube(TestVideo): ...@@ -129,32 +130,33 @@ class TestVideoNonYouTube(TestVideo):
'handout': None, 'handout': None,
'id': self.item_descriptor.location.html_id(), 'id': self.item_descriptor.location.html_id(),
'metadata': json.dumps(OrderedDict({ 'metadata': json.dumps(OrderedDict({
"saveStateUrl": self.item_descriptor.xmodule_runtime.ajax_url + "/save_user_state", 'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
"autoplay": False, 'autoplay': False,
"streams": "1.00:3_yD_cEKoCk", 'streams': '1.00:3_yD_cEKoCk',
"sub": "a_sub_file.srt.sjson", 'sub': 'a_sub_file.srt.sjson',
"sources": sources, 'sources': sources,
"captionDataDir": None, 'poster': None,
"showCaptions": "true", 'captionDataDir': None,
"generalSpeed": 1.0, 'showCaptions': 'true',
"speed": None, 'generalSpeed': 1.0,
"savedVideoPosition": 0.0, 'speed': None,
"start": 3603.0, 'savedVideoPosition': 0.0,
"end": 3610.0, 'start': 3603.0,
"transcriptLanguage": "en", 'end': 3610.0,
"transcriptLanguages": OrderedDict({"en": "English"}), 'transcriptLanguage': 'en',
"ytTestTimeout": 1500, 'transcriptLanguages': OrderedDict({'en': 'English'}),
"ytApiUrl": "https://www.youtube.com/iframe_api", 'ytTestTimeout': 1500,
"ytMetadataUrl": "https://www.googleapis.com/youtube/v3/videos/", 'ytApiUrl': 'https://www.youtube.com/iframe_api',
"ytKey": None, 'ytMetadataUrl': 'https://www.googleapis.com/youtube/v3/videos/',
"transcriptTranslationUrl": self.item_descriptor.xmodule_runtime.handler_url( 'ytKey': None,
'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__' self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'), ).rstrip('/?'),
"transcriptAvailableTranslationsUrl": self.item_descriptor.xmodule_runtime.handler_url( 'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations' self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'), ).rstrip('/?'),
"autohideHtml5": False, 'autohideHtml5': False,
"recordedYoutubeIsAvailable": True, 'recordedYoutubeIsAvailable': True,
})), })),
'track': None, 'track': None,
'transcript_download_format': u'srt', 'transcript_download_format': u'srt',
...@@ -185,32 +187,33 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -185,32 +187,33 @@ class TestGetHtmlMethod(BaseTestXmodule):
super(TestGetHtmlMethod, self).setUp() super(TestGetHtmlMethod, self).setUp()
self.setup_course() self.setup_course()
self.default_metadata_dict = OrderedDict({ self.default_metadata_dict = OrderedDict({
"saveStateUrl": "", 'saveStateUrl': '',
"autoplay": settings.FEATURES.get('AUTOPLAY_VIDEOS', True), 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
"streams": "1.00:3_yD_cEKoCk", 'streams': '1.00:3_yD_cEKoCk',
"sub": "a_sub_file.srt.sjson", 'sub': 'a_sub_file.srt.sjson',
"sources": '[]', 'sources': '[]',
"captionDataDir": None, 'poster': None,
"showCaptions": "true", 'captionDataDir': None,
"generalSpeed": 1.0, 'showCaptions': 'true',
"speed": None, 'generalSpeed': 1.0,
"savedVideoPosition": 0.0, 'speed': None,
"start": 3603.0, 'savedVideoPosition': 0.0,
"end": 3610.0, 'start': 3603.0,
"transcriptLanguage": "en", 'end': 3610.0,
"transcriptLanguages": OrderedDict({"en": "English"}), 'transcriptLanguage': 'en',
"ytTestTimeout": 1500, 'transcriptLanguages': OrderedDict({'en': 'English'}),
"ytApiUrl": "https://www.youtube.com/iframe_api", 'ytTestTimeout': 1500,
"ytMetadataUrl": "https://www.googleapis.com/youtube/v3/videos/", 'ytApiUrl': 'https://www.youtube.com/iframe_api',
"ytKey": None, 'ytMetadataUrl': 'https://www.googleapis.com/youtube/v3/videos/',
"transcriptTranslationUrl": self.item_descriptor.xmodule_runtime.handler_url( 'ytKey': None,
'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__' self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'), ).rstrip('/?'),
"transcriptAvailableTranslationsUrl": self.item_descriptor.xmodule_runtime.handler_url( 'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations' self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'), ).rstrip('/?'),
"autohideHtml5": False, 'autohideHtml5': False,
"recordedYoutubeIsAvailable": True, 'recordedYoutubeIsAvailable': True,
}) })
def test_get_html_track(self): def test_get_html_track(self):
...@@ -918,6 +921,19 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -918,6 +921,19 @@ class TestGetHtmlMethod(BaseTestXmodule):
context = self.item_descriptor.render(STUDENT_VIEW).content context = self.item_descriptor.render(STUDENT_VIEW).content
self.assertIn("'download_video_link': None", context) self.assertIn("'download_video_link': None", context)
@patch('xmodule.video_module.video_module.edxval_api.get_course_video_image_url')
def test_poster_image(self, get_course_video_image_url):
"""
Verify that poster image functionality works as expected.
"""
video_xml = '<video display_name="Video" download_video="true" edx_video_id="12345-67890">[]</video>'
get_course_video_image_url.return_value = '/media/video-images/poster.png'
self.initialize_module(data=video_xml)
context = self.item_descriptor.render(STUDENT_VIEW).content
self.assertIn('"poster": "/media/video-images/poster.png"', context)
@attr(shard=1) @attr(shard=1)
class TestVideoCDNRewriting(BaseTestXmodule): class TestVideoCDNRewriting(BaseTestXmodule):
...@@ -1261,7 +1277,7 @@ class TestVideoDescriptorStudentViewJson(TestCase): ...@@ -1261,7 +1277,7 @@ class TestVideoDescriptorStudentViewJson(TestCase):
'duration': self.TEST_DURATION, 'duration': self.TEST_DURATION,
'status': 'dummy', 'status': 'dummy',
'encoded_videos': [self.TEST_ENCODED_VIDEO], 'encoded_videos': [self.TEST_ENCODED_VIDEO],
'courses': [self.video.location.course_key] if associate_course_in_val else [], 'courses': [unicode(self.video.location.course_key)] if associate_course_in_val else [],
}) })
self.val_video = get_video_info(self.TEST_EDX_VIDEO_ID) # pylint: disable=attribute-defined-outside-init self.val_video = get_video_info(self.TEST_EDX_VIDEO_ID) # pylint: disable=attribute-defined-outside-init
...@@ -1391,6 +1407,7 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase): ...@@ -1391,6 +1407,7 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
def setUp(self): def setUp(self):
super(VideoDescriptorTest, self).setUp() super(VideoDescriptorTest, self).setUp()
self.descriptor.runtime.handler_url = MagicMock() self.descriptor.runtime.handler_url = MagicMock()
self.descriptor.runtime.course_id = MagicMock()
def test_get_context(self): def test_get_context(self):
"""" """"
...@@ -1438,7 +1455,7 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase): ...@@ -1438,7 +1455,7 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
actual = self.descriptor.definition_to_xml(resource_fs=None) actual = self.descriptor.definition_to_xml(resource_fs=None)
expected_str = """ expected_str = """
<video download_video="false" url_name="SampleProblem"> <video download_video="false" url_name="SampleProblem">
<video_asset client_video_id="test_client_video_id" duration="111.0"> <video_asset client_video_id="test_client_video_id" duration="111.0" image="">
<encoded_video profile="mobile" url="http://example.com/video" file_size="222" bitrate="333"/> <encoded_video profile="mobile" url="http://example.com/video" file_size="222" bitrate="333"/>
</video_asset> </video_asset>
</video> </video>
...@@ -1474,7 +1491,7 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase): ...@@ -1474,7 +1491,7 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
self.assertEqual(video_data['client_video_id'], 'test_client_video_id') self.assertEqual(video_data['client_video_id'], 'test_client_video_id')
self.assertEqual(video_data['duration'], 111) self.assertEqual(video_data['duration'], 111)
self.assertEqual(video_data['status'], 'imported') self.assertEqual(video_data['status'], 'imported')
self.assertEqual(video_data['courses'], [id_generator.target_course_id]) self.assertEqual(video_data['courses'], [{id_generator.target_course_id: None}])
self.assertEqual(video_data['encoded_videos'][0]['profile'], 'mobile') self.assertEqual(video_data['encoded_videos'][0]['profile'], 'mobile')
self.assertEqual(video_data['encoded_videos'][0]['url'], 'http://example.com/video') self.assertEqual(video_data['encoded_videos'][0]['url'], 'http://example.com/video')
self.assertEqual(video_data['encoded_videos'][0]['file_size'], 222) self.assertEqual(video_data['encoded_videos'][0]['file_size'], 222)
...@@ -1538,13 +1555,13 @@ class TestVideoWithBumper(TestVideo): ...@@ -1538,13 +1555,13 @@ class TestVideoWithBumper(TestVideo):
Test content with rendered bumper metadata. Test content with rendered bumper metadata.
""" """
get_url_for_profiles.return_value = { get_url_for_profiles.return_value = {
"desktop_mp4": "http://test_bumper.mp4", 'desktop_mp4': 'http://test_bumper.mp4',
"desktop_webm": "", 'desktop_webm': '',
} }
get_bumper_settings.return_value = { get_bumper_settings.return_value = {
"video_id": "edx_video_id", 'video_id': 'edx_video_id',
"transcripts": {}, 'transcripts': {},
} }
is_bumper_enabled.return_value = True is_bumper_enabled.return_value = True
...@@ -1556,17 +1573,17 @@ class TestVideoWithBumper(TestVideo): ...@@ -1556,17 +1573,17 @@ class TestVideoWithBumper(TestVideo):
'license': None, 'license': None,
'bumper_metadata': json.dumps(OrderedDict({ 'bumper_metadata': json.dumps(OrderedDict({
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', 'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
"showCaptions": "true", 'showCaptions': 'true',
"sources": ["http://test_bumper.mp4"], 'sources': ['http://test_bumper.mp4'],
'streams': '', 'streams': '',
"transcriptLanguage": "en", 'transcriptLanguage': 'en',
"transcriptLanguages": {"en": "English"}, 'transcriptLanguages': {'en': 'English'},
"transcriptTranslationUrl": video_utils.set_query_parameter( 'transcriptTranslationUrl': video_utils.set_query_parameter(
self.item_descriptor.xmodule_runtime.handler_url( self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__' self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'), 'is_bumper', 1 ).rstrip('/?'), 'is_bumper', 1
), ),
"transcriptAvailableTranslationsUrl": video_utils.set_query_parameter( 'transcriptAvailableTranslationsUrl': video_utils.set_query_parameter(
self.item_descriptor.xmodule_runtime.handler_url( self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations' self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'), 'is_bumper', 1 ).rstrip('/?'), 'is_bumper', 1
...@@ -1579,32 +1596,33 @@ class TestVideoWithBumper(TestVideo): ...@@ -1579,32 +1596,33 @@ class TestVideoWithBumper(TestVideo):
'handout': None, 'handout': None,
'id': self.item_descriptor.location.html_id(), 'id': self.item_descriptor.location.html_id(),
'metadata': json.dumps(OrderedDict({ 'metadata': json.dumps(OrderedDict({
"saveStateUrl": self.item_descriptor.xmodule_runtime.ajax_url + "/save_user_state", 'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
"autoplay": False, 'autoplay': False,
"streams": "0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg", 'streams': '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg',
"sub": "a_sub_file.srt.sjson", 'sub': 'a_sub_file.srt.sjson',
"sources": sources, 'sources': sources,
"captionDataDir": None, 'poster': None,
"showCaptions": "true", 'captionDataDir': None,
"generalSpeed": 1.0, 'showCaptions': 'true',
"speed": None, 'generalSpeed': 1.0,
"savedVideoPosition": 0.0, 'speed': None,
"start": 3603.0, 'savedVideoPosition': 0.0,
"end": 3610.0, 'start': 3603.0,
"transcriptLanguage": "en", 'end': 3610.0,
"transcriptLanguages": OrderedDict({"en": "English", "uk": u"Українська"}), 'transcriptLanguage': 'en',
"ytTestTimeout": 1500, 'transcriptLanguages': OrderedDict({'en': 'English', 'uk': u'Українська'}),
"ytApiUrl": "https://www.youtube.com/iframe_api", 'ytTestTimeout': 1500,
"ytMetadataUrl": "https://www.googleapis.com/youtube/v3/videos/", 'ytApiUrl': 'https://www.youtube.com/iframe_api',
"ytKey": None, 'ytMetadataUrl': 'https://www.googleapis.com/youtube/v3/videos/',
"transcriptTranslationUrl": self.item_descriptor.xmodule_runtime.handler_url( 'ytKey': None,
'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__' self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'), ).rstrip('/?'),
"transcriptAvailableTranslationsUrl": self.item_descriptor.xmodule_runtime.handler_url( 'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations' self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'), ).rstrip('/?'),
"autohideHtml5": False, 'autohideHtml5': False,
"recordedYoutubeIsAvailable": True, 'recordedYoutubeIsAvailable': True,
})), })),
'track': None, 'track': None,
'transcript_download_format': u'srt', 'transcript_download_format': u'srt',
...@@ -1613,8 +1631,8 @@ class TestVideoWithBumper(TestVideo): ...@@ -1613,8 +1631,8 @@ class TestVideoWithBumper(TestVideo):
{'display_name': 'Text (.txt) file', 'value': 'txt'} {'display_name': 'Text (.txt) file', 'value': 'txt'}
], ],
'poster': json.dumps(OrderedDict({ 'poster': json.dumps(OrderedDict({
"url": "http://img.youtube.com/vi/ZwkTiUPN0mg/0.jpg", 'url': 'http://img.youtube.com/vi/ZwkTiUPN0mg/0.jpg',
"type": "youtube" 'type': 'youtube'
})) }))
} }
......
...@@ -796,6 +796,9 @@ XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {}) ...@@ -796,6 +796,9 @@ XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {})
XBLOCK_SETTINGS.setdefault("VideoDescriptor", {})["licensing_enabled"] = FEATURES.get("LICENSING", False) XBLOCK_SETTINGS.setdefault("VideoDescriptor", {})["licensing_enabled"] = FEATURES.get("LICENSING", False)
XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.get('YOUTUBE_API_KEY', YOUTUBE_API_KEY) XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.get('YOUTUBE_API_KEY', YOUTUBE_API_KEY)
##### VIDEO IMAGE STORAGE #####
VIDEO_IMAGE_SETTINGS = ENV_TOKENS.get('VIDEO_IMAGE_SETTINGS', VIDEO_IMAGE_SETTINGS)
##### CDN EXPERIMENT/MONITORING FLAGS ##### ##### CDN EXPERIMENT/MONITORING FLAGS #####
CDN_VIDEO_URLS = ENV_TOKENS.get('CDN_VIDEO_URLS', CDN_VIDEO_URLS) CDN_VIDEO_URLS = ENV_TOKENS.get('CDN_VIDEO_URLS', CDN_VIDEO_URLS)
ONLOAD_BEACON_SAMPLE_RATE = ENV_TOKENS.get('ONLOAD_BEACON_SAMPLE_RATE', ONLOAD_BEACON_SAMPLE_RATE) ONLOAD_BEACON_SAMPLE_RATE = ENV_TOKENS.get('ONLOAD_BEACON_SAMPLE_RATE', ONLOAD_BEACON_SAMPLE_RATE)
......
...@@ -2564,6 +2564,22 @@ MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = 15 * 60 ...@@ -2564,6 +2564,22 @@ MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = 15 * 60
TIME_ZONE_DISPLAYED_FOR_DEADLINES = 'UTC' TIME_ZONE_DISPLAYED_FOR_DEADLINES = 'UTC'
########################## VIDEO IMAGE STORAGE ############################
VIDEO_IMAGE_SETTINGS = dict(
VIDEO_IMAGE_MAX_BYTES=2 * 1024 * 1024, # 2 MB
VIDEO_IMAGE_MIN_BYTES=2 * 1024, # 2 KB
# Backend storage
# STORAGE_CLASS='storages.backends.s3boto.S3BotoStorage',
# STORAGE_KWARGS=dict(bucket='video-image-bucket'),
STORAGE_KWARGS=dict(
location=MEDIA_ROOT,
base_url=MEDIA_URL,
),
DIRECTORY_PREFIX='video-images/',
)
# Source: # Source:
# http://loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt according to http://en.wikipedia.org/wiki/ISO_639-1 # http://loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt according to http://en.wikipedia.org/wiki/ISO_639-1
# Note that this is used as the set of choices to the `code` field of the # Note that this is used as the set of choices to the `code` field of the
......
...@@ -7,7 +7,6 @@ ...@@ -7,7 +7,6 @@
<input class="upload-button-input" type="file" name="<%= inputName %>"/> <input class="upload-button-input" type="file" name="<%= inputName %>"/>
</label> </label>
<button class="upload-submit" type="button" hidden="true"><%= uploadButtonTitle %></button> <button class="upload-submit" type="button" hidden="true"><%= uploadButtonTitle %></button>
<button class="u-field-remove-button" type="button"> <button class="u-field-remove-button" type="button">
<span class="remove-button-icon" aria-hidden="true"><%= removeButtonIcon %></span> <span class="remove-button-icon" aria-hidden="true"><%= removeButtonIcon %></span>
<span class="remove-button-title" aria-live="polite"><%= removeButtonTitle %></span> <span class="remove-button-title" aria-live="polite"><%= removeButtonTitle %></span>
......
...@@ -11,7 +11,7 @@ from PIL import Image ...@@ -11,7 +11,7 @@ from PIL import Image
@contextmanager @contextmanager
def make_image_file(dimensions=(320, 240), extension=".jpeg", force_size=None, orientation=None): def make_image_file(dimensions=(320, 240), prefix='tmp', extension='.jpeg', force_size=None, orientation=None):
""" """
Yields a named temporary file created with the specified image type and Yields a named temporary file created with the specified image type and
options. options.
...@@ -21,9 +21,13 @@ def make_image_file(dimensions=(320, 240), extension=".jpeg", force_size=None, o ...@@ -21,9 +21,13 @@ def make_image_file(dimensions=(320, 240), extension=".jpeg", force_size=None, o
The temporary file will be closed and deleted automatically upon exiting The temporary file will be closed and deleted automatically upon exiting
the `with` block. the `with` block.
prefix - To add prefix to random image file name, after adding will be like <custom-prefix><random-name>.png
otherwise by default `tmp` is added making file name tmp<random-name>.png.
""" """
image = Image.new('RGB', dimensions, "green") image = Image.new('RGB', dimensions, "green")
image_file = NamedTemporaryFile(suffix=extension) image_file = NamedTemporaryFile(prefix=prefix, suffix=extension)
try: try:
if orientation and orientation in xrange(1, 9): if orientation and orientation in xrange(1, 9):
exif_bytes = piexif.dump({'0th': {piexif.ImageIFD.Orientation: orientation}}) exif_bytes = piexif.dump({'0th': {piexif.ImageIFD.Orientation: orientation}})
......
...@@ -77,7 +77,7 @@ git+https://github.com/edx/lettuce.git@0.2.20.002#egg=lettuce==0.2.20.002 ...@@ -77,7 +77,7 @@ git+https://github.com/edx/lettuce.git@0.2.20.002#egg=lettuce==0.2.20.002
git+https://github.com/edx/edx-ora2.git@1.4.3#egg=ora2==1.4.3 git+https://github.com/edx/edx-ora2.git@1.4.3#egg=ora2==1.4.3
-e git+https://github.com/edx/edx-submissions.git@2.0.0#egg=edx-submissions==2.0.0 -e git+https://github.com/edx/edx-submissions.git@2.0.0#egg=edx-submissions==2.0.0
git+https://github.com/edx/ease.git@release-2015-07-14#egg=ease==0.1.3 git+https://github.com/edx/ease.git@release-2015-07-14#egg=ease==0.1.3
git+https://github.com/edx/edx-val.git@0.0.13#egg=edxval==0.0.13 git+https://github.com/edx/edx-val.git@0.0.16#egg=edxval==0.0.16
git+https://github.com/pmitros/RecommenderXBlock.git@v1.2#egg=recommender-xblock==1.2 git+https://github.com/pmitros/RecommenderXBlock.git@v1.2#egg=recommender-xblock==1.2
git+https://github.com/solashirai/crowdsourcehinter.git@518605f0a95190949fe77bd39158450639e2e1dc#egg=crowdsourcehinter-xblock==0.1 git+https://github.com/solashirai/crowdsourcehinter.git@518605f0a95190949fe77bd39158450639e2e1dc#egg=crowdsourcehinter-xblock==0.1
-e git+https://github.com/pmitros/RateXBlock.git@367e19c0f6eac8a5f002fd0f1559555f8e74bfff#egg=rate-xblock -e git+https://github.com/pmitros/RateXBlock.git@367e19c0f6eac8a5f002fd0f1559555f8e74bfff#egg=rate-xblock
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment