FFmpeg을 라이브러리처럼(a.k.a. libav) 사용하려면 어떻게 시작해야할지 알려줄만할 튜토리얼/책을 찾아봤었습니다. 그리고는 "How to write a video player in less than 1k lines" 라는 튜토리얼을 찾았죠. 하지만 안타깝게도 그건 더이상 관리가 안되고 있어서 이 글을 쓰기로 결정했습니다.
여기서 사용된 대부분의 코드는 C로 되어있습니다. 하지만 걱정하지 마세요: 당신도 쉽게 이해할 것이고 선호하는 언어에도 적용하실 수 있을겁니다.
FFmpeg libav는 python, go와 같은 다양한 언어로 된 많은 bindings을 제공합니다. 만약 사용하려는 언어에 그것이 없다면 ffi
를 통해서도 지원할 수 있습니다. (Lua 예시)
우리는 비디오와 오디오, 코덱, 컨테이너가 무엇인지에 대해 빠르게 학습한 후에 FFmpeg
명령을 어떻게 사용하는지 대해서 파헤쳐보고 마지막으로 코드도 작성해볼 것입니다, 삽질하면서 FFmpeg libav 배우기 섹션으로 바로 넘어가셔도 좋습니다.
혹자는 인터넷 비디오 스트리밍이 전통적인 TV의 미래라고 이야기하기도 합니다. 어떻게 되든 FFmpeg은 공부해둘만한 가치가 있는 것입니다.
목차
- 소개
- FFmpeg - 명령줄 도구
- 공통 비디오 연산
- 트랜스코딩 (Transcoding)
- 트랜스먹싱 (Transmuxing)
- 트랜스레이팅 (Transrating)
- 트랜스사이징 (Transsizing)
- 보너스: 적응형 스트리밍 (Adaptive Streaming)
- 더 들어가기
- 삽질하면서 FFmpeg libav 배우기
만약 당신이 여러 연속된 이미지들을 가지고 있고 이것들을 주어진 주파수에 맞게 변화시킨다면 (이를테면 초당 24장의 이미지), 움직임의 잔상을 만들게 될 것입니다. 요약하면 이게 비디오라는 것의 가장 기본적인 아이디어입니다: 정해진 속도에 맞게 돌아가는 연속된 사진들 / 프레임들.
Zeitgenössische Illustration (1886)
음소거된 비디오만으로도 다양한 감정들을 표현할 수는 있지만 여기에 소리를 더해준다면 훨씬 더 즐거운 경험을 가져다 줄 것입니다.
소리는 공기 혹은 가스, 액체, 고체와 같은 다른 매체들을 통해 압력의 파동 형태로 전파되는 진동입니다.
디지털 오디오 시스템에서는 마이크가 소리를 아날로그 전기 신호로 전환하고, 아날로그-디지털 변환기 (ADC) - 보통 펄스-부호 변조 (PCM)를 이용하여 - 아날로그 신호를 디지탈 신호로 변환합니다.
CODEC은 디지털 오디오/비디오를 압축하거나 압축해제하는 전자회로나 소프트웨어입니다. 이것은 raw (압축이안된) 디지털 오디오/비디오를 압축된 형태로 혹은 그 반대로 변환합니다. https://en.wikipedia.org/wiki/Video_codec
만약 우리가 수많은 이미지들을 차곡차곡 채워서 영화라고 부르는 하나의 파일로 만든다면, 결과적으로 엄청나게 큰 하나의 파일을 접하게 될 것 입니다. 한번 계산해봅시다:
한번 가정해봅시다. 해상도가 1080 x 1920
(높이 x 너비)인 비디오를 하나 만들건데 색을 인코딩하는데 픽셀당 3 bytes
(화면의 최소 화소)를 쓸 것입니다. (혹은 24비트 컬러, 16,777,216개의 다른 색상을 제공) 그리고 이 비디오는 초당 24프레임
으로 재생되고 30분
정도 길이입니다.
toppf = 1080 * 1920 //total_of_pixels_per_frame
cpp = 3 //cost_per_pixel
tis = 30 * 60 //time_in_seconds
fps = 24 //frames_per_second
required_storage = tis * fps * toppf * cpp
이 비디오는 거의 250.28GB
의 저장 용량이 필요하며 1.19Gbps
의 대역폭이 요구됩니다! 이것이 바로 우리가 CODEC을 사용해야하는 이유입니다.
컨테이너 혹은 래퍼(wrapper) 포맷은 데이터와 메타데이터의 다양한 요소들이 어떻게 하나의 컴퓨터 파일에 구성되어있는지를 기술하는 스펙을 담은 메타파일 포맷입니다. https://en.wikipedia.org/wiki/Digital_container_format
하나의 파일이 모든 스트림을 담고 있고 (주로 오디오와 비디오) 이것은 또 동기화와 제목, 해상도 등과 같은 일반적인 메타데이터도 제공합니다.
보통 우리는 파일의 확장자를 보고 포맷을 유추할 수 있습니다: 예를들면 video.webm
은 아마도 webm
를 컨테이너로 사용하는 비디오겠죠.
오디오와 비디오를 녹화하고 변환하고 스트리밍할 수 있는 완전한 크로스-플랫폼 솔루션.
멀티미디어 작업을 한다면 우리는 FFmpeg이라고 하는 정말 쩌는 툴/라이브러리를 사용할 수 있습니다. 아마도 여러분도 이것을 직간접적으로 알고있거나/사용했던 기회가 있었을 것입니다. (Chrome 사용시죠?).
이것은 ffmpeg
이라고하는 아주 단순하지만 파워풀한 바이너리 형태의 명려줄 프로그램도 제공합니다.
예를들어, 아래 명령을 치는 것만으로도 컨테이너를 mp4
에서 avi
로 변환할 수 있습니다:
$ ffmpeg -i input.mp4 output.avi
우리는 방금 어떤 컨테이너에서 다른 컨테이너로 변환하는 과정인 remuxing을 해보았습니다. 기술적으로 FFmpeg은 트랜스코딩(transcoding)도 할 수 있습니다만 이것들에 대해서는 뒤에서 다시 이야기하겠습니다.
FFmpeg이 어떻게 동작하는지를 아주 잘 설명하고 있는 문서가 있습니다.
간단히 정리하면, FFmpeg 명령줄 프로그램은 실행하기 위해 다음과 같은 형식의 인자를 갖춰야합니다 ffmpeg {1} {2} -i {3} {4} {5}
, 여기서:
- 전역 옵션
- 입력 파일 옵션
- 입력 url
- 출력 파일 옵션
- 출력 url
2, 3, 4, 5 부분은 필요한만큼 많아질 수 있습니다. 실제로 수행해보면 이 인자 형식을 더 쉽게 이해할 수 있습니다:
# WARNING: this file is around 300MB
$ wget -O bunny_1080p_60fps.mp4 http://distribution.bbb3d.renderfarming.net/video/mp4/bbb_sunflower_1080p_60fps_normal.mp4
$ ffmpeg \
-y \ # 전역 옵션
-c:a libfdk_aac \ # 입력 파일 옵션
-i bunny_1080p_60fps.mp4 \ # 입력 url
-c:v libvpx-vp9 -c:a libvorbis \ # 출력 파일 옵션
bunny_1080p_60fps_vp9.webm # 출력 url
이 명령은 두개의 스트림(aac
코덱으로 인코딩된 오디오와 h264
코덱으로 인코딩된 비디오)을 포함하는 mp4
를 입력 파일로 받고 이를 webm
으로 변환합니다, 물론 그 안의 오디오와 비디오 코덱들도 변환하고 있죠.
위의 명령을 더 단순화할 수도 있는데 그러면 FFmpeg이 기본값들을 사용하거나 추측하게될 것입니다.
예를들어 ffmpeg -i input.avi output.mp4
이렇게만 친다면 어떤 오디오/비디오 코덱이 output.mp4
를 만들기 위해 사용될까요?
Werner Robitza가 작성한 꼭 읽고/실행해볼만한 FFmpeg으로 인코딩하고 편집하는 것에 대한 튜토리얼이 있습니다.
오디오/비디오 작업 중 보통 미디어에 대해 일련의 작업을 수행하게 됩니다.
무엇인가? 스트림 (오디오 또는 비디오) 중에 하나를 기존 코덱에서 다른 코덱으로 변환하는 작업.
왜? 가끔 어떤 장치들은 (텔레비전, 스마트폰, 콘솔 등) X는 지원하지 않지만 Y를 지원합니다. 그리고 더 새로운 코덱들은 더 나은 압축률을 제공하기도 합니다.
어떻게? H264
(AVC) 비디오를 H265
(HEVC)로 변환하기.
$ ffmpeg \
-i bunny_1080p_60fps.mp4 \
-c:v libx265 \
bunny_1080p_60fps_h265.mp4
무엇인가? 하나의 포맷을 (컨테이너) 다른 포맷으로 변환하는 작업.
왜? 가끔 어떤 장치들은 (텔레비전, 스마트폰, 콘솔 등) X는 지원하지 않지만 Y를 지원합니다. 그리고 때때로 더 새로운 컨테이터들은 최신으로 요구되는 피처들을 제공합니다.
어떻게? mp4
에서 webm
으로 변환하기.
$ ffmpeg \
-i bunny_1080p_60fps.mp4 \
-c copy \ # just saying to ffmpeg to skip encoding
bunny_1080p_60fps.webm
무엇인가? 비트레이트를 변환하거나 다른 변환본(renditions)을 만드는 작업.
왜? 사람들은 2G
(edge)가 연결된 저사양의 스마트폰에서든 광통신
인터넷이 연결된 4K 텔레비전에든 당신의 비디오 볼 것이다. 그래서 같은 비디오라도 여러 비트레이트를 가진 하나 이상의 변환본을 제공해야합니다.
어떻게? 3856K와 2000K 사이의 비트레이트를 가진 변환본을 생성하기.
$ ffmpeg \
-i bunny_1080p_60fps.mp4 \
-minrate 964K -maxrate 3856K -bufsize 2000K \
bunny_1080p_60fps_transrating_964_3856.mp4
보통 트랜스레이팅(transrating)은 트랜스사이징(transsizing)과 함께 사용합니다. Werner Robitza가 작성한 또 다른 필독/실행물 FFmpeg rate 제어에 대한 연재 포스팅가 있습니다.
무엇인가? 하나의 해상도에서 다른 것으로 변환하는 작업. 이전에 언급한 것처럼 트랜스사이징(transsizing)은 주로 트랜스레이팅(transrating)과 함께 사용됩니다.
왜? 트랜스레이팅(transrating)에서의 이유와 동일함.
어떻게? 1080p
의 해상도를 480p
로 변환하기.
$ ffmpeg \
-i bunny_1080p_60fps.mp4 \
-vf scale=480:-1 \
bunny_1080p_60fps_transsizing_480.mp4
무엇인가? 다양한 (비트레이트의) 해상도를 생성하고 미디어들을 여러 청크로 나눠서 http를 통해 서비스하는 작업.
왜? 저사양 스마트폰 혹은 4K TV에서 시청할 수 있는 유연한 미디어를 제공하기 위해. 또한 이렇게 하면 확장이나 배포하기가 쉽습니다. 다만 지연시간이 생길 수 있습니다.
어떻게? DASH를 이용하여 적응형 WebM을 생성하기.
# video streams
$ ffmpeg -i bunny_1080p_60fps.mp4 -c:v libvpx-vp9 -s 160x90 -b:v 250k -keyint_min 150 -g 150 -an -f webm -dash 1 video_160x90_250k.webm
$ ffmpeg -i bunny_1080p_60fps.mp4 -c:v libvpx-vp9 -s 320x180 -b:v 500k -keyint_min 150 -g 150 -an -f webm -dash 1 video_320x180_500k.webm
$ ffmpeg -i bunny_1080p_60fps.mp4 -c:v libvpx-vp9 -s 640x360 -b:v 750k -keyint_min 150 -g 150 -an -f webm -dash 1 video_640x360_750k.webm
$ ffmpeg -i bunny_1080p_60fps.mp4 -c:v libvpx-vp9 -s 640x360 -b:v 1000k -keyint_min 150 -g 150 -an -f webm -dash 1 video_640x360_1000k.webm
$ ffmpeg -i bunny_1080p_60fps.mp4 -c:v libvpx-vp9 -s 1280x720 -b:v 1500k -keyint_min 150 -g 150 -an -f webm -dash 1 video_1280x720_1500k.webm
# audio streams
$ ffmpeg -i bunny_1080p_60fps.mp4 -c:a libvorbis -b:a 128k -vn -f webm -dash 1 audio_128k.webm
# the DASH manifest
$ ffmpeg \
-f webm_dash_manifest -i video_160x90_250k.webm \
-f webm_dash_manifest -i video_320x180_500k.webm \
-f webm_dash_manifest -i video_640x360_750k.webm \
-f webm_dash_manifest -i video_640x360_1000k.webm \
-f webm_dash_manifest -i video_1280x720_500k.webm \
-f webm_dash_manifest -i audio_128k.webm \
-c copy -map 0 -map 1 -map 2 -map 3 -map 4 -map 5 \
-f webm_dash_manifest \
-adaptation_sets "id=0,streams=0,1,2,3,4 id=1,streams=5" \
manifest.mpd
PS: 저는 이 예제를 DASH를 이용한 Adaptive WebM 재생에 대한 지침에서 가져왔습니다.
FFmpeg에 대한 아주 수많은 다른 사용방법들이 있습니다. 저는 이걸 YouTube 용 동영상들을 만들고/편집하는데 iMovie와 함께 사용합니다. 물론 여러분도 프로페셔널처럼 사용하실 수 있습니다.
가끔 '소리나는 것과 보이는 것이' 궁금하지 않으세요? David Robert Jones
FFmpeg는 미디어 파일들에 대한 필수 작업들을 수행하는 명령줄 도구로써 매우 유용합니다. 어떻게 우리의 프로그램에 이용할 수 있을까요?
FFmpeg는 우리의 프로그램에 통합될 수 있는 여러 라이브러리들로 구성되어있습니다. 보통, FFmpeg을 설치할때 이 모든 라이브러리들도 자동으로 설치됩니다. 이 라이브러리 모음들을 FFmpeg libav라고 해보죠.
이 제목은 Zed Shaw의 Learn X the Hard Way 시리즈, 특히 그의 책 Learn C the Hard Way에 대한 오마주입니다.
이 hello world는 실제로 "hello world"
메시지를 터미널에 보여주진 않습니다. 👅
대신 우리는 비디오의 정보를 출력할 것입니다. 비디오의 포맷 (컨테이너), 길이, 해상도, 오디오 채널들 같은 것들을 말이죠. 그리고 마지막으로 몇몇 프레임들을 디코딩하고 이미지 파일로 저장해보겠습니다.
하지만 코딩을 시작하기 전에, FFmpeg libav 아키텍처가 어떻게 동작하는지 이것들의 컴포넌트들이 서로 어떻게 통신하는지를 배워봅시다.
여기 비디오가 디코딩되는 프로세스를 담은 다이어그램이 하나 있습니다.
우선 여러분의 미디어 파일을 AVFormatContext
(비디오 컨테이너는 포맷이라고도 합니다)라고 불리는 컴포넌트로 불러올 필요가 있습니다.
이건 사실 파일 전체를 불러오는건 아닙니다: 종종 헤더만을 읽죠.
일단 최소한의 컨테이너 헤더를 불러왔다면, 우리는 이것의 스트림 (기본적이고 필수적인 오디오와 비디오 데이터라고 간주하시면 됩니다)에 접근할 수 있습니다.
각 스트림은 AVStream
라고 하는 컴포넌트로 접근 가능합니다.
스트림은 데이터의 연속적인 흐름을 의미하는 fancy한 이름입니다.
비디오가 두개의 스트림을 가지고 있다고 해봅시다: 오디오는 AAC CODEC로 인코딩되어있고 비디오는 H264 (AVC) CODEC로 인코딩되어있습니다. 각 스트림으로부터 AVPacket
컴포넌트로 로드될 패킷이라 칭하는 데이터의 조각들을 추출할 수 있습니다.
패킷안의 데이터는 여전히 인코딩되어 있습니다 (압축된상태). 이 패킷을 디코딩하기 위해서 우리는 이것들을 특정한 AVCodec
에 넘겨야합니다.
AVCodec
은 그것들을 AVFrame
으로 디코딩하며 최종적으로 우리에게 압축 해제된 프레임을 넘겨줍니다. 오디오 및 비디오 스트림에서 동일한 용어/프로세스가 사용된다는 점을 유의하십시오.
간혹 예제를 컴파일하고 실행하는데 이슈들을 겪는 분들이 계셔서 우리의 개발/실행 환경으로 Docker
를 사용할 것입니다, 우리는 또한 big buck bunny 비디오를 사용할 것인데 따로 로컬에 가지고 있지 않다면 make fetch_small_bunny_video
명령만 실행해주시면 됩니다.
TLDR; 코드랑 실행하는거나 보여주세요.
$ make run_hello좀 상세한 부분은 넘어가겠습니다. 그러나 걱정하진 마세요: 소스 코드는 github에 있습니다.
포맷 (컨테이너)에 관한 정보를 담고 있는 AVFormatContext
컴포넌트에게 메모리를 할당합니다.
AVFormatContext *pFormatContext = avformat_alloc_context();
이제 우리는 파일을 열고 헤더를 읽어서 AVFormatContext
에 포맷에 관한 기본적인 정보를 채워줄 것입니다 (보통 코덱은 열리지 않음).
이를 위해 사용할 함수는 avformat_open_input
입니다. 이 함수는 AVFormatContext
와 filename
두개의 옵셔널 인자를 받습니다: AVInputFormat
(NULL
을 넘기면 FFmpeg이 포맷을 추측)과 AVDictionary
(demuxer에 대한 옵션)
avformat_open_input(&pFormatContext, filename, NULL, NULL);
포맷 이름과 미디어 길이를 출력할 수 있습니다:
printf("Format %s, duration %lld us", pFormatContext->iformat->long_name, pFormatContext->duration);
streams
에 접근하기 위해서는, 미디어로부터 데이터를 읽어야합니다. avformat_find_stream_info
함수가 그 일을 하죠.
pFormatContext->nb_streams
가 스트림의 개수를 가지고 있고 pFormatContext->streams[i]
는 i
번째 스트림 (AVStream
)을 반환합니다.
avformat_find_stream_info(pFormatContext, NULL);
이제 모든 스트림에 대해 루프를 돌아보겠습니다.
for (int i = 0; i < pFormatContext->nb_streams; i++)
{
//
}
각 스트림에 대해서, i
번째 스트림에 사용된 코덱 속성들을 담고있는 AVCodecParameters
를 가져오겠습니다.
AVCodecParameters *pLocalCodecParameters = pFormatContext->streams[i]->codecpar;
이 코덱 속성을 이용하여 avcodec_find_decoder
함수를 통해 적절한 코덱을 찾을 수 있습니다. 코덱 id에 맞는 등록된 디코더를 찾고 스트림을 어떻게 enCOde와 DECode할지를 알고 있는 AVCodec
컴포넌트를 반환받을 수 있습니다.
AVCodec *pLocalCodec = avcodec_find_decoder(pLocalCodecParameters->codec_id);
이제 코덱에 관한 정보를 출력할 수 있습니다.
// specific for video and audio
if (pLocalCodecParameters->codec_type == AVMEDIA_TYPE_VIDEO) {
printf("Video Codec: resolution %d x %d", pLocalCodecParameters->width, pLocalCodecParameters->height);
} else if (pLocalCodecParameters->codec_type == AVMEDIA_TYPE_AUDIO) {
printf("Audio Codec: %d channels, sample rate %d", pLocalCodecParameters->channels, pLocalCodecParameters->sample_rate);
}
// general
printf("\tCodec %s ID %d bit_rate %lld", pLocalCodec->long_name, pLocalCodec->id, pLocalCodecParameters->bit_rate);
이 코덱을 기반으로 디코딩/인코딩 프로세스에 대한 컨텍스트를 담고있는 AVCodecContext
의 메모리를 할당할 수 있습니다. 그 다음 코덱 파라미터로 코덱 컨텍스트를 채워줍니다; avcodec_parameters_to_context
로 가능합니다.
일단 코덱 컨텍스트를 채웠다면 이제 코덱을 열 수 있습니다. avcodec_open2
로 가능합니다.
AVCodecContext *pCodecContext = avcodec_alloc_context3(pCodec);
avcodec_parameters_to_context(pCodecContext, pCodecParameters);
avcodec_open2(pCodecContext, pCodec, NULL);
이제 스트림으로부터 패킷을 읽고 디코딩하여 프레임으로 만들어볼 예정입니다. 그러나 우선, AVPacket
와 AVFrame
두 컴포넌트에 대해 메모리 할당이 필요합니다.
AVPacket *pPacket = av_packet_alloc();
AVFrame *pFrame = av_frame_alloc();
패킷이 존재하는 동안 루프를 돌면서 av_read_frame
함수를 이용해 스트림으로부터 패킷을 받아오겠습니다.
while (av_read_frame(pFormatContext, pPacket) >= 0) {
//...
}
코덱 컨텍스트를 avcodec_send_packet
함수를 통해 디코더에 raw 데이터 패킷 (압축된 프레임)을 보내봅시다.
avcodec_send_packet(pCodecContext, pPacket);
그리고 마찬가지로 코덱 컨텍스트를 avcodec_receive_frame
함수를 통해 디코더로부터 raw 데이터 프레임 (압축 해제된 프레임)를 받아봅시다.
avcodec_receive_frame(pCodecContext, pFrame);
프레임 번호, PTS, DTS, 프레임 타입 등을 출력해볼 수 있습니다.
printf(
"Frame %c (%d) pts %d dts %d key_frame %d [coded_picture_number %d, display_picture_number %d]",
av_get_picture_type_char(pFrame->pict_type),
pCodecContext->frame_number,
pFrame->pts,
pFrame->pkt_dts,
pFrame->key_frame,
pFrame->coded_picture_number,
pFrame->display_picture_number
);
마지막으로 디코딩된 프레임을 심플 흑백 이미지로 저장해볼 수 있습니다. 이 과정은 매우 단순합니다, 인덱스가 planes Y, Cb, Cr를 참조하고 있는 pFrame->data
를 사용할 것입니다. 우리는 흑백 이미지를 저장하기 위해 0
(Y) 인덱스를 선택했습니다.
save_gray_frame(pFrame->data[0], pFrame->linesize[0], pFrame->width, pFrame->height, frame_filename);
static void save_gray_frame(unsigned char *buf, int wrap, int xsize, int ysize, char *filename)
{
FILE *f;
int i;
f = fopen(filename,"w");
// writing the minimal required header for a pgm file format
// portable graymap format -> https://en.wikipedia.org/wiki/Netpbm_format#PGM_example
fprintf(f, "P5\n%d %d\n%d\n", xsize, ysize, 255);
// writing line by line
for (i = 0; i < ysize; i++)
fwrite(buf + i * wrap, 1, xsize, f);
fclose(f);
}
voilà! 이제 우리는 2MB짜리 흑백 이미지를 얻어냈습니다:
플레이어가 되세요 - 신규 MSE 비디오 플레이어를 작성 중인 젊은 JS 개발자
트랜스코딩 예제 코드로 넘어가기 전에 타이밍 혹은 어떻게 비디오 플레이어가 하나의 프레임을 제시간에 재생해야하는지에 대해서 이야기해봅시다.
지난 예제에서, 우리는 이렇게 보이는 프레임들을 저장했습니다.
비디오 플레이어를 디자인 할때 각 프레임을 주어진 속도에 재생해야합니다, 그렇지 않으면 너무 빠르거나 너무 느리게 재생되기 때문에 비디오를 제대로 즐기기 어려울 것입니다.
그래서 뭔가 프레임을 원활하게 재생할 수 있는 로직을 소개할 필요가 있습니다. 이 이슈를 위해, 각 프레임은 프리젠테이션 타임스탬프 (PTS)를 갖게 되는데 이것은 프레임속도(fps) 로 나누어지는 타임베이스(timebase) 라고 하는 유리수(분모가 타임스케일(timescale) 로 알려진)로 구성된(factored) 증가하는 숫자입니다.
예제를 좀 본다면 이해가 더 쉬울 것입니다, 몇개의 시나리오를 시뮬레이션해죠.
fps=60/1
이고 timebase=1/60000
라면 각 PTS는 timescale / fps = 1000
를 증가할 것 입니다. 그래서 각 프레임의 PTS 실제 시간은 이렇게 됩니다 (0부터 시작한다고 하면):
frame=0, PTS = 0, PTS_TIME = 0
frame=1, PTS = 1000, PTS_TIME = PTS * timebase = 0.016
frame=2, PTS = 2000, PTS_TIME = PTS * timebase = 0.033
동일한 시나리오지만 타임베이스가 1/60
이라면.
frame=0, PTS = 0, PTS_TIME = 0
frame=1, PTS = 1, PTS_TIME = PTS * timebase = 0.016
frame=2, PTS = 2, PTS_TIME = PTS * timebase = 0.033
frame=3, PTS = 3, PTS_TIME = PTS * timebase = 0.050
fps=25/1
와 timebase=1/75
에 대해서는 각 PTS는 timescale / fps = 3
만큼 증가할 것이고 PTS 시간은 이렇게 될 것 입니다:
frame=0, PTS = 0, PTS_TIME = 0
frame=1, PTS = 3, PTS_TIME = PTS * timebase = 0.04
frame=2, PTS = 6, PTS_TIME = PTS * timebase = 0.08
frame=3, PTS = 9, PTS_TIME = PTS * timebase = 0.12
- ...
frame=24, PTS = 72, PTS_TIME = PTS * timebase = 0.96
- ...
frame=4064, PTS = 12192, PTS_TIME = PTS * timebase = 162.56
이제 이 pts_time
으로 오디오의 pts_time
혹은 시스템 시간과 동기화해서 재생할 방법을 찾을 수 있습니다. FFmpeg libav는 그 정보들을 아래 API를 통해 제공합니다.
- fps =
AVStream->avg_frame_rate
- tbr =
AVStream->r_frame_rate
- tbn =
AVStream->time_base
호기심에 보자면, 우리가 저장했던 프레임들을 DTS 순으로 (frames: 1,6,4,2,3,5) 보내졌지만 재생은 PTS 순 (frames: 1,2,3,4,5)로 되었습니다. 또한, B-프레임이 P 혹은 I-프레임 대비 얼마나 저렴한지도 알 수 있죠.
LOG: AVStream->r_frame_rate 60/1
LOG: AVStream->time_base 1/60000
...
LOG: Frame 1 (type=I, size=153797 bytes) pts 6000 key_frame 1 [DTS 0]
LOG: Frame 2 (type=B, size=8117 bytes) pts 7000 key_frame 0 [DTS 3]
LOG: Frame 3 (type=B, size=8226 bytes) pts 8000 key_frame 0 [DTS 4]
LOG: Frame 4 (type=B, size=17699 bytes) pts 9000 key_frame 0 [DTS 2]
LOG: Frame 5 (type=B, size=6253 bytes) pts 10000 key_frame 0 [DTS 5]
LOG: Frame 6 (type=P, size=34992 bytes) pts 11000 key_frame 0 [DTS 1]
Remuxing은 하나의 포맷 (컨테이너)에서 다른 것으로 변경하는 작업입니다. 다음 예제처럼 FFmpeg을 쓰면 별로 어렵지 않게 MPEG-4 비디오를 MPEG-TS로 변경할 수 있습니다:
ffmpeg input.mp4 -c copy output.ts
이것은 mp4를 demux하지만 디코딩이나 인코딩은 하지 않습니다. (-c copy
) 최종적으로 mpegts
파일로 mux할 것입니다. 만약 포맷을 의미하는 -f
를 제공하지 않으면 ffmpeg은 파일 확장자로 포맷을 추측할 것입니다.
FFmpeg 혹은 libav의 일반적인 사용법은 아래 패턴/아키텍처 또는 워크플로우를 따릅니다:
- 프로토콜 레이어 -
input
을 받음 (예를들면file
이지만rtmp
또는HTTP
입력도 가능). - 포맷 레이어 - 컨텐츠를
demuxes
, 대부분 메타데이터와 스트림을 열어봄 - 코덱 레이어 - 압축된 스트림 데이터를
decodes
optional - 픽셀 레이어 - raw 프레임에 대해 (리사이징 같은)
filters
를 적용할 수도 있음 optional - and then it does the reverse path
- 코덱 레이어 - raw 프레임을
encodes
(또는re-encodes
혹은transcodes
까지도) optional - 포맷 레이어 - raw 스트림 (압축된 데이터)를
muxes
(또는remuxes
) - 프로토콜 레이어 - 그리고 마지막으로 muxed된 데이터를
output
으로 전송 (또다른 파일 혹은 네트워크 원격 서버일 수도 있음)
이 그래프는 Leixiaohua's와 Slhck's의 작업으로부터 큰 영감을 받은 것입니다.
자 이제 ffmpeg input.mp4 -c copy output.ts
와 동일한 효과를 제공할 수 있도록 libav 를 이용한 예제를 하나 구현해봅시다.
입력 (input_format_context
)으로부터 읽은 것을 다른 출력 (output_format_context
)으로 변환해보겠습니다.
AVFormatContext *input_format_context = NULL;
AVFormatContext *output_format_context = NULL;
일반적으로 메모리 할당을 시작하고 입력 포맷을 엽니다. 이번같은 특정한 경우에는, 입력 파일을 열고나서 출력 파일을 위한 메모리를 할당하겠습니다.
if ((ret = avformat_open_input(&input_format_context, in_filename, NULL, NULL)) < 0) {
fprintf(stderr, "Could not open input file '%s'", in_filename);
goto end;
}
if ((ret = avformat_find_stream_info(input_format_context, NULL)) < 0) {
fprintf(stderr, "Failed to retrieve input stream information");
goto end;
}
avformat_alloc_output_context2(&output_format_context, NULL, NULL, out_filename);
if (!output_format_context) {
fprintf(stderr, "Could not create output context\n");
ret = AVERROR_UNKNOWN;
goto end;
}
비디오, 오디오, 자막 타입의 스트림만 remux할 것이며 사용하게될 스트림을 인덱스 배열에 들고 있겠습니다.
number_of_streams = input_format_context->nb_streams;
streams_list = av_mallocz_array(number_of_streams, sizeof(*streams_list));
필요한만큼의 메모리를 할당한 후, 모든 스트림에 대해 각각 루프를 돌면서 avformat_new_stream 함수를 통해 출력 포맷 컨텍스트에다가 새로운 출력 스트림을 생성해야합니다. 비디오, 오디오, 자막이 아닌 모든 스트림들에 대해서는 마킹을 해서 나중에 스킵할 수 있게 하겠습니다.
for (i = 0; i < input_format_context->nb_streams; i++) {
AVStream *out_stream;
AVStream *in_stream = input_format_context->streams[i];
AVCodecParameters *in_codecpar = in_stream->codecpar;
if (in_codecpar->codec_type != AVMEDIA_TYPE_AUDIO &&
in_codecpar->codec_type != AVMEDIA_TYPE_VIDEO &&
in_codecpar->codec_type != AVMEDIA_TYPE_SUBTITLE) {
streams_list[i] = -1;
continue;
}
streams_list[i] = stream_index++;
out_stream = avformat_new_stream(output_format_context, NULL);
if (!out_stream) {
fprintf(stderr, "Failed allocating output stream\n");
ret = AVERROR_UNKNOWN;
goto end;
}
ret = avcodec_parameters_copy(out_stream->codecpar, in_codecpar);
if (ret < 0) {
fprintf(stderr, "Failed to copy codec parameters\n");
goto end;
}
}
이제 출력 파일을 생성할 수 있습니다.
if (!(output_format_context->oformat->flags & AVFMT_NOFILE)) {
ret = avio_open(&output_format_context->pb, out_filename, AVIO_FLAG_WRITE);
if (ret < 0) {
fprintf(stderr, "Could not open output file '%s'", out_filename);
goto end;
}
}
ret = avformat_write_header(output_format_context, NULL);
if (ret < 0) {
fprintf(stderr, "Error occurred when opening output file\n");
goto end;
}
그런 후에, 입력 스트림에서 패킷을 하나씩 출력 스트림으로 복사하겠습니다. 패킷이 존재하는 동안 (av_read_frame
), 각 패킷에 대해 PTS와 DTS를 다시 계산하고 마지막으로 포맷 컨텍스트에 (av_interleaved_write_frame
) 씁니다.
while (1) {
AVStream *in_stream, *out_stream;
ret = av_read_frame(input_format_context, &packet);
if (ret < 0)
break;
in_stream = input_format_context->streams[packet.stream_index];
if (packet.stream_index >= number_of_streams || streams_list[packet.stream_index] < 0) {
av_packet_unref(&packet);
continue;
}
packet.stream_index = streams_list[packet.stream_index];
out_stream = output_format_context->streams[packet.stream_index];
/* copy packet */
packet.pts = av_rescale_q_rnd(packet.pts, in_stream->time_base, out_stream->time_base, AV_ROUND_NEAR_INF|AV_ROUND_PASS_MINMAX);
packet.dts = av_rescale_q_rnd(packet.dts, in_stream->time_base, out_stream->time_base, AV_ROUND_NEAR_INF|AV_ROUND_PASS_MINMAX);
packet.duration = av_rescale_q(packet.duration, in_stream->time_base, out_stream->time_base);
// https://ffmpeg.org/doxygen/trunk/structAVPacket.html#ab5793d8195cf4789dfb3913b7a693903
packet.pos = -1;
//https://ffmpeg.org/doxygen/trunk/group__lavf__encoding.html#ga37352ed2c63493c38219d935e71db6c1
ret = av_interleaved_write_frame(output_format_context, &packet);
if (ret < 0) {
fprintf(stderr, "Error muxing packet\n");
break;
}
av_packet_unref(&packet);
}
마무리를 위해 av_write_trailer 함수를 통해 스트림 트레일러(trailer)를 출력 미디어 파일에 씁니다.
av_write_trailer(output_format_context);
이제 테스트할 준비가 되었습니다. 첫번째 테스트는 MP4에서 MPEG-TS 비디오 파일로의 포맷 (비디오 컨테이너) 변환입니다. 우리는 기본적으로 ffmpeg input.mp4 -c copy output.ts
명령줄을 libav를 이용해 만든 것입니다.
make run_remuxing_ts
동작합니다!!! 절 믿지 않았나요?! 그러시면 안되죠, ffprobe
로 한번 확인해보겠습니다:
ffprobe -i remuxed_small_bunny_1080p_60fps.ts
Input #0, mpegts, from 'remuxed_small_bunny_1080p_60fps.ts':
Duration: 00:00:10.03, start: 0.000000, bitrate: 2751 kb/s
Program 1
Metadata:
service_name : Service01
service_provider: FFmpeg
Stream #0:0[0x100]: Video: h264 (High) ([27][0][0][0] / 0x001B), yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 60 fps, 60 tbr, 90k tbn, 120 tbc
Stream #0:1[0x101]: Audio: ac3 ([129][0][0][0] / 0x0081), 48000 Hz, 5.1(side), fltp, 320 kb/s
우리가 했던 것을 그래프로 정리하기 위해, 초반 libav의 동작 방식에 대한 아이디어를 다시 한번 살펴보면 코덱 부분만 건너뛴걸 볼 수 있습니다.
이 챕터를 끝내기 전에 리먹싱(remuxing) 프로세스의 중요한 부분을 보여드리고자 합니다, muxer에 옵션을 줄 수 있다는 것인데요. 만약에 전송을 MPEG-DASH 포맷으로 하고 싶다면 MPEG-TS나 기본 MPEG-4 대신 (fmp4
라고 부르는) fragmented mp4를 사용해야합니다.
ffmpeg -i non_fragmented.mp4 -movflags frag_keyframe+empty_moov+default_base_moof fragmented.mp4
libav 버전도 거의 명령줄 만큼이나 쉽습니다. 패킷을 복사하기 바로 전, 출력 헤더를 쓸때 해당 옵션을 넘겨주기만 하면 됩니다.
AVDictionary* opts = NULL;
av_dict_set(&opts, "movflags", "frag_keyframe+empty_moov+default_base_moof", 0);
ret = avformat_write_header(output_format_context, &opts);
이제 fragmented mp4 파일을 생성할 수 있습니다:
make run_remuxing_fragmented_mp4
제가 여러분께 거짓말하고 있지 않다는걸 보여드리죠. 결과물의 차이를 확인하기 위해 gpac/mp4box.js 혹은 http://mp4parser.com/ 같은 아주 훌륭한 사이트/툴을 이용할 수 있습니다. 일단 "common" mp4 파일을 로드해보세요.
보시다시피 단 하나의 mdat
박스(atom)가 있습니다, 여기에 비디오와 오디오 프레임이 담겨있습니다. 이번엔 fragmented mp4를 로드해서 mdat
박스가 어떻게 흩어져있는지 보시겠습니다.
TLDR; 코드랑 실행하는거나 보여주세요.
$ make run_transcoding좀 상세한 부분은 넘어가겠습니다, 그러나 걱정하진 마세요: 소스 코드는 github에 있습니다.
이번 챕터에서는 아주 간단한 트랜스코더를 만들어보겠습니다, C로 작성할 것이고, 이것으로 H264로 인코딩된 비디오를 H265로 변환할 수 있을겁니다. FFmpeg/libav 라이브러리, 특히 libavcodec, libavformat, libavutil를 이용하겠습니다.
빠르게 복습해보면: AVFormatContext는 컨테이너 (ex: MKV, MP4, Webm, TS) 같은 미디어 파일 포맷에 대한 추상화 구조체입니다. AVStream는 주어진 포맷 (ex: 오디오, 비디오, 자막, 메타데이터)에 대한 각 데이터 유형을 나타냅니다. AVPacket은
AVStream
으로부터 얻어진 압축된 데이터의 조각입니다. 그리고 이것은 AVCodec (ex: av1, h264, vp9, hevc)에 의해 디코딩되어 AVFrame라고 불리는 raw 데이터로 만들어집니다.
간단한 트랜스먹싱 작업을 시작해봅시다. 그리고나서 이 코드 기반으로 빌드할 수 있을겁니다. 첫번째 단계는 입력 파일 로드하기입니다.
// Allocate an AVFormatContext
avfc = avformat_alloc_context();
// Open an input stream and read the header.
avformat_open_input(avfc, in_filename, NULL, NULL);
// Read packets of a media file to get stream information.
avformat_find_stream_info(avfc, NULL);
이제 디코더를 설정할 것인데, AVFormatContext
가 모든 AVStream
컴포넌트에 접근할 수 있게 해줄 것입니다. 그리고 각각의 스트림에 대해서, AVCodec
을 가져와서 특정 AVCodecContext
를 생성합니다. 그리고 마지막으로 주어진 코덱을 열게되고 디코딩 프로세스를 수행할 수 있습니다.
AVCodecContext는 비트레이트, 프레임 속도, 샘플레이트, 채널, 높이 등과 같은 미디어 설정에 대한 데이터를 가지고 있습니다.
for (int i = 0; i < avfc->nb_streams; i++)
{
AVStream *avs = avfc->streams[i];
AVCodec *avc = avcodec_find_decoder(avs->codecpar->codec_id);
AVCodecContext *avcc = avcodec_alloc_context3(*avc);
avcodec_parameters_to_context(*avcc, avs->codecpar);
avcodec_open2(*avcc, *avc, NULL);
}
마찬가지로 트랜스먹싱에서도 출력 미디어 파일을 준비해둬야합니다, 우선 출력 AVFormatContext
에 대해 메모리를 할당합니다. 이 출력 포맷에 각 스트림을 생성합니다. 스트림을 제대로 적재시키기 위해 디코더로부터 코덱 파라미터를 복사합니다.
인코더가 글로벌 헤더를 사용할 수 있도록 지정하는 AV_CODEC_FLAG_GLOBAL_HEADER
플래그를 설정합니다. 그리고 출력으로 쓰기 위한 파일을 열고 헤더를 저장합니다.
avformat_alloc_output_context2(&encoder_avfc, NULL, NULL, out_filename);
AVStream *avs = avformat_new_stream(encoder_avfc, NULL);
avcodec_parameters_copy(avs->codecpar, decoder_avs->codecpar);
if (encoder_avfc->oformat->flags & AVFMT_GLOBALHEADER)
encoder_avfc->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
avio_open(&encoder_avfc->pb, encoder->filename, AVIO_FLAG_WRITE);
avformat_write_header(encoder->avfc, &muxer_opts);
디코더로부터 AVPacket
을 얻어서, 타임스탬프를 조정하고, 패킷을 출력 파일에 제대로 씁니다. av_interleaved_write_frame
이 함수 이름이 "write frame"라고 되어있긴 하지만 이것은 패킷을 저장합니다 . 이제 파일에 스트림 트레일러를 쓰면서 트랜스먹싱 프로세스를 마무리합니다.
AVFrame *input_frame = av_frame_alloc();
AVPacket *input_packet = av_packet_alloc();
while (av_read_frame(decoder_avfc, input_packet) >= 0)
{
av_packet_rescale_ts(input_packet, decoder_video_avs->time_base, encoder_video_avs->time_base);
av_interleaved_write_frame(*avfc, input_packet) < 0));
}
av_write_trailer(encoder_avfc);
이전 섹션에서 간단한 트랜스먹서 프로그램을 봤는데요, 이번엔 여기에 인코딩을 기능을 추가해보겠습니다. 특히, h264
에서 h265
로 비디오를 트랜스코딩할 수 있게 하겠습니다.
디코더를 준비한 후, 그리고 출력 미디어 파일을 다루기 전에 인코더를 설정할 것입니다.
- 인코더에 비디오
AVStream
를 생성합니다,avformat_new_stream
libx265
라고 하는AVCodec
을 사용합니다,avcodec_find_encoder_by_name
- 생성한 코덱을 기반으로
AVCodecContext
를 생성합니다,avcodec_alloc_context3
- 트랜스코딩 세션에 대해 기본적인 속성들을 설정합니다, 그리고
- 코덱을 열고 컨텍스트에서 스트림으로 파라미터를 복사합니다.
avcodec_open2
,avcodec_parameters_from_context
AVRational input_framerate = av_guess_frame_rate(decoder_avfc, decoder_video_avs, NULL);
AVStream *video_avs = avformat_new_stream(encoder_avfc, NULL);
char *codec_name = "libx265";
char *codec_priv_key = "x265-params";
// we're going to use internal options for the x265
// it disables the scene change detection and fix then
// GOP on 60 frames.
char *codec_priv_value = "keyint=60:min-keyint=60:scenecut=0";
AVCodec *video_avc = avcodec_find_encoder_by_name(codec_name);
AVCodecContext *video_avcc = avcodec_alloc_context3(video_avc);
// encoder codec params
av_opt_set(sc->video_avcc->priv_data, codec_priv_key, codec_priv_value, 0);
video_avcc->height = decoder_ctx->height;
video_avcc->width = decoder_ctx->width;
video_avcc->pix_fmt = video_avc->pix_fmts[0];
// control rate
video_avcc->bit_rate = 2 * 1000 * 1000;
video_avcc->rc_buffer_size = 4 * 1000 * 1000;
video_avcc->rc_max_rate = 2 * 1000 * 1000;
video_avcc->rc_min_rate = 2.5 * 1000 * 1000;
// time base
video_avcc->time_base = av_inv_q(input_framerate);
video_avs->time_base = sc->video_avcc->time_base;
avcodec_open2(sc->video_avcc, sc->video_avc, NULL);
avcodec_parameters_from_context(sc->video_avs->codecpar, sc->video_avcc);
비디오 스트림의 트랜스코딩을 위해 디코딩 루프를 확장해야합니다.
- 디코더에 빈
AVPacket
를 전송합니다,avcodec_send_packet
- 압축이 해제된
AVFrame
를 받아옵니다,avcodec_receive_frame
- 이 raw 프레임의 트랜스코딩을 시작합니다,
- raw 프레임을 (인코더에) 보내고,
avcodec_send_frame
- 코덱에 맞게 압축된
AVPacket
을 받아옵니다,avcodec_receive_packet
- 타임스탬프를 설정하고,
av_packet_rescale_ts
- 패킷을 출력 파일에 씁니다.
av_interleaved_write_frame
AVFrame *input_frame = av_frame_alloc();
AVPacket *input_packet = av_packet_alloc();
while (av_read_frame(decoder_avfc, input_packet) >= 0)
{
int response = avcodec_send_packet(decoder_video_avcc, input_packet);
while (response >= 0) {
response = avcodec_receive_frame(decoder_video_avcc, input_frame);
if (response == AVERROR(EAGAIN) || response == AVERROR_EOF) {
break;
} else if (response < 0) {
return response;
}
if (response >= 0) {
encode(encoder_avfc, decoder_video_avs, encoder_video_avs, decoder_video_avcc, input_packet->stream_index);
}
av_frame_unref(input_frame);
}
av_packet_unref(input_packet);
}
av_write_trailer(encoder_avfc);
// used function
int encode(AVFormatContext *avfc, AVStream *dec_video_avs, AVStream *enc_video_avs, AVCodecContext video_avcc int index) {
AVPacket *output_packet = av_packet_alloc();
int response = avcodec_send_frame(video_avcc, input_frame);
while (response >= 0) {
response = avcodec_receive_packet(video_avcc, output_packet);
if (response == AVERROR(EAGAIN) || response == AVERROR_EOF) {
break;
} else if (response < 0) {
return -1;
}
output_packet->stream_index = index;
output_packet->duration = enc_video_avs->time_base.den / enc_video_avs->time_base.num / dec_video_avs->avg_frame_rate.num * dec_video_avs->avg_frame_rate.den;
av_packet_rescale_ts(output_packet, dec_video_avs->time_base, enc_video_avs->time_base);
response = av_interleaved_write_frame(avfc, output_packet);
}
av_packet_unref(output_packet);
av_packet_free(&output_packet);
return 0;
}
아시다시피 h265
버전의 미디어 파일이 h264
보다 사이즈가 작기 때문에 미디어 스트림을 h264
에서 h265
로 변환했습니다. 하지만 작성한 프로그램은 다음의 작업들도 수행할 수 있습니다:
/*
* H264 -> H265
* Audio -> remuxed (untouched)
* MP4 - MP4
*/
StreamingParams sp = {0};
sp.copy_audio = 1;
sp.copy_video = 0;
sp.video_codec = "libx265";
sp.codec_priv_key = "x265-params";
sp.codec_priv_value = "keyint=60:min-keyint=60:scenecut=0";
/*
* H264 -> H264 (fixed gop)
* Audio -> remuxed (untouched)
* MP4 - MP4
*/
StreamingParams sp = {0};
sp.copy_audio = 1;
sp.copy_video = 0;
sp.video_codec = "libx264";
sp.codec_priv_key = "x264-params";
sp.codec_priv_value = "keyint=60:min-keyint=60:scenecut=0:force-cfr=1";
/*
* H264 -> H264 (fixed gop)
* Audio -> remuxed (untouched)
* MP4 - fragmented MP4
*/
StreamingParams sp = {0};
sp.copy_audio = 1;
sp.copy_video = 0;
sp.video_codec = "libx264";
sp.codec_priv_key = "x264-params";
sp.codec_priv_value = "keyint=60:min-keyint=60:scenecut=0:force-cfr=1";
sp.muxer_opt_key = "movflags";
sp.muxer_opt_value = "frag_keyframe+empty_moov+delay_moov+default_base_moof";
/*
* H264 -> H264 (fixed gop)
* Audio -> AAC
* MP4 - MPEG-TS
*/
StreamingParams sp = {0};
sp.copy_audio = 0;
sp.copy_video = 0;
sp.video_codec = "libx264";
sp.codec_priv_key = "x264-params";
sp.codec_priv_value = "keyint=60:min-keyint=60:scenecut=0:force-cfr=1";
sp.audio_codec = "aac";
sp.output_extension = ".ts";
/* WIP :P -> it's not playing on VLC, the final bit rate is huge
* H264 -> VP9
* Audio -> Vorbis
* MP4 - WebM
*/
//StreamingParams sp = {0};
//sp.copy_audio = 0;
//sp.copy_video = 0;
//sp.video_codec = "libvpx-vp9";
//sp.audio_codec = "libvorbis";
//sp.output_extension = ".webm";
이제서야 솔직히 말하자면, 제가 생각했던 것보다 더 삽질했는데요. FFmpeg 명령줄 소스코드를 파봐야했고 테스트도 엄청 돌려봤습니다. 그리고 제가 뭔가 놓치는게 있는 것 같은데요, 왜냐하면
force-cfr
을 강제로 넣어줘야지만h264
가 작용하고warning messages (forced frame type (5) at 80 was changed to frame type (3))
같은 경고 메시지도 여전히 나고 있기 때문이죠.