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=[]
......
...@@ -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( 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);
......
...@@ -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
......
...@@ -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