Intro
๋ฐ๋ธ์ฝ์ค์์ ์ฒซ ํ๋ก์ ํธ๊ฐ ๋ง๋ฌด๋ฆฌ๋์๋ค.
์ฃผ๋ง ํฌํจ ์ผ์ฃผ์ผ ๊ฐ ์งํ๋ ํ๋ก์ ํธ์๋๋ฐ ํ์ ๊ตฌํ์ฌํญ์ ๋ชจ๋ ๊ตฌํํด์ ๋คํ์ด์๋ค. ํด,,
๊ทธ๋๋ ์์ฌ์ผ๋ก ๋ ์์ฉํด์ ์ถ๊ฐ ๊ธฐ๋ฅ๋ ๋ฃ๊ณ ์ถ์๋๋ฐ ๊ทธ๋ฌ์ง ๋ชปํด์ ์์ฌ์ ๋ค.
ํ๋ก์ ํธ ๊ฒฐ๊ณผ
vercel๋ก ๋ฐฐํฌ
https://project1-react-js-bootstrap-three.vercel.app/
Gayeon's Video Editor
project1-react-js-bootstrap-three.vercel.app
ํ๋ก์ ํธ ํด๋ ๊ตฌ์กฐ
๐ฆsrc
โฃ ๐assets
โ โฃ ๐github_icon.png
โ โ ๐video_placeholder.png
โฃ ๐components
โ โฃ ๐CustomButton.js
โ โฃ ๐Footer.js
โ โฃ ๐Header.js
โ โฃ ๐LoadingModal.js
โ โฃ ๐MultiRangeSlider.js
โ โฃ ๐ToastBox.js
โ โฃ ๐VideoConversionButton.js
โ โฃ ๐VideoEditorMain.js
โ โฃ ๐VideoPlaceholder.js
โ โฃ ๐VideoPlayer.js
โ โ ๐multiRangeSlider.css
โฃ ๐util
โ โฃ ๐readFileAsBase64.js
โ โ ๐sliderValueToVideoTime.js
โฃ ๐.DS_Store
โฃ ๐App.css
โฃ ๐App.js
โฃ ๐index.css
โ ๐index.js
๋น๋์ค ๋ฐ์ดํฐ ์ํ ๊ด๋ฆฌ
React Context API
: ๋น๋์ค ๋ฐ์ดํฐ์ ๋น๋์ค ์ถ๊ฐ/์ญ์ dispatch ํจ์๋ Context๋ก ๋ชจ๋ ์ปดํฌ๋ํธ์ ๊ณต๊ธํ๋๋ก ๊ตฌํ
๋น๋์ค ์ ๋ก๋
: ‘๋น๋์ค๋ฅผ ์ ๋ก๋ํด์ฃผ์ธ์’ ์ด๋ฏธ์ง ํด๋ฆญ ์ ๋น๋์ค ํ์ผ์ ์ ๋ก๋
<section className={'upload-layout'}>
<label>
<img
className={'video-upload-img'}
src={video_placeholder}
alt="๋น๋์ค๋ฅผ ์
๋ก๋ํด์ฃผ์ธ์"
/>
<input
className={'video-upload-input'}
type="file"
accept="video/*"
onChange={e => {
onChange(e.target.files[0]);
}}
/>
</label>
</section>
๋น๋์ค ํ๋ ์ด์ด ์ฝ๋
: video-react ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉํ์ฌ ๋น๋์ค ํ๋ ์ด์ด ๊ตฌํ
import React, { useContext, useEffect, useState } from 'react';
import { Player, BigPlayButton, LoadingSpinner, ControlBar } from 'video-react';
import { VideoFileContext } from '../App';
const VideoPlayer = ({ onPlayerChange, onPlayerStateChange }) => {
const videoFile = useContext(VideoFileContext);
const [player, setPlayer] = useState();
const [playerState, setPlayerState] = useState();
const [source, setSource] = useState();
// ๋น๋์ค ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ค์
useEffect(() => {
setSource(URL.createObjectURL(videoFile));
}, [videoFile]);
// Player์ state๋ฅผ ๋ณ์๋ก ์ ์ฅ
useEffect(() => {
if (playerState) onPlayerStateChange(playerState);
}, [playerState]);
// Player๊ฐ ๋ณ๊ฒฝ๋์์ ๊ฒฝ์ฐ, (๋น๋์ค ๋ณ๊ฒฝ) Player์ Player์ state ๊ฐ ๋ณ๊ฒฝ
useEffect(() => {
onPlayerChange(player);
if (player) {
player.subscribeToStateChange(setPlayerState);
}
}, [player]);
return (
<div className={'video-player'}>
<Player ref={player => setPlayer(player)} src={source} startTime={0}>
<source src={source} />
<BigPlayButton position="center" />
<LoadingSpinner />
<ControlBar disableCompletely></ControlBar>
</Player>
</div>
);
};
export default VideoPlayer;
๋น๋์ค ํธ์ง ๊ธฐ๋ฅ
: ์๋์ ์ ํ๋ธ๋ฅผ ์ฐธ๊ณ ํ์ฌ Multi Range Slider ์ปดํฌ๋ํธ๋ฅผ ๊ตฌํํ์๋ค.
https://www.youtube.com/watch?v=pTlsnFLiK6c
import React, { useEffect, useRef, useState } from 'react';
import './multiRangeSlider.css';
const MultiRangeSlider = ({ onSliderChange }) => {
const [minVal, setMinVal] = useState(0);
const [maxVal, setMaxVal] = useState(100);
const range = useRef(null);
const thumbLeft = useRef(null);
const thumbRight = useRef(null);
useEffect(() => {
// ์ผ์ชฝ slider๊ฐ ์ค๋ฅธ์ชฝ input range์ ๊ฐ ์ด์์ผ ์ X
const value = Math.min(parseInt(minVal), parseInt(maxVal) - 1);
setMinVal(value);
onSliderChange([minVal, maxVal]);
if (range.current) {
thumbLeft.current.style.left = `${value}%`;
range.current.style.left = `${value}%`;
}
}, [minVal]);
useEffect(() => {
// ์ค๋ฅธ์ชฝ slider๊ฐ ์ผ์ชฝ input range์ ๊ฐ ์ดํ์ผ ์ X
const value = Math.max(parseInt(minVal) + 1, parseInt(maxVal));
setMaxVal(value);
onSliderChange([minVal, maxVal]);
if (range.current) {
thumbRight.current.style.right = `${100 - value}%`;
range.current.style.right = `${100 - value}%`;
}
}, [maxVal]);
return (
<div className="middle">
<div className="multi-range-slider">
<input
type="range"
min="0"
max="100"
value={minVal}
onChange={e => setMinVal(e.target.value)}
/>
<input
type="range"
min="0"
max="100"
value={maxVal}
onChange={e => setMaxVal(e.target.value)}
/>
<div className="slider">
<div className="track"></div>
<div className="range" ref={range}></div>
<div className="thumb thumb-left" ref={thumbLeft}></div>
<div className="thumb thumb-right" ref={thumbRight}></div>
</div>
</div>
</div>
);
};
export default MultiRangeSlider;
๋น๋์ค/GIF/์์ฑ ๋ด๋ณด๋ด๊ธฐ
: FFmpeg ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ฌ์ฉํ์ฌ ๊ตฌํํ์๋ค.
๐ ๋ด๋ณด๋ด๊ธฐ ๊ธฐ๋ฅ์์ ์ฌ์ฉํ๋ ๊ณตํต ํจ์ ๋ถ๋ฆฌํ๊ธฐ
- writeFile
const writeFile = async inputFileName => { return await ffmpeg.writeFile( inputFileName, await fetchFile(URL.createObjectURL(videoFile)) ); };
- getMinMaxTime
const getMinMaxTime = () => { const [min, max] = sliderValues; // sliderValueToVideoTime: Math.round((duration * sliderValue) / 100) const minTime = sliderValueToVideoTime(videoPlayerState.duration, min); const maxTime = sliderValueToVideoTime(videoPlayerState.duration, max); return [minTime, maxTime]; };
- downloadFile
const downloadFile = dataURL => { const link = document.createElement('a'); link.href = dataURL; link.setAttribute('download', ''); link.click(); };
ํ ์คํธ & ๋ชจ๋ฌ ๋์ฐ๊ธฐ
- MUI์ Snackbar, Modal ์ปดํฌ๋ํธ๋ก ๊ตฌํ
ํ๋ก์ ํธ ํ๊ณ
์ฒ์์ ์น์์ CSS๋ก ๊ธฐ๋ณธ ๋ ์ด์์ ๊ตฌ์กฐ๋ฅผ ์ก๋ ๊ฒ ์๊ฐ๋ณด๋ค ์ด๋ ค์ ๋ค. ๐ญ
์ฃผ๋ก Flex๋ฅผ ์ฌ์ฉํ๋๋ฐ ํค๋๋ฅผ ๊ณ ์ ์ํค๊ณ ๋๋จธ์ง๋ฅผ ๊ฐ์ด๋ฐ ์ ๋ ฌํ๋ ๋ถ๋ถ๋ ๊ฝค๋ ํ๋ค์๋ค. ์์ง CSS ์ค๋ ฅ์ด ๋ถ์กฑํ ๊ฒ ๊ฐ๋ค. ๊ณ์ ํ๋ก์ ํธ ํ๋ฉด์ ์ค๋ ฅ์ ํค์์ผ์ง.
๊ทธ๋ฆฌ๊ณ ์ด๋ฒ ํ๋ก์ ํธ์์ ์๋ก์ด ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ฌ์ฉํด ๋ณด์๋๋ฐ video-react, FFmpeg ์ด๋ฌํ ๋น๋์ค ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ดํดํ๋ ๋ฐ์ ์๊ฐ์ด ์ค๋ ๊ฑธ๋ ธ๋ค. ๊ทธ๋๋ ์ดํดํ๊ณ ์์ฉ๊น์ง ํ ์ ์์ด์ ๋ฟ๋ฏํ์๋ค. ํฅํ ์์ ์ผ๋ก ๋ฐฐ์ ํธ์ง ๊ธฐ๋ฅ์ ์ถ๊ฐํ๊ณ GIF ์ฉ๋์ ๋ฎ์ถฐ ์ ์ฅํ๋ ๋ฐฉ๋ฒ์ ๋ํด ์์๋ณด๊ณ ์ ์ฉํ ์์ ์ด๋ค.