diff --git a/packages/design-system/src/icons/camera_off.svg b/packages/design-system/src/icons/camera_off.svg new file mode 100644 index 000000000000..7af068bec99b --- /dev/null +++ b/packages/design-system/src/icons/camera_off.svg @@ -0,0 +1 @@ + diff --git a/packages/design-system/src/icons/index.js b/packages/design-system/src/icons/index.js index 08e0b2fa6962..7a4f4dd7640f 100644 --- a/packages/design-system/src/icons/index.js +++ b/packages/design-system/src/icons/index.js @@ -48,6 +48,7 @@ export { default as Box4 } from './box4.svg'; export { default as Box4Alternate } from './box4_alternate.svg'; export { default as Bucket } from './bucket.svg'; export { default as Camera } from './camera.svg'; +export { default as CameraOff } from './camera_off.svg'; export { default as Captions } from './captions.svg'; export { default as Checkbox } from './checkbox.svg'; export { default as Checklist } from './checklist.svg'; diff --git a/packages/elements/src/types.js b/packages/elements/src/types.js index 62e1b4a24499..d7748e066a6d 100644 --- a/packages/elements/src/types.js +++ b/packages/elements/src/types.js @@ -29,14 +29,17 @@ import { MULTIPLE_VALUE, BACKGROUND_TEXT_MODE, OverlayType } from './constants'; const StoryPropTypes = {}; -export const BackgroundAudioPropType = PropTypes.shape({ +export const BackgroundAudioPropTypeShape = { id: PropTypes.number, src: PropTypes.string, length: PropTypes.number, lengthFormatted: PropTypes.string, mimeType: PropTypes.string, needsProxy: PropTypes.bool, -}); +}; +export const BackgroundAudioPropType = PropTypes.shape( + BackgroundAudioPropTypeShape +); StoryPropTypes.mask = PropTypes.shape({ type: PropTypes.string.isRequired, diff --git a/packages/story-editor/src/app/highlights/quickActions/useQuickActions.js b/packages/story-editor/src/app/highlights/quickActions/useQuickActions.js index 5ca4bc40a86c..441398b05722 100644 --- a/packages/story-editor/src/app/highlights/quickActions/useQuickActions.js +++ b/packages/story-editor/src/app/highlights/quickActions/useQuickActions.js @@ -72,20 +72,15 @@ const { Settings, } = Icons; -const StyledSettings = styled(Settings).attrs({ +const quickActionIconAttrs = { width: 24, height: 24, -})``; - -const Mic = styled(Icons.Mic).attrs({ - width: 24, - height: 24, -})``; - -const MicOff = styled(Icons.MicOff).attrs({ - width: 24, - height: 24, -})``; +}; +const StyledSettings = styled(Settings).attrs(quickActionIconAttrs)``; +const Mic = styled(Icons.Mic).attrs(quickActionIconAttrs)``; +const MicOff = styled(Icons.MicOff).attrs(quickActionIconAttrs)``; +const Video = styled(Icons.Camera).attrs(quickActionIconAttrs)``; +const VideoOff = styled(Icons.CameraOff).attrs(quickActionIconAttrs)``; export const MediaPicker = ({ render, ...props }) => { const { @@ -302,20 +297,26 @@ const useQuickActions = () => { const { isInRecordingMode, toggleRecordingMode, + toggleVideo, toggleAudio, + hasVideo, hasAudio, toggleSettings, audioInput, + videoInput, isReady, } = useMediaRecording(({ state, actions }) => ({ isInRecordingMode: state.isInRecordingMode, hasAudio: state.hasAudio, + hasVideo: state.hasVideo, audioInput: state.audioInput, + videoInput: state.videoInput, isReady: state.status === 'ready' && !state.file?.type?.startsWith('image') && !state.isCountingDown, toggleRecordingMode: actions.toggleRecordingMode, + toggleVideo: actions.toggleVideo, toggleAudio: actions.toggleAudio, toggleSettings: actions.toggleSettings, muteAudio: actions.muteAudio, @@ -813,15 +814,32 @@ const useQuickActions = () => { }); toggleAudio(); }, - disabled: !isReady, + disabled: !isReady || !hasVideo, + ...actionMenuProps, + }, + videoInput && { + Icon: hasVideo ? Video : VideoOff, + label: hasVideo + ? __('Disable Video', 'web-stories') + : __('Enable Video', 'web-stories'), + onClick: () => { + trackEvent('media_recording_video_toggled', { + status: hasVideo ? 'off' : 'on', + }); + toggleVideo(); + }, + disabled: !isReady || !hasAudio, ...actionMenuProps, }, ].filter(Boolean); }, [ actionMenuProps, audioInput, + videoInput, + hasVideo, hasAudio, toggleAudio, + toggleVideo, toggleRecordingMode, toggleSettings, isReady, diff --git a/packages/story-editor/src/app/highlights/states.js b/packages/story-editor/src/app/highlights/states.js index 9b1db9d7794d..f6157d71d403 100644 --- a/packages/story-editor/src/app/highlights/states.js +++ b/packages/story-editor/src/app/highlights/states.js @@ -58,6 +58,9 @@ const keys = { MEDIA3P: 'MEDIA3P', TEXT_SET: 'TEXT', PAGE_TEMPLATES: 'PAGE_TEMPLATES', + + // DOCUMENT + BACKGROUND_AUDIO: 'BACKGROUND_AUDIO', }; export const STATES = { @@ -84,6 +87,10 @@ export const STATES = { focus: true, tab: DOCUMENT, }, + [keys.BACKGROUND_AUDIO]: { + focus: true, + tab: DOCUMENT, + }, [keys.CAPTIONS]: { focus: true, tab: STYLE, diff --git a/packages/story-editor/src/app/media/useUploadMedia.js b/packages/story-editor/src/app/media/useUploadMedia.js index 26bc6b9e5142..5f2d2fb9e599 100644 --- a/packages/story-editor/src/app/media/useUploadMedia.js +++ b/packages/story-editor/src/app/media/useUploadMedia.js @@ -278,7 +278,11 @@ function useUploadMedia({ const isTooLarge = canTranscode && isFileTooLarge(file); try { - validateFileForUpload(file, canTranscode, isTooLarge); + validateFileForUpload({ + file, + canTranscodeFile: canTranscode, + isFileTooLarge: isTooLarge, + }); } catch (e) { showSnackbar({ message: e.message, diff --git a/packages/story-editor/src/app/media/utils/useFFmpeg.js b/packages/story-editor/src/app/media/utils/useFFmpeg.js index 959b33bdaac2..8d37c68bc4de 100644 --- a/packages/story-editor/src/app/media/utils/useFFmpeg.js +++ b/packages/story-editor/src/app/media/utils/useFFmpeg.js @@ -457,6 +457,59 @@ function useFFmpeg() { [getFFmpegInstance] ); + /** + * Converts any audio file format to MP3 using FFmpeg. + * + * @param {File} file Original audio file object. + * @return {Promise} Converted video file object. + */ + const convertToMp3 = useCallback( + async (file) => { + //eslint-disable-next-line @wordpress/no-unused-vars-before-return -- False positive because of the finally(). + const trackTiming = getTimeTracker('load_mp3_conversion'); + + let ffmpeg; + + try { + ffmpeg = await getFFmpegInstance(file); + + const tempFileName = uuidv4() + '.mp3'; + const outputFileName = getFileName(file) + '.mp3'; + + await ffmpeg.run( + // Input filename. + '-i', + file.name, + // Output filename. MUST be different from input filename. + tempFileName + ); + + const data = ffmpeg.FS('readFile', tempFileName); + + return new blobToFile( + new Blob([data.buffer], { type: 'audio/mpeg' }), + outputFileName, + 'audio/mpeg' + ); + } catch (err) { + // eslint-disable-next-line no-console -- We want to surface this error. + console.error(err); + + trackError('mp3_conversion', err.message); + + throw err; + } finally { + try { + ffmpeg.exit(); + // eslint-disable-next-line no-empty -- no-op + } catch (e) {} + + trackTiming(); + } + }, + [getFFmpegInstance] + ); + /** * Determines whether the given file can be transcoded. * @@ -496,6 +549,7 @@ function useFFmpeg() { stripAudioFromVideo, getFirstFrameOfVideo, convertGifToVideo, + convertToMp3, trimVideo, }), [ @@ -505,6 +559,7 @@ function useFFmpeg() { stripAudioFromVideo, getFirstFrameOfVideo, convertGifToVideo, + convertToMp3, trimVideo, ] ); diff --git a/packages/story-editor/src/app/uploader/test/useUploader.js b/packages/story-editor/src/app/uploader/test/useUploader.js index 5fd81313af27..3eef6cf559fc 100644 --- a/packages/story-editor/src/app/uploader/test/useUploader.js +++ b/packages/story-editor/src/app/uploader/test/useUploader.js @@ -88,7 +88,7 @@ describe('useUploader', () => { }, }); - await expect(() => validateFileForUpload({})).toThrow( + await expect(() => validateFileForUpload({ file: {} })).toThrow( 'Sorry, you are not allowed to upload files.' ); }); @@ -100,7 +100,9 @@ describe('useUploader', () => { maxUpload: 2000000, }); - await expect(() => validateFileForUpload({ size: 3000000 })).toThrow( + await expect(() => + validateFileForUpload({ file: { size: 3000000 } }) + ).toThrow( 'Your file is 3MB and the upload limit is 2MB. Please resize and try again!' ); }); @@ -111,23 +113,38 @@ describe('useUploader', () => { } = setup({}); await expect(() => - validateFileForUpload({ size: 20000, type: 'video/quicktime' }) + validateFileForUpload({ + file: { size: 20000, type: 'video/quicktime' }, + }) ).toThrow( 'Please choose only png, jpg, jpeg, gif, webp, mp4, or webm to upload.' ); }); + it('throws an error if file type is not supported and in list of mime types', async () => { + const { + actions: { validateFileForUpload }, + } = setup({}); + + await expect(() => + validateFileForUpload({ + file: { size: 20000, type: 'video/quicktime' }, + overrideAllowedMimeTypes: ['video/mp4'], + }) + ).toThrow('Please choose only mp4 to upload.'); + }); + it('throws an error if file too large to transcode', async () => { const { actions: { validateFileForUpload }, } = setup({}); await expect(() => - validateFileForUpload( - { size: 1024 * 1024 * 1024 * 2, type: 'video/mp4' }, - true, - true - ) + validateFileForUpload({ + file: { size: 1024 * 1024 * 1024 * 2, type: 'video/mp4' }, + canTranscodeFile: true, + isFileTooLarge: true, + }) ).toThrow( 'Your file is too large (2048 MB) and cannot be processed. Please try again with a file that is smaller than 2048 MB.' ); @@ -141,14 +158,11 @@ describe('useUploader', () => { }); await expect(() => - validateFileForUpload( - { - size: 1024 * 1024 * 1024 * 3, - type: 'video/quicktime', - }, - true, - true - ) + validateFileForUpload({ + file: { size: 1024 * 1024 * 1024 * 3, type: 'video/quicktime' }, + canTranscodeFile: true, + isFileTooLarge: true, + }) ).toThrow( 'Your file is too large (3072 MB) and cannot be processed. Please try again with a file that is smaller than 2048 MB.' ); @@ -160,11 +174,11 @@ describe('useUploader', () => { } = setup({}); await expect(() => - validateFileForUpload( - { size: 1024 * 1024 * 150, type: 'video/mp4' }, - true, - false - ) + validateFileForUpload({ + file: { size: 1024 * 1024 * 150, type: 'video/mp4' }, + canTranscodeFile: true, + isFileTooLarge: false, + }) ).not.toThrow(); }); @@ -174,11 +188,11 @@ describe('useUploader', () => { } = setup({}); await expect(() => - validateFileForUpload( - { size: 1024 * 1024 * 1024 * 2, type: 'video/mp4' }, - false, - true - ) + validateFileForUpload({ + file: { size: 1024 * 1024 * 1024 * 2, type: 'video/mp4' }, + canTranscodeFile: false, + isFileTooLarge: true, + }) ).toThrow( 'Your file is 2048MB and the upload limit is 100MB. Please resize and try again!' ); @@ -190,11 +204,11 @@ describe('useUploader', () => { } = setup({}); await expect(() => - validateFileForUpload( - { size: 1024 * 1024 * 50, type: 'video/mp4' }, - false, - false - ) + validateFileForUpload({ + file: { size: 1024 * 1024 * 50, type: 'video/mp4' }, + canTranscodeFile: false, + isFileTooLarge: false, + }) ).not.toThrow(); }); @@ -206,7 +220,9 @@ describe('useUploader', () => { }); await expect(() => - validateFileForUpload({ size: 20000, type: 'video/quicktime' }) + validateFileForUpload({ + file: { size: 20000, type: 'video/quicktime' }, + }) ).toThrow('Please choose only mp4 to upload.'); }); @@ -216,7 +232,9 @@ describe('useUploader', () => { } = setup({ allowedMimeTypes: { image: [], video: [], vector: [] } }); await expect(() => - validateFileForUpload({ size: 20000, type: 'video/quicktime' }) + validateFileForUpload({ + file: { size: 20000, type: 'video/quicktime' }, + }) ).toThrow('No file types are currently supported.'); }); }); diff --git a/packages/story-editor/src/app/uploader/useUploader.js b/packages/story-editor/src/app/uploader/useUploader.js index 74dfc0905cb6..69d7b6d1fd90 100644 --- a/packages/story-editor/src/app/uploader/useUploader.js +++ b/packages/story-editor/src/app/uploader/useUploader.js @@ -56,18 +56,6 @@ function useUploader() { ], [allowedImageMimeTypes, allowedVectorMimeTypes, allowedVideoMimeTypes] ); - const allowedFileTypes = useMemo( - () => - allowedMimeTypes.map((type) => getExtensionsFromMimeType(type)).flat(), - [allowedMimeTypes] - ); - - const isValidType = useCallback( - ({ type }) => { - return allowedMimeTypes.includes(type); - }, - [allowedMimeTypes] - ); const isFileSizeWithinLimits = useCallback( ({ size }) => { @@ -80,12 +68,19 @@ function useUploader() { * Validates a file for upload. * * @throws Throws an error if file doesn't meet requirements. - * @param {Object} file File object. - * @param {boolean} canTranscodeFile Whether file can be transcoded by consumer. - * @param {boolean} isFileTooLarge Whether file is too large for consumer. + * @param {Object} args + * @param {Object} args.file File object. + * @param {boolean} args.canTranscodeFile Whether file can be transcoded by consumer. + * @param {boolean} args.isFileTooLarge Whether file is too large for consumer. + * @param {Array} args.overrideAllowedMimeTypes Array of override allowed mime types. */ const validateFileForUpload = useCallback( - (file, canTranscodeFile, isFileTooLarge) => { + ({ + file, + canTranscodeFile, + isFileTooLarge, + overrideAllowedMimeTypes = allowedMimeTypes, + }) => { // Bail early if user doesn't have upload capabilities. if (!hasUploadMediaAction) { const message = __( @@ -110,6 +105,8 @@ function useUploader() { throw createError('SizeError', file.name, message); } + const isValidType = ({ type }) => + overrideAllowedMimeTypes.includes(type); // TODO: Move this check to useUploadMedia? if (!isValidType(file)) { let message = __( @@ -117,6 +114,10 @@ function useUploader() { 'web-stories' ); + const allowedFileTypes = overrideAllowedMimeTypes + .map((type) => getExtensionsFromMimeType(type)) + .flat(); + if (allowedFileTypes.length) { /* translators: %s is a list of allowed file extensions. */ message = sprintf( @@ -142,13 +143,7 @@ function useUploader() { throw createError('SizeError', file.name, message); } }, - [ - hasUploadMediaAction, - isFileSizeWithinLimits, - maxUpload, - allowedFileTypes, - isValidType, - ] + [allowedMimeTypes, hasUploadMediaAction, isFileSizeWithinLimits, maxUpload] ); /** @@ -156,11 +151,16 @@ function useUploader() { * * @param {Object} file File object. * @param {Object} additionalData Additional Data object. + * @param {Array} overrideAllowedMimeTypes Array of override allowed mime types. */ const uploadFile = useCallback( - (file, additionalData = {}) => { + ( + file, + additionalData = {}, + overrideAllowedMimeTypes = allowedMimeTypes + ) => { // This will throw if the file cannot be uploaded. - validateFileForUpload(file); + validateFileForUpload({ file, overrideAllowedMimeTypes }); const _additionalData = { storyId, @@ -171,7 +171,7 @@ function useUploader() { return uploadMedia(file, _additionalData); }, - [validateFileForUpload, uploadMedia, storyId] + [allowedMimeTypes, validateFileForUpload, storyId, uploadMedia] ); return useMemo(() => { diff --git a/packages/story-editor/src/components/canvas/mediaRecordingLayer.js b/packages/story-editor/src/components/canvas/mediaRecordingLayer.js index 5abd5c2df9c6..b2635db6cd7a 100644 --- a/packages/story-editor/src/components/canvas/mediaRecordingLayer.js +++ b/packages/story-editor/src/components/canvas/mediaRecordingLayer.js @@ -58,6 +58,7 @@ import { PHOTO_FILE_TYPE, VideoWrapper, Video, + Audio, Photo, } from '../mediaRecording'; import { PageTitleArea } from './layout'; @@ -76,6 +77,7 @@ function MediaRecordingLayer() { getMediaStream, setFile, needsPermissions, + hasVideo, hasAudio, isGif, toggleIsGif, @@ -89,6 +91,7 @@ function MediaRecordingLayer() { liveStream: state.liveStream, mediaBlob: state.mediaBlob, mediaBlobUrl: state.mediaBlobUrl, + hasVideo: state.hasVideo, hasAudio: state.hasAudio, isGif: state.isGif, needsPermissions: @@ -160,7 +163,7 @@ function MediaRecordingLayer() { resetStream(); getMediaStream(); // eslint-disable-next-line react-hooks/exhaustive-deps -- Only want to act on actual input changes. - }, [audioInput, videoInput]); + }, [audioInput, videoInput, hasVideo]); useEffect(() => { if (isReady) { @@ -202,26 +205,40 @@ function MediaRecordingLayer() { {!isImageCapture && ( <> - {mediaBlobUrl && ( + {mediaBlobUrl && hasVideo && ( )} - {mediaBlobUrl && ( - <> - {/* eslint-disable-next-line jsx-a11y/media-has-caption -- We don't have tracks for this. */} - )} diff --git a/packages/story-editor/src/components/library/panes/media/local/mediaRecording/mediaRecording.js b/packages/story-editor/src/components/library/panes/media/local/mediaRecording/mediaRecording.js index 506269034b6e..c02c9b98add6 100644 --- a/packages/story-editor/src/components/library/panes/media/local/mediaRecording/mediaRecording.js +++ b/packages/story-editor/src/components/library/panes/media/local/mediaRecording/mediaRecording.js @@ -78,7 +78,7 @@ function MediaRecording() { return null; } - const label = __('Record Video', 'web-stories'); + const label = __('Record Video/Audio', 'web-stories'); return (