Skip to content

Commit

Permalink
Improve video size and format validation (#2755)
Browse files Browse the repository at this point in the history
  • Loading branch information
kienstra authored and swissspidy committed Jul 16, 2019
1 parent 13075c1 commit 715ecb2
Show file tree
Hide file tree
Showing 20 changed files with 462 additions and 76 deletions.
12 changes: 12 additions & 0 deletions assets/css/amp-stories-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,15 @@ div[data-amp-caption="noCaption"] .wp-block-video figcaption {
height: 100%;
width: 100%;
}

/* If the Media Library has 2 notices, like for wrong video file type and size, prevent them from covering the media. */
.media-frame.has-two-notices .media-frame-content {
bottom: 120px;
}

/* More space for notices in the sidebar */

.components-panel__body .components-notice {
margin-left: 0;
margin-right: 0;
}
47 changes: 42 additions & 5 deletions assets/src/common/components/select-media-frame.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { enforceFileType, getNoticeTemplate } from '../helpers';
import { enforceFileSize, enforceFileType, getNoticeTemplate, mediaLibraryHasTwoNotices } from '../helpers';

const { wp } = window;

const NOTICE_CLASSNAME = 'notice notice-warning notice-alt inline';

/**
* FeaturedImageSelectionError
*
Expand All @@ -24,7 +26,7 @@ const { wp } = window;
* @augments Backbone.View
*/
const FeaturedImageSelectionError = wp.media.View.extend( {
className: 'notice notice-warning notice-alt inline',
className: NOTICE_CLASSNAME,
template: ( () => {
const message = sprintf(
/* translators: 1: image width in pixels. 2: image height in pixels. 3: required minimum width in pixels. 4: required minimum height in pixels. */
Expand Down Expand Up @@ -63,6 +65,31 @@ export const SelectionFileTypeError = wp.media.View.extend( {
} )(),
} );

/**
* SelectionFileSizeError
*
* Applies when the video size is more than a certain amount of MB per second.
* Very similar to the FeaturedImageSelectionError class.
*
* @class
* @augments wp.media.View
* @augments wp.Backbone.View
* @augments Backbone.View
*/
export const SelectionFileSizeError = wp.media.View.extend( {
className: NOTICE_CLASSNAME,
template: ( () => {
const message = sprintf(
/* translators: 1: the recommended max MB per second for videos. 2: the actual MB per second of the video. */
__( 'A video size of less than %1$s MB per second is recommended. The selected video is %2$s MB per second.', 'amp' ),
'{{maxVideoMegabytesPerSecond}}',
'{{actualVideoMegabytesPerSecond}}',
);

return getNoticeTemplate( message );
} )(),
} );

/**
* FeaturedImageToolbarSelect
*
Expand Down Expand Up @@ -115,7 +142,7 @@ export const FeaturedImageToolbarSelect = wp.media.view.Toolbar.Select.extend( {
} );

/**
* EnforcedFileTypeToolbarSelect
* EnforcedFileToolbarSelect
*
* Prevents selecting an attachment that has the wrong file type, like .mov or .txt.
*
Expand All @@ -127,7 +154,7 @@ export const FeaturedImageToolbarSelect = wp.media.view.Toolbar.Select.extend( {
* @augments Backbone.View
* @inheritDoc
*/
export const EnforcedFileTypeToolbarSelect = wp.media.view.Toolbar.Select.extend( {
export const EnforcedFileToolbarSelect = wp.media.view.Toolbar.Select.extend( {
/**
* Refresh the view.
*/
Expand All @@ -139,6 +166,13 @@ export const EnforcedFileTypeToolbarSelect = wp.media.view.Toolbar.Select.extend
const attachment = selection.models[ 0 ];

enforceFileType.call( this, attachment, SelectionFileTypeError );
enforceFileSize.call( this, attachment, SelectionFileSizeError );

// If there are two notices, like for wrong size and type, prevent the notices from covering the media.
const mediaFrame = this.$el.parents( '.media-frame' );
if ( mediaFrame ) {
mediaFrame.toggleClass( 'has-two-notices', mediaLibraryHasTwoNotices.call( this ) );
}
},
} );

Expand Down Expand Up @@ -175,7 +209,10 @@ export const getSelectMediaFrame = ( ToolbarSelect ) => {
createSelectToolbar( toolbar, options ) {
options = options || this.options.button || {};
options.controller = this;
options = Object.assign( {}, options, { allowedTypes: get( this, [ 'options', 'allowedTypes' ], null ) } );
options = {
...options,
allowedTypes: get( this, [ 'options', 'allowedTypes' ], null ),
};

toolbar.view = new ToolbarSelect( options );
},
Expand Down
4 changes: 2 additions & 2 deletions assets/src/common/components/with-enforced-file-type.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { EnforcedFileTypeToolbarSelect, getSelectMediaFrame } from './select-media-frame';
import { EnforcedFileToolbarSelect, getSelectMediaFrame } from './select-media-frame';

const { wp } = window;

Expand Down Expand Up @@ -55,7 +55,7 @@ export default ( InitialMediaUpload ) => {
* @see wp.media.CroppedImageControl.initFrame
*/
initFileTypeMedia() {
const SelectMediaFrame = getSelectMediaFrame( EnforcedFileTypeToolbarSelect );
const SelectMediaFrame = getSelectMediaFrame( EnforcedFileToolbarSelect );
const previousOnSelect = this.onSelect;
const isVideo = isEqual( [ 'video' ], this.props.allowedTypes );
const queryType = isVideo ? 'video/mp4' : this.props.allowedTypes; // For the Video block, only display .mp4 files.
Expand Down
4 changes: 4 additions & 0 deletions assets/src/common/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@
export const MIN_FONT_SIZE = 6;
export const MAX_FONT_SIZE = 72;
export const MINIMUM_FEATURED_IMAGE_WIDTH = 1200;
export const MEGABYTE_IN_BYTES = 1000000;
export const VIDEO_ALLOWED_MEGABYTES_PER_SECOND = 1;
export const FILE_TYPE_ERROR_VIEW = 'select-file-type-error';
export const FILE_SIZE_ERROR_VIEW = 'select-file-size-error';
121 changes: 114 additions & 7 deletions assets/src/common/helpers/index.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
/**
* External dependencies
*/
import { get, includes, template } from 'lodash';

import { get, has, includes, reduce, template } from 'lodash';
/**
* WordPress dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { getColorObjectByAttributeValues, getColorObjectByColorValue } from '@wordpress/block-editor';

/**
* Internal dependencies
*/
import { MINIMUM_FEATURED_IMAGE_WIDTH } from '../constants';
import {
FILE_SIZE_ERROR_VIEW,
FILE_TYPE_ERROR_VIEW,
MEGABYTE_IN_BYTES,
MINIMUM_FEATURED_IMAGE_WIDTH,
VIDEO_ALLOWED_MEGABYTES_PER_SECOND,
} from '../constants';

/**
* Determines whether whether the image has the minimum required dimensions.
Expand Down Expand Up @@ -265,23 +269,126 @@ export const enforceFileType = function( attachment, SelectionError ) {
return;
}

const fileTypeError = 'select-file-type-error';
const allowedTypes = get( this, [ 'options', 'allowedTypes' ], null );
const selectButton = this.get( 'select' );

// If the file type isn't allowed, display a notice and disable the 'Select' button.
if ( allowedTypes && attachment.get( 'type' ) && ! isFileTypeAllowed( attachment, allowedTypes ) ) {
this.secondary.set(
fileTypeError,
FILE_TYPE_ERROR_VIEW,
new SelectionError( { mimeType: attachment.get( 'mime' ) } )
);
if ( selectButton && selectButton.model ) {
selectButton.model.set( 'disabled', true ); // Disable the button to select the file.
}
} else {
this.secondary.unset( fileTypeError );
this.secondary.unset( FILE_TYPE_ERROR_VIEW );
if ( selectButton && selectButton.model ) {
selectButton.model.set( 'disabled', false ); // Enable the button to select the file.
}
}
};

/**
* If the attachment has the wrong file size, this displays a notice in the Media Library and disables the 'Select' button.
*
* This is not an arrow function so that it can be called with enforceFileSize.call( this, foo, bar ).
*
* @param {Object} attachment The selected attachment.
* @param {Object} SelectionError The error to display.
*/
export const enforceFileSize = function( attachment, SelectionError ) {
if ( ! attachment ) {
return;
}

const isVideo = 'video' === get( attachment, [ 'media_type' ], null ) || 'video' === get( attachment, [ 'attributes', 'type' ], null );

// If the file type is 'video' and its size is over the limit, display a notice in the Media Library.
if ( isVideo && isVideoSizeExcessive( getVideoBytesPerSecond( attachment ) ) ) {
this.secondary.set(
FILE_SIZE_ERROR_VIEW,
new SelectionError( {
actualVideoMegabytesPerSecond: Math.round( getVideoBytesPerSecond( attachment ) / MEGABYTE_IN_BYTES ),
maxVideoMegabytesPerSecond: VIDEO_ALLOWED_MEGABYTES_PER_SECOND,
} )
);
} else {
this.secondary.unset( FILE_SIZE_ERROR_VIEW );
}
};

/**
* Gets whether the Media Library has two notices.
*
* It's possible to have a notice that the file type and size are wrong.
* In that case, this will need different styling, so the notices don't overlap the media.
*
* @return {boolean} Whether the Media Library has two notices.
*/
export const mediaLibraryHasTwoNotices = function() {
return !! this.secondary.get( FILE_TYPE_ERROR_VIEW ) && !! this.secondary.get( FILE_SIZE_ERROR_VIEW );
};

/**
* Gets the number of megabytes per second for the video.
*
* @param {Object} media The media object of the video.
* @return {?number} Number of megabytes per second, or null if media details unavailable.
*/
export const getVideoBytesPerSecond = ( media ) => {
if ( has( media, [ 'media_details', 'filesize' ] ) && has( media, [ 'media_details', 'length' ] ) ) {
return media.media_details.filesize / media.media_details.length;
} else if ( has( media, [ 'attributes', 'filesizeInBytes' ] ) && has( media, [ 'attributes', 'fileLength' ] ) ) {
return media.attributes.filesizeInBytes / getSecondsFromTime( media.attributes.fileLength );
}

return null;
};

/**
* Gets whether the video file size is over a certain amount of bytes per second.
*
* @param {number} videoSize Video size per second, in bytes.
* @return {boolean} Whether the file size is more than a certain amount of MB per second, or null of the data isn't available.
*/
export const isVideoSizeExcessive = ( videoSize ) => {
return videoSize > VIDEO_ALLOWED_MEGABYTES_PER_SECOND * MEGABYTE_IN_BYTES;
};

/**
* Gets the number of seconds in a colon-separated time string, like '01:10'.
*
* @param {string} time A colon-separated time, like '0:12'.
* @return {number} seconds The number of seconds in the time, like 12.
*/
export const getSecondsFromTime = ( time ) => {
const minuteInSeconds = 60;
const splitTime = time.split( ':' );

return reduce(
splitTime,
( totalSeconds, timeSection, index ) => {
const parsedTimeSection = isNaN( parseInt( timeSection ) ) ? 0 : parseInt( timeSection );
const distanceFromRight = splitTime.length - 1 - index;
const multiple = Math.pow( minuteInSeconds, distanceFromRight ); // This should be 1 for seconds, 60 for minutes, 360 for hours...
return totalSeconds + ( multiple * parsedTimeSection );
},
0
);
};

/**
* Given a URL, returns file size in bytes.
*
* @param {string} url URL to a file.
* @return {Promise<number>} File size in bytes.
*/
export const getContentLengthFromUrl = async ( url ) => {
const { fetch } = window;

const response = await fetch( url, {
method: 'head',
} );
return Number( response.headers.get( 'content-length' ) );
};
79 changes: 79 additions & 0 deletions assets/src/common/helpers/test/enforceFileSize.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Internal dependencies
*/
import { enforceFileSize } from '../';
import { Mock, AlternateMock, MockSelectionError } from './fixtures/mockClasses';
import { FILE_SIZE_ERROR_VIEW } from '../../constants';

describe( 'enforceFileSize', () => {
it( 'should have a new error when the video file size is too big', () => {
const mockThis = new Mock();
const selectButton = { model: new AlternateMock() };
mockThis.set( {
secondary: new AlternateMock(),
select: selectButton,
} );

const attachment = new Mock();
const filesize = 12000000;
const length = 4;
attachment.set( {
media_details: { filesize, length },
media_type: 'video',
} );

enforceFileSize.call( mockThis, attachment, MockSelectionError );
const actualSelectionError = mockThis.secondary.get( FILE_SIZE_ERROR_VIEW );

expect( actualSelectionError.get( 'maxVideoMegabytesPerSecond' ) ).toBe( 1 );
expect( actualSelectionError.get( 'actualVideoMegabytesPerSecond' ) ).toBe( 3 );

// This shouldn't disable the 'Select' button in the Media Library.
expect( selectButton.model.get( 'disabled' ) ).toBe( undefined );
} );

it( 'should not have an error when the video file size is under the maximum', () => {
const mockThis = new Mock();
const selectButton = { model: new AlternateMock() };
mockThis.set( {
secondary: new AlternateMock(),
select: selectButton,
} );

const attachment = new Mock();
const filesize = 6000000;
const length = 6;
attachment.set( {
media_details: { filesize, length },
media_type: 'video',
} );

enforceFileSize.call( mockThis, attachment, MockSelectionError );

expect( mockThis.secondary.get( FILE_SIZE_ERROR_VIEW ) ).toBe( undefined );
expect( selectButton.model.get( 'disabled' ) ).toBe( undefined );
} );

it( 'should not have an error if the file type is not a video', () => {
const mockThis = new Mock();
const selectButton = { model: new AlternateMock() };
mockThis.set( {
secondary: new AlternateMock(),
select: selectButton,
} );

const attachment = new Mock();
const filesize = 12000000;
const length = 4;
const nonVideo = 'image';
attachment.set( {
media_details: { filesize, length },
media_type: nonVideo,
} );

enforceFileSize.call( mockThis, attachment, MockSelectionError );

expect( mockThis.secondary.get( FILE_SIZE_ERROR_VIEW ) ).toBe( undefined );
expect( selectButton.model.get( 'disabled' ) ).toBe( undefined );
} );
} );
Loading

0 comments on commit 715ecb2

Please sign in to comment.