Notice
Recent Posts
Recent Comments
Link
250x250
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
Tags
- 풀스택
- 상태관리
- 프론트 엔드
- 코딩
- 국비지원
- 비전공자
- 리엑트
- 클론코딩
- 리엑트 네이티브
- 자바
- react native
- 인스타그램
- Java
- 비전공
- 스프링
- 스타트업
- 개발자
- Spring
- 풀스택 개발자
- 백엔드
- expo
- spring boot
- 국비 지원
- 개발
- 스프링 부트
- react-native
- ffmpeg
- 프론트엔드
- Redux
- react
Archives
- Today
- Total
오티스의개발일기
[FFMPEG] 계획 및 ffmpeg-kit-react-native 를 활용하여 영상프레임마다 잘라 클라이언트에 보여주기 본문
개발/FFmpeg
[FFMPEG] 계획 및 ffmpeg-kit-react-native 를 활용하여 영상프레임마다 잘라 클라이언트에 보여주기
안되면 될때까지.. 2023. 1. 14. 20:56728x90
다음글 >
2023.01.15 - [개발/FFmpeg] - [FFMPEG] FFmpeg Commands 알아보기 + FFmpeg 맥북 M1 에 설치
# FFmpeg 란?
FFmpeg (www.ffmpeg.org) 은 비디오, 오디오, 이미지를 쉽게
인코딩 (Encoding),
디코딩 (Decoding),
먹싱 (Muxing),
디먹싱 (Demuxing)
할 수 있도록 도움을 주는 멀티미디어 프레임워크 입니다.
# 사용하는 이유
일단 내가 만드려고 하는것은 단순 카메라 어플이아닌 영상을 받아와 어떠한 노래나 영상을 편집한다던가
그런것들을 가능하게하는 어플을 만들고싶다.
그렇기때문에 위에서 언급한 것들을 활용해
받은영상을
1. 디코딩후 디먹싱과정을 거쳐 영상과 오디오를 분리한후
2. ffmpegCommand 를 활용하여 내가 원하는 쿼리문으로 영상을 조작한후
3. 먹싱과 인코딩을 통해 다시 추출하여 그화면을 클라이언트에 보여주는 과정을 진행해야한다.
이러한 작업하기위해서는 ffmpeg 만이 유일한 기술이기때문에 사용해야한다.
위에 언급한것처럼 물론 쉬워보이지 않는다.
그렇기때문에 이 공부하는 내용들을 하나하나 집어가야할 필요성이있다.
일단 계획은 이렇다.
- 온라인상에 존재하는 ffmpeg-kit-react-native 라이브러리를 활용한 프로잭트를 클론
- 코드의 순서를 파악
- 그후 ffmpegCommand 에 존재하는 수많은 커맨드중 주요 기능들에대해 공부
- 클라이언트와 별개로 내가 원하는 command 를 사용해 영상을 직접 편집
- 그리고 react-native 에 만든 커맨드들을 적용
- 커맨드에따른 ui 제작후 적용시키는 과정
항상 계획은 틀어지기 마련... 시간이좀 오래걸리더라도 한번 하나하나 뜯어볼 생각이다..
오늘은 클론코딩을 진행하여 주석에 설명을 다는 작업을 하였다.
이프로잭트는 영상을 업로드한후 초마다 프레임을 잘라 그 프레임들을 하나하나 캐싱하여 저장한후
클라이언트에 보여주는 프로잭트이다.
결과물 먼저 보여주도록 하겠다.
# Camera.js
import React, {useState} from 'react';
import {
SafeAreaView,
Dimensions,
Pressable,
Text,
StyleSheet,
View,
ScrollView,
Image,
} from 'react-native';
import ImagePicker from 'react-native-image-crop-picker';
import Video from 'react-native-video';
import FFmpegWrapper from '../../constants/FFmpegWrapper';
const SCREEN_WIDTH = Dimensions.get('screen').width; // 화면 width 사이즈
const SCREEN_HEIGHT = Dimensions.get('screen').height; // 화면 height 사이즈
export const FRAME_PER_SEC = 1; // 몇초마다 끊을것인지
export const FRAME_WIDTH = 80; // 하나의 프레임당 width 길이 [노란색 border 의 프레임을 뜻함]
const TILE_HEIGHT = 80; // 4 sec. 의 높이 길이
const TILE_WIDTH = FRAME_WIDTH / 2; // 현재 노란색 프레임의 반크기
const DURATION_WINDOW_DURATION = 4; // 프레임 몇개를 사용할것인지
const DURATION_WINDOW_BORDER_WIDTH = 4; // 테두리 굵기
const DURATION_WINDOW_WIDTH =
DURATION_WINDOW_DURATION * FRAME_PER_SEC * TILE_WIDTH; // 총 노란색 프레임의 width 길이
const POPLINE_POSITION = '50%'; // 노란색 프레임 중간 노란색 divder 의 위치 선정 50% === center
const getFileNameFromPath = path => { // 파일 이름 가져오는 함수
const fragments = path.split('/');
let fileName = fragments[fragments.length - 1];
fileName = fileName.split('.')[0];
return fileName;
};
const FRAME_STATUS = Object.freeze({
LOADING: {name: Symbol('LOADING')},
READY: {name: Symbol('READY')},
});
const Camera = () => {
const [selectedVideo, setSelectedVideo] = useState(); // {uri: <string>, localFileName: <string>, creationDate: <Date>}
const [frames, setFrames] = useState(); // <[{status: <FRAME_STATUS>, uri: <string>}]>
const handlePressSelectVideoButton = () => {
ImagePicker.openPicker({
mediaType: 'video',
}).then(videoAsset => {
console.log(`Selected video ${JSON.stringify(videoAsset, null, 2)}`);
setSelectedVideo({
uri: videoAsset.sourceURL || videoAsset.path,
localFileName: getFileNameFromPath(videoAsset.path),
creationDate: videoAsset.creationDate,
});
});
};
const handleVideoLoad = videoAssetLoaded => { // 영상이 업로드 된후 동작 videoAssetLoaded 안에는 들어온 영상의 정보가 담겨져있다.
const numberOfFrames = Math.ceil(videoAssetLoaded.duration); // 영상의 초를 반올림함 5.758999824523926 -> 6
setFrames( // 영상이 로드가된후 초마다 만들어진 프레임의 상태를 전부 loading 으로 채워준다.
Array(numberOfFrames).fill({
status: FRAME_STATUS.LOADING.name.description,
}),
);
FFmpegWrapper.getFrames( // 업로드된 영사을 FFmpeg Command 를 통하여 원하는 초마다 프레임을자르고 반환
selectedVideo.localFileName, // 업로드된 영상의 이름
selectedVideo.uri, // 업로드된 영상의 uri
numberOfFrames, // 프레임의 갯수
filePath => {
const _framesURI = []; // 각 프레임을 담을 배열
for (let i = 0; i < numberOfFrames; i++) {
_framesURI.push( // 각프레임을 하나하나 담는다.
`${filePath.replace('%4d', String(i + 1).padStart(4, 0))}`, // FFmpegWrapper 에서 지정한 outputImagePath 의 이름중 %4d' -> padStart 를 통해 받은 인덱스 예) 0,1,2,3 -> 0001 ,0002,0003,0004 로 변경후 교체
);
}
const _frames = _framesURI.map(_frameURI => ({ // 받은 배열들을 다시 map으로 반복문을 돌려 프레임마다 uri 를 저장해주고 상태를 LOADING -> READY 으로 변경해준다.
uri: _frameURI,
status: FRAME_STATUS.READY.name.description,
}));
setFrames(_frames); // 변경된 값들을 useState 를통해 다시 저장
},
);
};
const renderFrame = (frame, index) => {
if (frame.status === FRAME_STATUS.LOADING.name.description) { // 받은 프레임의 상태가 LOADING 이라면
return <View style={styles.loadingFrame} key={index} />; // 로딩중인 프레임 반환
} else { // 받은 프레임의 상태가 READY 이라면
return ( // 정상적인 프레임 반환
<Image
key={index}
source={{uri: 'file://' + frame.uri}} // 파일은 저장했지만 아이폰 기준으로 file://을 붙여줘야하므로 file 포함애서 반환
style={{
width: TILE_WIDTH,
height: TILE_HEIGHT,
}}
onLoad={() => { // 다되면 이미지가 반환됬다고 알림
console.log('Image loaded');
}}
/>
);
}
};
return (
<SafeAreaView style={styles.mainContainer}>
{selectedVideo ? ( // 선택된 비디오가 있다면
<>
<View style={styles.videoContainer}>
<Video
style={styles.video}
resizeMode={'cover'}
source={{uri: selectedVideo.uri}}
repeat={true}
onLoad={handleVideoLoad}
/>
</View>
{frames && (
<View style={styles.durationWindowAndFramesLineContainer}>
<View style={styles.durationWindow}>
<View style={styles.durationLabelContainer}>
<Text style={styles.durationLabel}>
{DURATION_WINDOW_DURATION} sec.
</Text>
</View>
</View>
<View style={styles.popLineContainer}>
<View style={styles.popLine} />
</View>
<View style={styles.durationWindowLeftBorder} />
<View style={styles.durationWindowRightBorder} />
<ScrollView
showsHorizontalScrollIndicator={false}
horizontal={true}
style={styles.framesLine}
alwaysBounceHorizontal={true}
scrollEventThrottle={1}>
<View style={styles.prependFrame} />
{frames.map((frame, index) => renderFrame(frame, index))}
<View style={styles.appendFrame} />
</ScrollView>
</View>
)}
</>
) : ( // 선택된 비디오가 없다면
<Pressable
style={styles.buttonContainer}
onPress={handlePressSelectVideoButton}>
<Text style={styles.buttonText}>Select a video</Text>
</Pressable>
)}
</SafeAreaView>
);
};
const styles = StyleSheet.create({
mainContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
buttonContainer: {
backgroundColor: '#000',
paddingVertical: 12,
paddingHorizontal: 32,
borderRadius: 16,
},
buttonText: {
color: '#fff',
},
videoContainer: {
width: SCREEN_WIDTH,
height: 0.6 * SCREEN_HEIGHT,
backgroundColor: 'rgba(255,255,255,0.1)',
zIndex: 0,
},
video: {
height: '100%',
width: '100%',
},
durationWindowAndFramesLineContainer: {
top: -DURATION_WINDOW_BORDER_WIDTH,
width: SCREEN_WIDTH,
height: TILE_HEIGHT + DURATION_WINDOW_BORDER_WIDTH * 2,
justifyContent: 'center',
zIndex: 10,
},
durationWindow: {
width: DURATION_WINDOW_WIDTH,
borderColor: 'yellow',
borderWidth: DURATION_WINDOW_BORDER_WIDTH,
borderRadius: 4,
height: TILE_HEIGHT + DURATION_WINDOW_BORDER_WIDTH * 2,
alignSelf: 'center',
},
durationLabelContainer: {
backgroundColor: 'yellow',
alignSelf: 'center',
top: -26,
paddingHorizontal: 8,
paddingVertical: 4,
borderTopLeftRadius: 4,
borderTopRightRadius: 4,
},
durationLabel: {
color: 'rgba(0,0,0,0.6)',
fontWeight: '700',
},
popLineContainer: {
position: 'absolute',
alignSelf: POPLINE_POSITION === '50%' && 'center',
zIndex: 25,
},
popLine: {
width: 3,
height: TILE_HEIGHT,
backgroundColor: 'yellow',
},
durationWindowLeftBorder: {
position: 'absolute',
width: DURATION_WINDOW_BORDER_WIDTH,
alignSelf: 'center',
height: TILE_HEIGHT + DURATION_WINDOW_BORDER_WIDTH * 2,
left: SCREEN_WIDTH / 2 - DURATION_WINDOW_WIDTH / 2,
borderTopLeftRadius: 8,
borderBottomLeftRadius: 8,
backgroundColor: 'yellow',
zIndex: 25,
},
durationWindowRightBorder: {
position: 'absolute',
width: DURATION_WINDOW_BORDER_WIDTH,
right: SCREEN_WIDTH - SCREEN_WIDTH / 2 - DURATION_WINDOW_WIDTH / 2,
height: TILE_HEIGHT + DURATION_WINDOW_BORDER_WIDTH * 2,
borderTopRightRadius: 8,
borderBottomRightRadius: 8,
backgroundColor: 'yellow',
zIndex: 25,
},
framesLine: {
width: SCREEN_WIDTH,
position: 'absolute',
},
loadingFrame: {
width: TILE_WIDTH,
height: TILE_HEIGHT,
backgroundColor: 'rgba(0,0,0,0.05)',
borderColor: 'rgba(0,0,0,0.1)',
borderWidth: 1,
},
prependFrame: {
width: SCREEN_WIDTH / 2 - DURATION_WINDOW_WIDTH / 2,
},
appendFrame: {
width: SCREEN_WIDTH / 2 - DURATION_WINDOW_WIDTH / 2,
},
});
export default Camera;
# FFmpegWrapper.js
아무래도 이부분이 실질적인 ffmpeg를 활용하는곳이다 다른 부분은 다 이해했지만 command 쪽은 아직 이해가가지않아서 커맨드에대한 부분을 따로 포스팅할 예정이다.
// lib/FFmpeg.js
import {FFmpegKit, FFmpegKitConfig, ReturnCode} from 'ffmpeg-kit-react-native';
import RNFS from 'react-native-fs';
import {
FRAME_PER_SEC, // 몇초마다 끊을것인지
FRAME_WIDTH, // 하나의 프레임당 width 길이 [노란색 border 의 프레임을 뜻함]
} from '../screens/main/Camera';
class FFmpegWrapper {
static getFrames(
localFileName,
videoURI,
frameNumber,
successCallback,
errorCallback,
) {
let outputImagePath = `${RNFS.CachesDirectoryPath}/${localFileName}_%4d.png`; // 업로드된 파일을 캐싱하여 각 초마다 저장했을때의 path 를 등록
const ffmpegCommand = `-ss 0 -i ${videoURI} -vf "fps=${FRAME_PER_SEC}/1:round=up,scale=${FRAME_WIDTH}:-2" -vframes ${frameNumber} ${outputImagePath}`;
FFmpegKit.executeAsync( // FFmpegKit 라이브러리의 비동기 실행함수 시작
ffmpegCommand, // 작성한 커맨드를 실행 이해하기 쉽게 풀면 sql query 날렸다 생각하면 쉬움
async session => { // 비동기후 response 반환
const state = FFmpegKitConfig.sessionStateToString( // 상태를 String 형태로 변환
await session.getState(), // 세션의 상태를 받아옴
);
const returnCode = await session.getReturnCode(); // 세션의 response 확인
const failStackTrace = await session.getFailStackTrace(); // 실패시 호출이 시작된시점부터 예외가 발생할때까지의 함수 목록반환 (디버깅용임)
const duration = await session.getDuration(); // 걸린 시간 반환
if (ReturnCode.isSuccess(returnCode)) { // 성공적으로 처리되었다면
console.log(
`Encode completed successfully in ${duration} milliseconds;.`, // 처리되었다는 log 반환
);
console.log(`Check at ${outputImagePath}`); // 프레임이 저장된 위치
successCallback(outputImagePath); // 이 getFrame 을 호출한 부분으로 callBack 쉽게 말하면 resolve 와 비슷하다
} else { // 실패시 상태 반환
console.log('Encode failed. Please check log for the details.');
console.log(
`Encode failed with state ${state} and rc ${returnCode}.${
(failStackTrace, '\\n')
}`,
);
errorCallback(); // reject 와 비슷한 에러 콜백 반환
}
},
log => { // 로그
console.log(log.getMessage());
},
statistics => { // 통계 같음
console.log(statistics);
},
).then(session =>
console.log(
`Async FFmpeg process started with sessionId ${session.getSessionId()}.`, // 세션 아이디
),
);
}
}
export default FFmpegWrapper;
이것으로 포스팅을 마치겠다..
다음시간에는 command 쪽을 좀더 파볼생각이다.
다음글 >
2023.01.15 - [개발/FFmpeg] - [FFMPEG] FFmpeg Commands 알아보기 + FFmpeg 맥북 M1 에 설치
# 깃허브 주소
https://github.com/1domybest/react-native-baund-clone.git
728x90
'개발 > FFmpeg' 카테고리의 다른 글
Comments