diff --git a/packages/media/src/getMsFromHMS.js b/packages/media/src/getMsFromHMS.js new file mode 100644 index 000000000000..eba331254da7 --- /dev/null +++ b/packages/media/src/getMsFromHMS.js @@ -0,0 +1,36 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Converts time in H:M:S format to milliseconds. + * + * @param {string} time Time in HH:MM:SS or H:M:S format. + * @return {number} Milliseconds. + */ +function getMsFromHMS(time) { + if (!time) { + return 0; + } + const parts = time.split(':'); + if (parts.length !== 3) { + return 0; + } + const seconds = + parseFloat(parts[2]) + parseInt(parts[1]) * 60 + parseInt(parts[0]) * 3600; + return Math.round(1000 * seconds); +} + +export default getMsFromHMS; diff --git a/packages/media/src/index.js b/packages/media/src/index.js index 16e43a6d9059..456721183de7 100644 --- a/packages/media/src/index.js +++ b/packages/media/src/index.js @@ -30,6 +30,7 @@ export { default as getFileNameWithExt } from './getFileNameWithExt'; export { default as getExtensionFromMimeType } from './getExtensionFromMimeType'; export { default as getFirstFrameOfVideo } from './getFirstFrameOfVideo'; export { default as getImageDimensions } from './getImageDimensions'; +export { default as getMsFromHMS } from './getMsFromHMS'; export { default as getVideoDimensions } from './getVideoDimensions'; export { default as getVideoLength } from './getVideoLength'; export { default as getVideoLengthDisplay } from './getVideoLengthDisplay'; diff --git a/packages/media/src/test/getMsFromHMS.js b/packages/media/src/test/getMsFromHMS.js new file mode 100644 index 000000000000..4a6d8fc98870 --- /dev/null +++ b/packages/media/src/test/getMsFromHMS.js @@ -0,0 +1,34 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal dependencies + */ +import getMsFromHMS from '../getMsFromHMS'; + +describe('getMsFromHMS', () => { + it('should correctly format values resulting in 0', () => { + expect(getMsFromHMS('00:00:00')).toBe(0); + expect(getMsFromHMS(null)).toBe(0); + expect(getMsFromHMS('foo')).toBe(0); + }); + + it('should return correct results', () => { + expect(getMsFromHMS('00:00:01')).toBe(1000); + expect(getMsFromHMS('00:01:00')).toBe(60000); + expect(getMsFromHMS('00:00:10.5')).toBe(10500); + }); +}); diff --git a/packages/story-editor/src/app/api/apiProvider.js b/packages/story-editor/src/app/api/apiProvider.js index 56930363f31b..0df22e8d421e 100644 --- a/packages/story-editor/src/app/api/apiProvider.js +++ b/packages/story-editor/src/app/api/apiProvider.js @@ -53,6 +53,7 @@ function APIProvider({ children }) { saveStoryById, autoSaveById, getMedia, + getMediaById, uploadMedia, updateMedia, deleteMedia, @@ -119,6 +120,11 @@ function APIProvider({ children }) { [media, getMedia] ); + actions.getMediaById = useCallback( + (mediaId) => getMediaById(mediaId, media), + [getMediaById, media] + ); + actions.uploadMedia = useCallback( (file, additionalData) => uploadMedia(file, additionalData, media), [media, uploadMedia] diff --git a/packages/story-editor/src/app/media/utils/useProcessMedia.js b/packages/story-editor/src/app/media/utils/useProcessMedia.js index c6144de69885..b733c541b221 100644 --- a/packages/story-editor/src/app/media/utils/useProcessMedia.js +++ b/packages/story-editor/src/app/media/utils/useProcessMedia.js @@ -59,17 +59,11 @@ function useProcessMedia({ ); const updateExistingElements = useCallback( - ({ oldResource }) => { - const { id } = oldResource; + ({ oldResource: resource }) => { + const { id } = resource; updateElementsByResourceId({ id, - properties: () => { - return { - resource: { - ...oldResource, - }, - }; - }, + properties: () => ({ resource }), }); }, [updateElementsByResourceId] @@ -186,11 +180,13 @@ function useProcessMedia({ * @param {string} end Time stamp of end time of new video. Example '00:02:00'. */ const trimExistingVideo = useCallback( - ({ resource: oldResource, start, end }) => { - const { src: url, mimeType, poster } = oldResource; + ({ resource: oldResource, canvasResourceId, start, end }) => { + const { id, src: url, mimeType, poster } = oldResource; + + const canvasResource = { ...oldResource, id: canvasResourceId }; const trimData = { - original: oldResource.id, + original: id, start, end, }; @@ -198,7 +194,7 @@ function useProcessMedia({ const onUploadStart = () => { updateExistingElements({ oldResource: { - ...oldResource, + ...canvasResource, trimData, isTrimming: true, }, @@ -207,7 +203,10 @@ function useProcessMedia({ const onUploadError = () => { updateExistingElements({ - oldResource: { ...oldResource, isTrimming: false }, + oldResource: { + ...canvasResource, + isTrimming: false, + }, }); }; @@ -224,9 +223,9 @@ function useProcessMedia({ }; const onUploadProgress = ({ resource }) => { - const oldResourceWithId = { ...resource, id: oldResource.id }; + const newResourceWithCanvasId = { ...resource, id: canvasResourceId }; updateExistingElements({ - oldResource: oldResourceWithId, + oldResource: newResourceWithCanvasId, }); }; diff --git a/packages/story-editor/src/components/videoTrim/provider.js b/packages/story-editor/src/components/videoTrim/provider.js index 2c43695e780d..c26a8dfe03fb 100644 --- a/packages/story-editor/src/components/videoTrim/provider.js +++ b/packages/story-editor/src/components/videoTrim/provider.js @@ -25,19 +25,17 @@ import { trackEvent } from '@web-stories-wp/tracking'; /** * Internal dependencies */ -import { useLocalMedia, useStory } from '../../app'; +import { useLocalMedia } from '../../app'; import VideoTrimContext from './videoTrimContext'; import useVideoTrimMode from './useVideoTrimMode'; import useVideoNode from './useVideoNode'; function VideoTrimProvider({ children }) { - const { selectedElements } = useStory(({ state: { selectedElements } }) => ({ - selectedElements, - })); const { trimExistingVideo } = useLocalMedia((state) => ({ trimExistingVideo: state.actions.trimExistingVideo, })); - const { isTrimMode, hasTrimMode, toggleTrimMode } = useVideoTrimMode(); + const { isTrimMode, hasTrimMode, toggleTrimMode, videoData } = + useVideoTrimMode(); const { hasChanged, currentTime, @@ -49,10 +47,10 @@ function VideoTrimProvider({ children }) { setVideoNode, resetOffsets, setIsDraggingHandles, - } = useVideoNode(); + } = useVideoNode(videoData); const performTrim = useCallback(() => { - const { resource } = selectedElements[0]; + const { resource, element } = videoData; if (!resource) { return; } @@ -63,6 +61,9 @@ function VideoTrimProvider({ children }) { length: lengthInSeconds, lengthFormatted: getVideoLengthDisplay(lengthInSeconds), }, + // This is the ID of the resource, that's currently on canvas and needs to be cloned. + // It's only different from the above resource, if the canvas resource is a trim of the other. + canvasResourceId: element.resource.id, start: formatMsToHMS(startOffset), end: formatMsToHMS(endOffset), }); @@ -73,16 +74,11 @@ function VideoTrimProvider({ children }) { end_offset: endOffset, }); toggleTrimMode(); - }, [ - endOffset, - startOffset, - trimExistingVideo, - selectedElements, - toggleTrimMode, - ]); + }, [endOffset, startOffset, trimExistingVideo, toggleTrimMode, videoData]); const value = { state: { + videoData, hasChanged, isTrimMode, hasTrimMode, diff --git a/packages/story-editor/src/components/videoTrim/useVideoNode.js b/packages/story-editor/src/components/videoTrim/useVideoNode.js index c27979aebf6c..f9e5dabdb9a9 100644 --- a/packages/story-editor/src/components/videoTrim/useVideoNode.js +++ b/packages/story-editor/src/components/videoTrim/useVideoNode.js @@ -30,7 +30,7 @@ import { */ import { MEDIA_VIDEO_MINIMUM_DURATION } from '../../constants'; -function useVideoNode() { +function useVideoNode(videoData) { const [currentTime, setCurrentTime] = useState(null); const [startOffset, rawSetStartOffset] = useState(null); const [originalStartOffset, setOriginalStartOffset] = useState(null); @@ -69,26 +69,31 @@ function useVideoNode() { }, [paused, isDraggingHandles]); useEffect(() => { - if (!videoNode) { + if (!videoNode || !videoData) { return undefined; } + function restart(at) { + videoNode.currentTime = at / 1000; + videoNode.play(); + } + function onLoadedMetadata(evt) { const duration = Math.floor(evt.target.duration * 1000); - rawSetStartOffset(0); - setOriginalStartOffset(0); - setCurrentTime(0); - rawSetEndOffset(duration); - setOriginalEndOffset(duration); + rawSetStartOffset(videoData.start); + setOriginalStartOffset(videoData.start); + setCurrentTime(videoData.start); + rawSetEndOffset(videoData.end ?? duration); + setOriginalEndOffset(videoData.end ?? duration); setMaxOffset(duration); + restart(videoData.start); } function onTimeUpdate(evt) { const currentOffset = Math.floor(evt.target.currentTime * 1000); setCurrentTime(Math.min(currentOffset, endOffset)); // If we've reached the end of the video, start again unless the user has paused the video. if (currentOffset > endOffset && !isPausedTracker.current) { - videoNode.currentTime = startOffset / 1000; - videoNode.play(); + restart(startOffset); } } videoNode.addEventListener('timeupdate', onTimeUpdate); @@ -98,7 +103,7 @@ function useVideoNode() { videoNode.removeEventListener('timeupdate', onTimeUpdate); videoNode.removeEventListener('loadedmetadata', onLoadedMetadata); }; - }, [startOffset, endOffset, videoNode]); + }, [startOffset, endOffset, videoData, videoNode]); const setStartOffset = useCallback( (offset) => { diff --git a/packages/story-editor/src/components/videoTrim/useVideoTrimMode.js b/packages/story-editor/src/components/videoTrim/useVideoTrimMode.js index 543f8589d608..4184dfcbf7cd 100644 --- a/packages/story-editor/src/components/videoTrim/useVideoTrimMode.js +++ b/packages/story-editor/src/components/videoTrim/useVideoTrimMode.js @@ -18,13 +18,14 @@ * External dependencies */ import { useFeature } from 'flagged'; -import { useCallback, useMemo } from '@web-stories-wp/react'; +import { useCallback, useMemo, useState } from '@web-stories-wp/react'; import { trackEvent } from '@web-stories-wp/tracking'; +import { getMsFromHMS } from '@web-stories-wp/media'; /** * Internal dependencies */ -import { useCanvas, useStory } from '../../app'; +import { useCanvas, useStory, useAPI } from '../../app'; import useFFmpeg from '../../app/media/utils/useFFmpeg'; function useVideoTrimMode() { @@ -44,6 +45,10 @@ function useVideoTrimMode() { const { selectedElement } = useStory(({ state: { selectedElements } }) => ({ selectedElement: selectedElements.length === 1 ? selectedElements[0] : null, })); + const { + actions: { getMediaById }, + } = useAPI(); + const [videoData, setVideoData] = useState(null); const toggleTrimMode = useCallback(() => { if (isEditing) { @@ -54,11 +59,49 @@ function useVideoTrimMode() { hasEditMenu: true, showOverflow: false, }); + + const { resource } = selectedElement; + const { trimData } = resource; + + const defaultVideoData = { + element: selectedElement, + resource, + start: 0, + end: null, + }; + + if (trimData?.original) { + // First clear any existing data + setVideoData(null); + // Load correct video resource + getMediaById(trimData.original) + .then( + // If exists, use as resource with offsets + (originalResource) => ({ + element: selectedElement, + resource: originalResource, + start: getMsFromHMS(trimData.start), + end: getMsFromHMS(trimData.end), + }), + // If load fails, pretend there's no original + () => defaultVideoData + ) + // Regardless, set resulting data as video data + .then((data) => setVideoData(data)); + } else { + setVideoData(defaultVideoData); + } } trackEvent('video_trim_mode_toggled', { status: isEditing ? 'closed' : 'open', }); - }, [isEditing, clearEditing, setEditingElementWithState, selectedElement]); + }, [ + isEditing, + clearEditing, + setEditingElementWithState, + selectedElement, + getMediaById, + ]); const { isTranscodingEnabled } = useFFmpeg(); @@ -74,6 +117,7 @@ function useVideoTrimMode() { isTrimMode: isEditing && isTrimMode, hasTrimMode, toggleTrimMode, + videoData, }; } diff --git a/packages/story-editor/src/components/videoTrim/videoTrimmer.js b/packages/story-editor/src/components/videoTrim/videoTrimmer.js index cf8149f7a13a..772e6e9c7dda 100644 --- a/packages/story-editor/src/components/videoTrim/videoTrimmer.js +++ b/packages/story-editor/src/components/videoTrim/videoTrimmer.js @@ -59,9 +59,17 @@ function VideoTrimmer() { performTrim, setIsDraggingHandles, toggleTrimMode, + videoData, } = useVideoTrim( ({ - state: { currentTime, startOffset, endOffset, maxOffset, hasChanged }, + state: { + currentTime, + startOffset, + endOffset, + maxOffset, + hasChanged, + videoData, + }, actions: { setStartOffset, setEndOffset, @@ -80,6 +88,7 @@ function VideoTrimmer() { performTrim, setIsDraggingHandles, toggleTrimMode, + videoData, }) ); const { workspaceWidth, pageWidth } = useLayout( @@ -106,7 +115,7 @@ function VideoTrimmer() { } }, []); - if (!pageWidth || !maxOffset) { + if (!pageWidth || !maxOffset || !videoData) { // We still need a reffed element, or the focus trap will break, // so just return an empty element return
; diff --git a/packages/story-editor/src/elements/video/trim.js b/packages/story-editor/src/elements/video/trim.js index 6afc050879c4..6909ce6383f4 100644 --- a/packages/story-editor/src/elements/video/trim.js +++ b/packages/story-editor/src/elements/video/trim.js @@ -27,6 +27,7 @@ import { getMediaSizePositionProps } from '@web-stories-wp/media'; import StoryPropTypes from '../../types'; import MediaDisplay from '../media/display'; import useVideoTrim from '../../components/videoTrim/useVideoTrim'; +import CircularProgress from '../../components/circularProgress'; import PlayPauseButton from './playPauseButton'; import { getBackgroundStyle, videoWithScale } from './util'; @@ -43,10 +44,18 @@ const Wrapper = styled.div` position: absolute; `; +const Spinner = styled.div` + position: absolute; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +`; + function VideoTrim({ box, element }) { const { width, height } = box; - const { poster, resource, tracks, isBackground, scale, focalX, focalY } = - element; + const { poster, tracks, isBackground, scale, focalX, focalY } = element; const wrapperRef = useRef(); const videoRef = useRef(); let style = {}; @@ -58,17 +67,13 @@ function VideoTrim({ box, element }) { }; } - const videoProps = getMediaSizePositionProps( - resource, - width, - height, - scale, - focalX, - focalY + const { resource, setVideoNode } = useVideoTrim( + ({ state: { videoData }, actions: { setVideoNode } }) => ({ + setVideoNode, + resource: videoData?.resource, + }) ); - videoProps.crossOrigin = 'anonymous'; - const boxAtOrigin = useMemo( () => ({ ...box, @@ -77,10 +82,6 @@ function VideoTrim({ box, element }) { }), [box] ); - - const { setVideoNode } = useVideoTrim(({ actions: { setVideoNode } }) => ({ - setVideoNode, - })); const setRef = useCallback( (node) => { videoRef.current = node; @@ -89,6 +90,27 @@ function VideoTrim({ box, element }) { [setVideoNode] ); + if (!resource) { + return ( +