diff --git a/client/src/App.tsx b/client/src/App.tsx index 2f488aa..2554d10 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,25 +1,13 @@ import React from 'react'; -import { useSelector } from 'react-redux'; -import TimeLine from '@/components/organisms/TimeLine'; +import EditPage from '@/pages/Edit'; import GlobalStyle from '@/theme/globalStyle'; -import Header from '@/components/organisms/Header'; -import Tools from '@/components/organisms/Tools'; -import VideoContainer from '@/components/organisms/VideoContainer'; -import Loading from '@/components/atoms/Loading'; -import { getMessage } from '@/store/selectors'; const App: React.FC = () => { - const message = useSelector(getMessage); - return ( <> - {message && } -
- - - + ); }; diff --git a/client/src/components/atoms/FileInput/FileInput.tsx b/client/src/components/atoms/FileInput/FileInput.tsx index 64cae41..ea07307 100644 --- a/client/src/components/atoms/FileInput/FileInput.tsx +++ b/client/src/components/atoms/FileInput/FileInput.tsx @@ -1,24 +1,52 @@ import React from 'react'; -import styled from 'styled-components'; +import styled, { keyframes } from 'styled-components'; import color from '@/theme/colors'; +const slide = keyframes` + from { + transform: translate(0, -50px) rotate(90deg); + opacity: 0; + } + to { + transform: translate(0, 0) rotate(0deg); + opacity: 1; + } +`; + const StyledDiv = styled.div` position: absolute; display: flex; + flex-direction: column; justify-content: center; border-radius: 5px; - padding: 5px 16px; top: 2rem; right: 0; - background-color: ${color.GRAY}; - box-shadow: 1px 1px 2px 1px ${color.WHITE}; + border: 1px solid ${color.BORDER}; + background-color: ${color.BLACK}; + box-shadow: 1px 1px 2px 1px ${color.BORDER}; + animation: ${slide} 0.4s -0.1s ease-out; + transform-origin: center center; + width: 5rem; `; -const StyledLabel = styled.label` +const FromLocal = styled.label` color: ${color.WHITE}; font-size: 12px; + text-align: center; cursor: pointer; + padding: 5px 16px; + transition: 0.7s; + border-radius: 5px 5px 0 0; + + &:hover { + background-color: ${color.GRAY}; + } +`; + +const FromServer = styled(FromLocal)` + border-top: 1px solid ${color.BORDER}; + border-radius: 0 0 5px 5px; `; const StyledInput = styled.input` @@ -33,13 +61,14 @@ const FileInput = React.forwardRef( ({ handleChange }, forwardedRef) => { return ( - 내 컴퓨터 + 로컬 + 서버 ); } diff --git a/client/src/components/atoms/Slider/Slider.tsx b/client/src/components/atoms/Slider/Slider.tsx index b2c490d..2d834ef 100644 --- a/client/src/components/atoms/Slider/Slider.tsx +++ b/client/src/components/atoms/Slider/Slider.tsx @@ -40,10 +40,10 @@ const Slider: React.FC = ({ thumbnailRef }) => { const time = useSelector(getCurrentTime); useEffect(() => { - const currentTime = video.getCurrentTime(); + const currentTime = video.get('currentTime'); const width = thumbnailRef.current.clientWidth; - const totalDuration = video.getDuration(); + const totalDuration = video.get('duration'); const movedLocation = totalDuration ? (currentTime / totalDuration) * width diff --git a/client/src/components/molecules/CropLayer/CropLayer.tsx b/client/src/components/molecules/CropLayer/CropLayer.tsx index b1930b1..26d3ebe 100644 --- a/client/src/components/molecules/CropLayer/CropLayer.tsx +++ b/client/src/components/molecules/CropLayer/CropLayer.tsx @@ -1,13 +1,15 @@ -import React, { useState } from 'react'; -import { useSelector } from 'react-redux'; +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector, shallowEqual } from 'react-redux'; import { Range } from 'react-range'; import styled from 'styled-components'; + import color from '@/theme/colors'; import convertReactStyleToCSS from '@/utils/convert'; +import video from '@/video'; +import { crop } from '@/store/currentVideo/actions'; +import { getStartEnd, getCropState } from '@/store/selectors'; -const STEP = 0.1; const MIN = 0; -const MAX = 100; interface OverlayProps { width: number; @@ -59,7 +61,25 @@ const Thumb = styled.div` } `; -const CropLayer = ({ positions, setPositions, duration }) => { +const CropLayer = () => { + const MAX = video.get('duration'); + const STEP = (MAX - MIN) / 1024; + + const { start, end } = useSelector(getStartEnd, shallowEqual); + const { isCrop, isCropConfirm } = useSelector(getCropState, shallowEqual); + + const [positions, setPositions] = useState([0, 0]); + + const dispatch = useDispatch(); + + useEffect(() => { + if (isCrop) setPositions([start, end]); + }, [isCrop]); + + useEffect(() => { + if (isCropConfirm) dispatch(crop(positions[0], positions[1])); + }, [isCropConfirm]); + return ( { onMouseDown={props.onMouseDown} onTouchStart={props.onTouchStart} > - - + +
{ - const currentTime = () => Math.round(video.getCurrentTime()); + const currentTime = () => Math.round(video.get('currentTime')); const [time, setTime] = useState(currentTime()); const visible = useSelector(getVisible); diff --git a/client/src/components/molecules/Thumbnail/Thumbnail.tsx b/client/src/components/molecules/Thumbnail/Thumbnail.tsx index ca855bf..902e571 100644 --- a/client/src/components/molecules/Thumbnail/Thumbnail.tsx +++ b/client/src/components/molecules/Thumbnail/Thumbnail.tsx @@ -6,7 +6,7 @@ import { moveTo } from '@/store/currentVideo/actions'; import Slider from '@/components/atoms/Slider'; import HoverSlider from '@/components/atoms/HoverSlider'; import video from '@/video'; -import { getThumbnails } from '@/store/selectors'; +import { getThumbnails, getIsCrop } from '@/store/selectors'; import CropLayer from '@/components/molecules/CropLayer'; const StyledDiv = styled.div` @@ -24,8 +24,9 @@ const StyledImg = styled.img` const Thumbnail: React.FC = () => { const thumbnails = useSelector(getThumbnails); + const isCrop = useSelector(getIsCrop); + const [time, setTime] = useState(0); - const [position, setPosition] = useState([0, 0]); const dispatch = useDispatch(); @@ -47,7 +48,7 @@ const Thumbnail: React.FC = () => { const distance = mouseLocation - offset; const width = thumbnailRef.current.clientWidth; - const duration = video.getDuration(); + const duration = video.get('duration'); const hoverTime = (distance / width) * duration; @@ -71,10 +72,10 @@ const Thumbnail: React.FC = () => { onMouseLeave={handleMouseLeave} onMouseEnter={handleMouseEnter} > - + {isCrop && } - {thumbnails.map(image => { + {(isCrop ? video.getThumbnails() : thumbnails).map(image => { return ; })} diff --git a/client/src/components/molecules/TimeZone/TimeZone.tsx b/client/src/components/molecules/TimeZone/TimeZone.tsx index 83e7be1..ea1feed 100644 --- a/client/src/components/molecules/TimeZone/TimeZone.tsx +++ b/client/src/components/molecules/TimeZone/TimeZone.tsx @@ -1,8 +1,13 @@ import React from 'react'; -import { useSelector } from 'react-redux'; +import { shallowEqual, useSelector } from 'react-redux'; import styled from 'styled-components'; -import { getDuration } from '@/store/selectors'; +import { + getDuration, + getStartEnd, + getIsCrop, + getIsCropAndDuration, +} from '@/store/selectors'; import TimeText from '@/components/atoms/TimeText'; import color from '@/theme/colors'; @@ -23,7 +28,8 @@ const InnerDiv = styled.div` const PART_COUNT = 6; -const getTimes = (duration: number): number[] => { +const getTimes = ({ start, end }): number[] => { + const duration: number = end - start; if (!duration) return []; const times: number[] = []; @@ -40,8 +46,11 @@ const getTimes = (duration: number): number[] => { }; const TimeZone: React.FC = () => { - const duration: number = Math.round(useSelector(getDuration)); - const times: number[] = getTimes(duration); + const { isCrop, duration } = useSelector(getIsCropAndDuration, shallowEqual); + const { start, end } = useSelector(getStartEnd); + + const parameter = isCrop ? { start: 0, end: duration } : { start, end }; + const times: number[] = getTimes(parameter); return ( diff --git a/client/src/components/molecules/UploadArea/UploadArea.tsx b/client/src/components/molecules/UploadArea/UploadArea.tsx index ba46c91..1409a9a 100644 --- a/client/src/components/molecules/UploadArea/UploadArea.tsx +++ b/client/src/components/molecules/UploadArea/UploadArea.tsx @@ -8,6 +8,8 @@ import { setVideo } from '@/store/originalVideo/actions'; import { getName } from '@/store/selectors'; import { reset } from '@/store/actionTypes'; +import webglController from '@/webgl/webglController'; + const StyledDiv = styled.div` display: flex; align-items: center; @@ -29,11 +31,8 @@ const UploadArea: React.FC = () => { const handleChange = () => { const localFile: File = ref.current?.files[0]; - - if (localFile) { - const objectURL = URL.createObjectURL(localFile); - dispatch(setVideo(localFile, objectURL)); - } else dispatch(reset()); + const objectURL = URL.createObjectURL(localFile); + dispatch(setVideo(localFile, objectURL)); setVisible(false); }; diff --git a/client/src/components/organisms/Tools/Tools.tsx b/client/src/components/organisms/Tools/Tools.tsx index 7cf6b8e..8304bdc 100644 --- a/client/src/components/organisms/Tools/Tools.tsx +++ b/client/src/components/organisms/Tools/Tools.tsx @@ -1,145 +1,104 @@ -import React, { useState } from 'react'; -import styled from 'styled-components'; +import React, { useState, useReducer, useCallback, useMemo } from 'react'; +import styled, { keyframes } from 'styled-components'; import { shallowEqual, useDispatch, useSelector } from 'react-redux'; -import { - BsFillSkipStartFill, - BsFillSkipEndFill, - BsFillPlayFill, - BsFillPauseFill, -} from 'react-icons/bs'; -import { RiScissorsLine } from 'react-icons/ri'; + import webglController from '@/webgl/webglController'; import ButtonGroup from '@/components/molecules/ButtonGroup'; import UploadArea from '@/components/molecules/UploadArea'; -import size from '@/theme/sizes'; import video from '@/video'; import { play as playAction, pause, moveTo, } from '@/store/currentVideo/actions'; -import { getStartEnd } from '@/store/selectors'; +import { getStartEnd, getPlaying } from '@/store/selectors'; +import { cropStart, cropCancel, cropConfirm } from '@/store/actionTypes'; +import reducer, { initialData, ButtonTypes } from './reducer'; +import { + getEditToolData, + getSubEditToolsData, + getVideoToolsData, +} from './buttonData'; + +const UP = 'up'; +const DOWN = 'down'; const StyledDiv = styled.div` + position: relative; display: flex; justify-content: space-between; - align-items: center; + align-items: flex-end; padding: 1rem; `; -interface button { - onClick: () => void; - message: string; - type: 'default' | 'transparent'; - children: React.ReactChild; -} +const slide = keyframes` + from { + transform: translate(0, 50px); + opacity: 0; + } + to { + transform: translate(0, 0); + opacity: 1; + } +`; + +const StyledEditToolDiv = styled.div` + display: flex; + flex-direction: column; + align-items: center; +`; + +const EditTool = styled(ButtonGroup)` + width: 20rem; +`; + +const WrapperDiv = styled.div` + width: 25rem; + display: flex; + justify-content: center; + animation: ${slide} 0.5s -0.1s ease-out; +`; + +const SubEditTool = styled(ButtonGroup)` + width: 100%; +`; -const getVideoToolsData = ( - backwardVideo: () => void, - playPauseVideo: () => void, - forwardVideo: () => void, - play: boolean -): button[] => [ - { - onClick: backwardVideo, - message: '', - type: 'transparent', - children: , - }, - { - onClick: playPauseVideo, - message: '', - type: 'transparent', - children: play ? ( - - ) : ( - - ), - }, - { - onClick: forwardVideo, - message: '', - type: 'transparent', - children: , - }, -]; - -const getEditToolsData = ( - rotateLeft90Degree: () => void, - rotateRight90Degree: () => void, - reverseUpsideDown: () => void, - reverseSideToSide: () => void, - enlarge: () => void, - reduce: () => void, - crop: () => void -): button[] => [ - { - onClick: rotateLeft90Degree, - message: "Left 90'", - type: 'transparent', - children: null, - }, - { - onClick: rotateRight90Degree, - message: "Right 90'", - type: 'transparent', - children: null, - }, - { - onClick: reverseUpsideDown, - message: 'Up to Down', - type: 'transparent', - children: null, - }, - { - onClick: reverseSideToSide, - message: 'Side to Side', - type: 'transparent', - children: null, - }, - { onClick: enlarge, message: 'enlarge', type: 'transparent', children: null }, - { onClick: reduce, message: 'reduce', type: 'transparent', children: null }, - { - onClick: crop, - message: 'crop', - type: 'transparent', - children: , - }, -]; - -const EditTool = styled(ButtonGroup)``; const VideoTool = styled(ButtonGroup)``; -const Tools: React.FC = () => { - const [play, setPlay] = useState(true); // Fix 스토어로 등록 +interface props { + setEdit: Function; +} + +const Tools: React.FC = ({ setEdit }) => { + const play = useSelector(getPlaying); const dispatch = useDispatch(); + const [toolType, setToolType] = useState(null); + const [buttonData, dispatchButtonData] = useReducer(reducer, initialData); const { start, end } = useSelector(getStartEnd, shallowEqual); const backwardVideo = () => { - const dstTime = Math.max(video.getCurrentTime() - 10, start); + const dstTime = Math.max(video.get('currentTime') - 10, start); video.setCurrentTime(dstTime); dispatch(moveTo(dstTime)); }; const forwardVideo = () => { - const dstTime = Math.min(video.getCurrentTime() + 10, end); + const dstTime = Math.min(video.get('currentTime') + 10, end); video.setCurrentTime(dstTime); dispatch(moveTo(dstTime)); }; const playPauseVideo = () => { - if (play) { + if (!play) { video.play(); dispatch(playAction()); } else { video.pause(); dispatch(pause()); } - - setPlay(!play); }; document.onkeydown = (event: KeyboardEvent) => { @@ -160,14 +119,61 @@ const Tools: React.FC = () => { } }; - const rotateLeft90Degree = () => webglController.rotateLeft90Degree(); - const rotateRight90Degree = () => webglController.rotateRight90Degree(); - const reverseUpsideDown = () => webglController.reverseUpsideDown(); - const reverseSideToSide = () => webglController.reverseSideToSide(); - const enlarge = () => webglController.enlarge(); - const reduce = () => webglController.reduce(); - const crop = () => { - // crop + const closeSubtool = () => { + setEdit(DOWN); + setToolType(null); + dispatchButtonData({ type: null }); + }; + + const openSubtool = (type: ButtonTypes, payload: (() => void)[]) => { + if (type !== ButtonTypes.crop) dispatch(cropCancel()); + setEdit(UP); + setToolType(type); + dispatchButtonData({ type, payload }); + }; + + const handleCropManually = useCallback(() => {}, []); // TODO: + const handleCropConfirm = useCallback(() => { + dispatch(cropConfirm()); + closeSubtool(); + }, [dispatch]); + const handleCropCancel = useCallback(() => { + dispatch(cropCancel()); + closeSubtool(); + }, [dispatch]); + + const methods = useMemo( + () => ({ + rotateReverse: [ + webglController.rotateLeft90Degree, + webglController.rotateRight90Degree, + webglController.reverseUpsideDown, + webglController.reverseSideToSide, + ], + ratio: [webglController.enlarge, webglController.reduce], + crop: [handleCropManually, handleCropConfirm, handleCropCancel], + }), + [] + ); + + const handleRotateReverse = () => + toolType === ButtonTypes.videoEffect + ? closeSubtool() + : openSubtool(ButtonTypes.videoEffect, methods.rotateReverse); + + const handleRatio = () => + toolType === ButtonTypes.ratio + ? closeSubtool() + : openSubtool(ButtonTypes.ratio, methods.ratio); + + const handleCrop = () => { + if (toolType === ButtonTypes.crop) { + dispatch(cropCancel()); + closeSubtool(); + } else { + openSubtool(ButtonTypes.crop, methods.crop); + dispatch(cropStart()); + } }; return ( @@ -180,17 +186,20 @@ const Tools: React.FC = () => { play )} /> - + {toolType && ( + + + )} - /> + + ); diff --git a/client/src/components/organisms/Tools/buttonData.tsx b/client/src/components/organisms/Tools/buttonData.tsx new file mode 100644 index 0000000..9b7241d --- /dev/null +++ b/client/src/components/organisms/Tools/buttonData.tsx @@ -0,0 +1,85 @@ +import React from 'react'; + +import { + BsFillSkipStartFill, + BsFillSkipEndFill, + BsFillPlayFill, + BsFillPauseFill, + BsAspectRatio, +} from 'react-icons/bs'; +import { RiScissorsLine } from 'react-icons/ri'; +import { MdScreenRotation } from 'react-icons/md'; + +import size from '@/theme/sizes'; + +import { ButtonData } from './reducer'; + +interface button { + onClick: () => void; + message: string; + type: 'default' | 'transparent'; + children: React.ReactChild; +} + +export const getVideoToolsData = ( + backwardVideo: () => void, + playPauseVideo: () => void, + forwardVideo: () => void, + play: boolean +): button[] => [ + { + onClick: backwardVideo, + message: '', + type: 'transparent', + children: , + }, + { + onClick: playPauseVideo, + message: '', + type: 'transparent', + children: play ? ( + + ) : ( + + ), + }, + { + onClick: forwardVideo, + message: '', + type: 'transparent', + children: , + }, +]; + +export const getEditToolData = ( + rotateReverse: () => void, + ratio: () => void, + crop: () => void +): button[] => [ + { + onClick: rotateReverse, + message: '회전 / 반전', + type: 'transparent', + children: , + }, + { + onClick: ratio, + message: '비율', + type: 'transparent', + children: , + }, + { + onClick: crop, + message: '자르기', + type: 'transparent', + children: , + }, +]; + +export const getSubEditToolsData = (buttonData: ButtonData): button[] => + [...Array(buttonData.onClicks?.length)].map((_, idx) => ({ + onClick: buttonData.onClicks[idx], + message: buttonData.messages[idx], + type: buttonData.type, + children: buttonData.childrens[idx], + })); diff --git a/client/src/components/organisms/Tools/reducer.tsx b/client/src/components/organisms/Tools/reducer.tsx new file mode 100644 index 0000000..29062a4 --- /dev/null +++ b/client/src/components/organisms/Tools/reducer.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { BsTerminal, BsCheck, BsX } from 'react-icons/bs'; +import { + MdRotateLeft, + MdRotateRight, + MdZoomIn, + MdZoomOut, +} from 'react-icons/md'; +import { CgMergeHorizontal, CgMergeVertical } from 'react-icons/cg'; + +import size from '@/theme/sizes'; + +export enum ButtonTypes { + crop = 'crop', + videoEffect = 'videoEffect', + ratio = 'ratio', +} + +export interface ButtonData { + onClicks: (() => void)[]; + messages: string[]; + type: 'default' | 'transparent'; + childrens: React.ReactChild[]; +} + +interface ButtonDataAction { + type: ButtonTypes | null; + payload?: (() => void)[]; +} + +// crop +const cropMessages = ['직접입력', '확인', '취소']; +const cropChildrens = [ + , + , + , +]; + +// videoEffect +const rotateReverseMessages = ['왼쪽', '오른쪽', '상하 반전', '좌우 반전']; +const rotateReverseChildrens = [ + , + , + , + , +]; + +// ratio +const ratioMessages = ['확대', '축소']; +const ratioChildrens = [ + , + , +]; + +export const initialData: ButtonData = { + onClicks: [], + messages: [], + type: 'transparent', + childrens: [], +}; + +export default (state: ButtonData, action: ButtonDataAction): ButtonData => { + switch (action.type) { + case ButtonTypes.crop: + return { + onClicks: action.payload, + messages: cropMessages, + type: 'transparent', + childrens: cropChildrens, + }; + case ButtonTypes.videoEffect: + return { + onClicks: action.payload, + messages: rotateReverseMessages, + type: 'transparent', + childrens: rotateReverseChildrens, + }; + case ButtonTypes.ratio: + return { + onClicks: action.payload, + messages: ratioMessages, + type: 'transparent', + childrens: ratioChildrens, + }; + default: + return initialData; + } +}; diff --git a/client/src/components/organisms/VideoContainer/VideoContainer.tsx b/client/src/components/organisms/VideoContainer/VideoContainer.tsx index d5415fe..42628fb 100644 --- a/client/src/components/organisms/VideoContainer/VideoContainer.tsx +++ b/client/src/components/organisms/VideoContainer/VideoContainer.tsx @@ -1,5 +1,36 @@ import React from 'react'; -import styled from 'styled-components'; +import styled, { css, keyframes } from 'styled-components'; + +import color from '@/theme/colors'; + +const UP = 'up'; +const DOWN = 'down'; + +const slideUp = keyframes` + from { + transform: translate(0, 2rem); + } + to { + transform: translate(0, 0); + } +`; + +const slideDown = keyframes` + from { + transform: translate(0, -2rem); + } + to { + transform: translate(0, 0); + } +`; + +const videoUp = css` + animation: ${slideUp} 0.3s ease-out; +`; + +const videoDown = css` + animation: ${slideDown} 0.5s ease-out; +`; const StyledDiv = styled.div` display: flex; @@ -8,13 +39,20 @@ const StyledDiv = styled.div` `; const StyledCanvas = styled.canvas` - height: 35rem; + height: 32rem; + background-color: ${color.BLACK}; + ${({ isEdit }) => (isEdit === UP ? videoUp : '')}; + ${({ isEdit }) => (isEdit === DOWN ? videoDown : '')}; `; -const VideoContainer: React.FC = () => { +interface props { + isEdit: string; +} + +const VideoContainer: React.FC = ({ isEdit }) => { return ( - + ); }; diff --git a/client/src/index.html b/client/src/index.html index b1bba6e..03bc745 100644 --- a/client/src/index.html +++ b/client/src/index.html @@ -4,7 +4,9 @@ - react-app + + + WAVE diff --git a/client/src/pages/edit.tsx b/client/src/pages/edit.tsx new file mode 100644 index 0000000..064edde --- /dev/null +++ b/client/src/pages/edit.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; +import styled from 'styled-components'; + +import TimeLine from '@/components/organisms/TimeLine'; +import Header from '@/components/organisms/Header'; +import Tools from '@/components/organisms/Tools'; +import VideoContainer from '@/components/organisms/VideoContainer'; +import Loading from '@/components/atoms/Loading'; +import { getMessage } from '@/store/selectors'; + +const StyledDiv = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100vh; + width: 100vw; +`; + +const BottomDiv = styled.div``; + +const EditPage: React.FC = () => { + const message = useSelector(getMessage); + const [isEdit, setEdit] = useState(''); + + return ( + + {message && } +
+ + + + + + + ); +}; + +export default React.memo(EditPage); diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/store/actionTypes.ts b/client/src/store/actionTypes.ts index e4a1eb2..93c9903 100644 --- a/client/src/store/actionTypes.ts +++ b/client/src/store/actionTypes.ts @@ -16,6 +16,16 @@ export const MOVE_TO = 'current/MOVE_TO'; export const SET_THUMBNAILS = 'current/SET_THUMBNAILS'; export const CROP = 'current/CROP'; +// crop +export const CROP_START = 'crop/CROP_START'; +export const cropStart = () => ({ type: CROP_START }); + +export const CROP_CANCEL = 'crop/CROP_CANCEL'; +export const cropCancel = () => ({ type: CROP_CANCEL }); + +export const CROP_CONFIRM = 'crop/CROP_CONFIRM'; +export const cropConfirm = () => ({ type: CROP_CONFIRM }); + // history // global diff --git a/client/src/store/crop/actions.ts b/client/src/store/crop/actions.ts new file mode 100644 index 0000000..f2f9dbc --- /dev/null +++ b/client/src/store/crop/actions.ts @@ -0,0 +1,32 @@ +import { CROP_START, CROP_CANCEL, CROP_CONFIRM, CROP } from '../actionTypes'; +import { CropAction } from '../currentVideo/actions'; + +export const cropStart = () => ({ + type: typeof CROP_START, +}); + +export const cropCancel = () => ({ + type: typeof CROP_CANCEL, +}); + +export const cropConfirm = () => ({ + type: typeof CROP_CONFIRM, +}); + +export type CropStartAction = { + type: typeof CROP_START; +}; + +export type CropCancelAction = { + type: typeof CROP_CANCEL; +}; + +export type CropConfirmAction = { + type: typeof CROP_CONFIRM; +}; + +export type CropStoreAction = + | CropStartAction + | CropCancelAction + | CropConfirmAction + | CropAction; diff --git a/client/src/store/crop/reducer.ts b/client/src/store/crop/reducer.ts new file mode 100644 index 0000000..70270d3 --- /dev/null +++ b/client/src/store/crop/reducer.ts @@ -0,0 +1,42 @@ +import { CROP_START, CROP_CANCEL, CROP_CONFIRM, CROP } from '../actionTypes'; +import { CropStoreAction } from './actions'; + +export interface CropState { + isCrop: boolean; + isCropConfirm: boolean; +} + +const initialState: CropState = { + isCrop: false, + isCropConfirm: false, +}; + +export default ( + state: CropState = initialState, + action: CropStoreAction +): CropState => { + switch (action.type) { + case CROP_START: + return { + ...state, + isCrop: true, + }; + case CROP_CANCEL: + return { + ...state, + isCrop: false, + }; + case CROP_CONFIRM: + return { + ...state, + isCropConfirm: true, + }; + case CROP: + return { + isCrop: false, + isCropConfirm: false, + }; + default: + return state; + } +}; diff --git a/client/src/store/crop/sagas.ts b/client/src/store/crop/sagas.ts new file mode 100644 index 0000000..e12cbb3 --- /dev/null +++ b/client/src/store/crop/sagas.ts @@ -0,0 +1,25 @@ +import { put, call, takeLatest } from 'redux-saga/effects'; + +import video from '@/video/video'; +import webglController from '@/webgl/webglController'; +import { setThumbnails } from '../currentVideo/actions'; +import { CROP, error } from '../actionTypes'; + +function* updateThumbnails(action) { + try { + const thumbnails: string[] = yield call( + video.makeThumbnails, + action.payload.start, + action.payload.end + ); + yield call(webglController.main); + yield put(setThumbnails(thumbnails)); + } catch (err) { + console.log(err); + yield put(error()); + } +} + +export default function* CropThumbnail() { + yield takeLatest(CROP, updateThumbnails); +} diff --git a/client/src/store/currentVideo/actions.ts b/client/src/store/currentVideo/actions.ts index 390a2c9..738ac53 100644 --- a/client/src/store/currentVideo/actions.ts +++ b/client/src/store/currentVideo/actions.ts @@ -51,7 +51,7 @@ export type SetThumbnailsAction = { }; }; -type CropAction = { +export type CropAction = { type: typeof CROP; payload: { start: number; diff --git a/client/src/store/originalVideo/sagas.ts b/client/src/store/originalVideo/sagas.ts index d277b48..ee70e4d 100644 --- a/client/src/store/originalVideo/sagas.ts +++ b/client/src/store/originalVideo/sagas.ts @@ -1,13 +1,19 @@ -import { put, call, takeLatest } from 'redux-saga/effects'; +import { put, call, takeLatest, select } from 'redux-saga/effects'; import video from '@/video/video'; -import WebglController from '@/webgl/webglController'; +import webglController from '@/webgl/webglController'; import { loadMetadata } from './actions'; import { setThumbnails } from '../currentVideo/actions'; import { SET_VIDEO, error } from '../actionTypes'; +import { getDuration } from '../selectors'; const TIMEOUT = 5_000; +export function* deleteSrc() { + yield call(webglController.reset); + yield call(video.revoke); +} + function waitMetadataLoading(objectURL) { return new Promise((resolve, reject) => { const timer = setTimeout(reject, TIMEOUT, 'loading metadata timeout'); @@ -33,7 +39,7 @@ function* load(action) { const thumbnails: string[] = yield call(video.makeThumbnails, 0, duration); - yield call(WebglController.main); + yield call(webglController.main); yield put(setThumbnails(thumbnails)); } catch (err) { console.log(err); @@ -41,6 +47,6 @@ function* load(action) { } } -export default function* watchSetVideo() { +export function* watchSetVideo() { yield takeLatest(SET_VIDEO, load); } diff --git a/client/src/store/reducer.ts b/client/src/store/reducer.ts index 5e786ba..93013ec 100644 --- a/client/src/store/reducer.ts +++ b/client/src/store/reducer.ts @@ -1,15 +1,18 @@ import { combineReducers } from 'redux'; import originalVideo, { OriginalVideoState } from './originalVideo/reducer'; import currentVideo, { CurrentVideoState } from './currentVideo/reducer'; +import crop, { CropState } from './crop/reducer'; export interface RootState { originalVideo: OriginalVideoState; currentVideo: CurrentVideoState; + crop: CropState; } const reducers = { originalVideo, currentVideo, + crop, }; export default combineReducers(reducers); diff --git a/client/src/store/sagas.ts b/client/src/store/sagas.ts index 09d4577..7fb727a 100644 --- a/client/src/store/sagas.ts +++ b/client/src/store/sagas.ts @@ -1,16 +1,12 @@ import { all, call, takeEvery } from 'redux-saga/effects'; -import video from '@/video'; -import watchSetVideo from '@/store/originalVideo/sagas'; +import { watchSetVideo, deleteSrc } from '@/store/originalVideo/sagas'; +import CropThumbnail from '@/store/crop/sagas'; import { RESET } from './actionTypes'; -function* deleteSrc() { - yield call(video.revoke); -} - function* watchReset() { yield takeEvery(RESET, deleteSrc); } export default function* rootSaga() { - yield all([watchSetVideo(), watchReset()]); + yield all([watchSetVideo(), watchReset(), CropThumbnail()]); } diff --git a/client/src/store/selectors.ts b/client/src/store/selectors.ts index 336be27..934baf4 100644 --- a/client/src/store/selectors.ts +++ b/client/src/store/selectors.ts @@ -25,3 +25,27 @@ export const getStartEnd = (state: RootState) => { }; export const getThumbnails = (state: RootState) => state.currentVideo.thumbnails; + +// crop +export const getIsCrop = (state: RootState) => state.crop.isCrop; + +export const getIsCropConfirm = (state: RootState) => state.crop.isCropConfirm; + +export const getCropState = (state: RootState) => { + const { isCrop } = state.crop; + const { isCropConfirm } = state.crop; + + return { + isCrop, + isCropConfirm, + }; +}; +export const getIsCropAndDuration = (state: RootState) => { + const { isCrop } = state.crop; + const { length } = state.originalVideo; + + return { + isCrop, + duration: length, + }; +}; diff --git a/client/src/video/video.tsx b/client/src/video/video.tsx index 201e2c7..5f3bbb0 100644 --- a/client/src/video/video.tsx +++ b/client/src/video/video.tsx @@ -1,12 +1,21 @@ class Video { private video: HTMLVideoElement; - private THUMNAIL_COUNT: number = 30; - private canvas: HTMLCanvasElement; + private THUMBNAIL_COUNT: number = 30; + private thumbnails: string[]; + private getAllowedFields: Set = new Set([ + 'paused', + 'duration', + 'videoWidth', + 'videoHeight', + 'src', + 'currentTime', + ]); + constructor() { this.canvas = document.createElement('canvas'); this.video = document.createElement('video'); @@ -18,28 +27,13 @@ class Video { return this.video.paused; }; - getVideo = () => { - return this.video; - }; - - getDuration = () => { - return this.video.duration; + get = field => { + if (this.getAllowedFields.has(field)) return this.video[field]; + return undefined; }; - getVideoWidth = () => { - return this.video.videoWidth; - }; - - getVideoHeight = () => { - return this.video.videoHeight; - }; - - getSrc = () => { - return this.video.src; - }; - - getCurrentTime = () => { - return this.video.currentTime; + getVideo = () => { + return this.video; }; getThumbnails = () => { @@ -59,20 +53,20 @@ class Video { return new Promise((resolve, reject) => { try { (async () => { - const gap = (end - start) / (this.THUMNAIL_COUNT - 1); - let secs = start; + const gap = (end - start) / (this.THUMBNAIL_COUNT - 1); + let secs = end; - const images: string[] = []; + const images: string[] = new Array(this.THUMBNAIL_COUNT); - for (let count = 0; count < this.THUMNAIL_COUNT; count += 1) { + for (let count = this.THUMBNAIL_COUNT - 1; count >= 0; count -= 1) { this.setCurrentTime(secs); const image: string = await this.getImageAt(); - secs += gap; - images.push(image); + secs -= gap; + images[count] = image; } - this.setCurrentTime(0); - this.thumbnails = images; + if (start === 0 && end === this.video.duration) + this.thumbnails = images; resolve(images); })(); } catch (err) { @@ -103,8 +97,9 @@ class Video { }; revoke = () => { - if (this.getSrc()) { - URL.revokeObjectURL(this.getSrc()); + const src = this.get('src'); + if (src) { + URL.revokeObjectURL(src); this.video.removeAttribute('src'); this.load(); } diff --git a/client/src/webgl/webglController.ts b/client/src/webgl/webglController.ts index 78661eb..00ccd41 100644 --- a/client/src/webgl/webglController.ts +++ b/client/src/webgl/webglController.ts @@ -23,104 +23,63 @@ interface ProgramInfo { }; } -class WebglController { - copyVideo: Boolean; +const RATIO = 1.25; +const INVERSE = 1 / RATIO; - positions: Array; +class WebglController { + positions: number[][]; buffers: Buffers; gl: WebGLRenderingContext; + init = { + positions: [ + [-1.0, -1.0], + [1.0, -1.0], + [1.0, 1.0], + [-1.0, 1.0], + ], + }; + constructor() { - this.copyVideo = false; - this.positions = [-1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0]; + this.positions = this.init.positions.map(pair => [...pair]); } rotateLeft90Degree = () => { - this.positions.push(this.positions.shift()); + // 0123 => 1230 this.positions.push(this.positions.shift()); this.buffers = this.initBuffers(); }; rotateRight90Degree = () => { - this.positions.unshift(this.positions.pop()); + // 0123 => 3012 this.positions.unshift(this.positions.pop()); this.buffers = this.initBuffers(); }; reverseUpsideDown = () => { - const x1 = this.positions.shift(); - const y1 = this.positions.shift(); - - const x2 = this.positions.shift(); - const y2 = this.positions.shift(); - - const x3 = this.positions.shift(); - const y3 = this.positions.shift(); - this.positions.push(x3); - this.positions.push(y3); - - this.positions.push(x2); - this.positions.push(y2); - - this.positions.push(x1); - this.positions.push(y1); - + // 0123 => 3210 + this.positions.reverse(); this.buffers = this.initBuffers(); }; reverseSideToSide = () => { - const x1 = this.positions.shift(); - const y1 = this.positions.shift(); - - const x2 = this.positions.shift(); - const y2 = this.positions.shift(); - - const x3 = this.positions.shift(); - const y3 = this.positions.shift(); - - this.positions.push(x3); - this.positions.push(y3); - - this.positions.unshift(y1); - this.positions.unshift(x1); - - this.positions.unshift(y2); - this.positions.unshift(x2); - + // 0123 => 1032 + this.positions = [ + ...this.positions.slice(0, 2).reverse(), + ...this.positions.slice(-2).reverse(), + ]; this.buffers = this.initBuffers(); }; enlarge = () => { - const temp = []; - - this.positions.forEach(element => { - if (element < 0) { - temp.push(element - 1); - } else { - temp.push(element + 1); - } - }); - - this.positions = temp; - + this.positions = this.positions.map(pair => pair.map(val => val * RATIO)); this.buffers = this.initBuffers(); }; reduce = () => { - const temp = []; - - this.positions.forEach(element => { - if (element < 0) { - temp.push(element + 1); - } else { - temp.push(element - 1); - } - }); - - this.positions = temp; - + this.positions = this.positions.map(pair => pair.map(val => val * INVERSE)); this.buffers = this.initBuffers(); }; @@ -139,7 +98,7 @@ class WebglController { this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer); this.gl.bufferData( this.gl.ARRAY_BUFFER, - new Float32Array(this.positions), + new Float32Array(this.positions.flat()), this.gl.STATIC_DRAW ); @@ -353,8 +312,8 @@ class WebglController { glInit = () => { this.gl = this.initCanvas( - video.getVideoWidth().toString(), - video.getVideoHeight().toString() + video.get('videoWidth').toString(), + video.get('videoHeight').toString() ); this.buffers = this.initBuffers(); const shaderProgram = this.initShaderProgram(); @@ -384,7 +343,7 @@ class WebglController { const texture = this.initTexture(); const render = () => { - if (!video.getSrc()) return; + if (!video.get('src')) return; this.updateTexture(texture); this.drawScene(programInfo, texture); @@ -397,5 +356,11 @@ class WebglController { main = () => { this.glInit(); }; + + reset = () => { + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + this.positions = this.init.positions.map(pair => [...pair]); + this.initBuffers(); + }; } export default new WebglController(); diff --git a/tsconfig.json b/tsconfig.json index 199fa1d..242701f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "lib": [ "es5", "es6", - "es2015", + "es2019", "DOM" ], "jsx": "react",