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. */}
-
- {!isGif && }
- >
- )}
- {!mediaBlob && !mediaBlobUrl && liveStream && (
-
- )}
+ {mediaBlobUrl &&
+ (hasVideo ? (
+ <>
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption -- We don't have tracks for this. */}
+
+ {!isGif && }
+ >
+ ) : (
+ <>
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption -- No captions wanted/needed here. */}
+
+ >
+ ))}
+ {!mediaBlob &&
+ !mediaBlobUrl &&
+ liveStream &&
+ (hasVideo ? (
+
+ ) : (
+ <>
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption -- No an actual
>
)}
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 (