만들리에/CropMon (스크린레코더)

CropMon 성능 개선 작업 PoC (2/2) - FFmpeg 리서치

gamz 2023. 7. 16. 01:23

이어서 계속

CropMon 성능 개선 작업 PoC (1/2) - FFmpeg 리서치에서는 여러 비디오 소스로부터 이를 Crop하고 Merge하는 실험을 해봤다. 이에 이어서 오디오 부분을 다뤄보고자 한다.

사실 마치 계획대로 잘 된 것 처럼 정리를 시도하고 있지만 엄청 많은 삽질과 고통속에서 몸부림쳤다. 개념도 없고 레퍼런스도 잘 못 찾는 상황에서 코드 한줄 못 나간채로 바닥에서 잠들기도 하고 시작피로를 못 넘어 며칠을 그냥 흘려보내기도 했다. 몇주간 마음의 부채를 안고 사는 지옥같은 시간에 포기의 유혹도 많았었다. 어쨌든 또 한번 작은 고개를 넘을 수 있어서 너무 개운하고 아직도 멀었지만 포스트를 남길 수 있게 되어 감사하다.

 

목표

두 비디오 소스의 병합(merge)에 더해 두 오디오 소스도 병합하는게 목표다.

 

실행

1. FFmpeg CLI 를 통해 오디오도 함께 녹음하기

기존 비디오에 대한 필터만 있던 커맨드에서 오디오 부분에 대한 필터 그래프를 추가했다.

ffmpeg -y \
    -f avfoundation -framerate 60 -i "0:0" \
    -f avfoundation -framerate 60 -i "1:1" \
    -filter_complex " \
    [0:v] crop=800:500:400:400, pad=1600:500 [main]; \
    [1:v] crop=800:500:400:400 [v1]; \
    [main][v1] overlay=800:0, framerate=60, setpts=N/FR/TB [v]; \
    [0:a][1:a] amerge=inputs=2, pan=stereo|FL=c0+c2|FR=c1+c2, asetpts=N/SR/TB [a]; " \
    -map "[v]" \
    -map "[a]" \
    output.mp4

추가된 라인은 딱 하나다.

[0:a][1:a] amerge=inputs=2, pan=stereo|FL=c0+c2|FR=c1+c2, asetpts=N/SR/TB [a];
  • [0:a][1:a] - 첫번째(0)와 두번째(1) 입력의 오디오 스트림을 가져와서,
  • amerge=inputs=2 - amerge 필터에 입력으로 넣는다. amerge 에는 2개의 입력을 받는다고 설정해줌.
  • pan=stereo|FL=c0+c2|FR=c1+c2 - pan 필터를 이용해 오디오 스트림의 채널을 믹스한다.
  • asetpts=N/SR/TB - asetpts 필터를 이용해 오디오 프레임의 PTS를 설정한다. (이 부분은 제대로 성공 못 함)
⭐️ 오디오 채널 믹스(pan)에 대하여?

오디오 스트림에는 채널이 존재하는데 모노는 1개의 채널, 스테레오는 2개(FL, FR) 채널이 존재한다. 각각의 채널은 채널 레이아웃에 따라 별칭이 존재하기도 하며 순서대로 인덱스(c0, c1, c2, ...)로 접근하기도 한다.

내가 위에서 pan의 인자로 설정한 stereo|FL=c0+c2|FR=c1+c2 는 즉, 출력 오디오 스트림의 채널 레이아웃은 stereo가 될건데 첫번째 스트림의 모노채널 게인(gain)과 두번째 스트림의 스테레오 왼쪽채널(FL) 게인을 합쳐서 출력의 FL로, 첫번째 스트림의 모노채널 게인과 두번째 스트림의 스테레오 오른쪽채널(FR) 게인을 합쳐서 출력의 FR로 할당한다는 의미이다.

(더 자세한 내용은 Audio Channel Manipulation Wiki를 참조)

 

 

이렇게 하여 출력된 아웃풋 영상이다. 그런데 비디오와 오디오 싱크(Sync)도 틀어지고 오디오도 뭔가 끊기면서 빨리 재생된다. 이 부분에 대해서 많은 삽질을 했지만 아직 풀지는 못 했다. 혹시 아시는 분은 제발 저에게 가르침을 주십쇼 🙇‍♂️

2. FFmpeg 라이브러리 이용하여 작성

이제 본격적으로 라이브러리를 이용해 이를 구현해보도록 하겠다. 지금까지는 코드를 아주 안구조적으로 짜왔는데 이러다보니 계속 추가되는 포맷컨텍스트, 코덱컨텍스트, 필터그래프, 포맷변환컨텍스트 등으로 점점 변수 관리가 복잡해져서 스스로 코드를 따라갈 수 없는 상태가 되었다. 그래서 아주 자잘한 리팩토링부터 하고 시작하기로 했다.

 

자잘한 리팩토링

우선 굵직하게 입력 2개 + 출력 1개라고 했을때 각각 비디오 & 오디오 스트림 포함하고 각 스트림에 대해서는 디코딩/변환/인코딩이 필요하며 이에 필요한 변수들이 또 여럿이라 이것들을 묶을 단위가 필요하다고 봤고 MediaContext 라는 구조체를 하나 추가했다.

typedef struct MediaContext {
  char filename[64];
  AVFormatContext *formatCtx;

  int videoIndex;
  AVCodec* videoCodec;
  AVStream* videoStream;
  AVCodecContext* videoCodecCtx;
  AVFilterContext *videoBufferFilterCtx;
  SwsContext* swsCtx;

  int audioIndex;
  AVCodec* audioCodec;
  AVStream* audioStream;
  AVCodecContext* audioCodecCtx;
  AVFilterContext *audioBufferFilterCtx;
  SwrContext* swrCtx;
  AVAudioFifo* audioFifo;
} MediaContext;

그리고 main 에 늘어놨던 기능 블럭들을 함수로 분리하여 조금더 main 함수를 짧게 만들었다. loop 들도 더 구조할 수 있겠지만 거기까지 가진 않았다.

int main() {
    std::signal(SIGINT, signalHandler);

    avdevice_register_all();
    
    ...
    MediaContext* outputCtx = openOutputMediaCtx(outputFilename, &videoParams, &audioParams);
    MediaContext* input1Ctx = openInputMediaCtx(0, 0, outputCtx);
    MediaContext* input2Ctx = openInputMediaCtx(2, 2, outputCtx);

    ...
    createFilterGraphForVideo(input1Ctx, input2Ctx, outputCtx, cropX, cropY, cropWidth, cropHeight);
    createFilterGraphForAudio(input1Ctx, input2Ctx, outputCtx);

    ...
    if (avformat_write_header(outputCtx->formatCtx, nullptr) < 0) {
        return -1;
    }

    while (!allDone) {
        if (av_read_frame(input1Ctx->formatCtx, input1Packet) == 0) {
        ...
        ...
    }
 }

 

오디오 필터그래프

비디오 필터 그래프처럼 오디오 필터 그래프도 비슷한 방식으로 구성한다. 필터 그래프 문법으로된 문자열로부터 avfilter_graph_create_filter 함수를 활용하여 필터컨텍스트를 생성하고 avfilter_link 를 통해 이들을 연결한다.

AVFilterGraph* createFilterGraphForAudio(MediaContext* input1Ctx, MediaContext* input2Ctx, MediaContext* outputCtx) {
    AVFilterGraph *filterGraph = avfilter_graph_alloc();

    AVFilterContext *mergeCtx;
    AVFilterContext *panCtx;

    const AVFilter *bufferSrcFilter = avfilter_get_by_name("abuffer");
    const AVFilter *mergeFilter = avfilter_get_by_name("amerge");
    const AVFilter *panFilter = avfilter_get_by_name("pan");
    const AVFilter *bufferSinkFilter = avfilter_get_by_name("abuffersink");

    char filterArgs[512];
    snprintf(filterArgs, sizeof(filterArgs),
        "time_base=%d/%d:sample_rate=%d:sample_fmt=%s:channel_layout=0x%"PRIx64,
        outputCtx->audioCodecCtx->time_base.num,
        outputCtx->audioCodecCtx->time_base.den,
        input1Ctx->audioCodecCtx->sample_rate,
        av_get_sample_fmt_name(input1Ctx->audioCodecCtx->sample_fmt),
        input1Ctx->audioCodecCtx->channel_layout);
    if (avfilter_graph_create_filter(&input1Ctx->audioBufferFilterCtx, bufferSrcFilter, "a-in1", filterArgs, nullptr, filterGraph) < 0) {
        return nullptr;
    }

    snprintf(filterArgs, sizeof(filterArgs),
        "time_base=%d/%d:sample_rate=%d:sample_fmt=%s:channel_layout=0x%"PRIx64,
        outputCtx->audioCodecCtx->time_base.num,
        outputCtx->audioCodecCtx->time_base.den,
        input2Ctx->audioCodecCtx->sample_rate,
        av_get_sample_fmt_name(input2Ctx->audioCodecCtx->sample_fmt),
        input2Ctx->audioCodecCtx->channel_layout);
    if (avfilter_graph_create_filter(&input2Ctx->audioBufferFilterCtx, bufferSrcFilter, "a-in2", filterArgs, nullptr, filterGraph) < 0) {
        return nullptr;
    }

    snprintf(filterArgs, sizeof(filterArgs), "inputs=%d", 2);
    if (avfilter_graph_create_filter(&mergeCtx, mergeFilter, "a-amerge", filterArgs, nullptr, filterGraph) < 0) {
        return nullptr;
    }

    snprintf(filterArgs, sizeof(filterArgs), "stereo|FL=c0+c2|FR=c1+c2");
    if (avfilter_graph_create_filter(&panCtx, panFilter, "a-pan", filterArgs, nullptr, filterGraph) < 0) {
        return nullptr;
    }

    if (avfilter_graph_create_filter(&outputCtx->audioBufferFilterCtx, bufferSinkFilter, "a-out", nullptr, nullptr, filterGraph) < 0) {
        return nullptr;
    }

    if (avfilter_link(input1Ctx->audioBufferFilterCtx, 0, mergeCtx, 0) < 0) {
        return nullptr;
    }

    if (avfilter_link(input2Ctx->audioBufferFilterCtx, 0, mergeCtx, 1) < 0) {
        return nullptr;
    }

    if (avfilter_link(mergeCtx, 0, panCtx, 0) < 0) {
        return nullptr;
    }

    if (avfilter_link(panCtx, 0, outputCtx->audioBufferFilterCtx, 0) < 0) {
        return nullptr;
    }

    if (avfilter_graph_config(filterGraph, nullptr) < 0) {
        return nullptr;
    }

    return filterGraph;
}

 

프레임 포맷 변환

돌이켜보면 너무나도 기본적인 것인데 가장 많은 삽질을 했던 부분이다. 일단 비디오 데이터든 오디오 데이터든 인코딩할때 인코더가 요구하는 포맷으로 넣어줘야한다. 그래서 비디오에서도 YUV420P 포맷으로 변환을 했었는데 오디오에 대해서는 그걸 간과했다.

 

오디오는 AAC 인코더를 사용했는데 이녀석의 두가지 주요한 요구사항이 있었다.

  • 샘플 포맷(AVSamepleFormat)은 AV_SAMPLE_FMT_FLT
  • 인코딩에 필요한 프레임사이즈(frame_size)가 1024라는 점 (이건 바꿀 수 있는 부분이지를 모르겠음)

비디오(이미지)의 포맷을 변환할때는 libswscale 이 제공하는 SwsContext 를 사용하지만 오디오의 경우에는 libresample 이 제공하는 SwrContext 를 써야한다. 그리고 오디오 샘플 데이터의 버퍼링 등을 위해서 AVAudioFifo를 사용한다.

   
 int convert_audio_frame(AVFrame* inputFrame, AVFrame* resampledFrame, MediaContext* inputCtx, MediaContext* outputCtx) {
    const AVSampleFormat outFormat = outputCtx->audioCodecCtx->sample_fmt;
    const AVChannelLayout* outChLayout = &outputCtx->audioCodecCtx->ch_layout;
    const int outFrameSize = outputCtx->audioCodecCtx->frame_size;  // AAC 코덱의 프레임 사이즈
    const int outSampleRate = outputCtx->audioCodecCtx->sample_rate;
    const int inputFrameSize = inputFrame->nb_samples;

    // 현재 Fifo에 쌓인 데이터가 있는지 확인, 데이터 사이즈가 출력 코덱(AAC)의 프레임 사이즈에 못 미치면
    int curFifoSize = av_audio_fifo_size(inputCtx->audioFifo);
    if (curFifoSize < outFrameSize) {
        // 오디오 샘플 데이터를 변환하여
        uint8_t** convertedSamples = (uint8_t**)calloc(outChLayout->nb_channels, sizeof(*convertedSamples));
        int ret = av_samples_alloc(convertedSamples, nullptr, outChLayout->nb_channels, inputFrameSize, outFormat, 0);
        swr_convert(inputCtx->swrCtx, convertedSamples, inputFrameSize, (const uint8_t**)inputFrame->extended_data, inputFrameSize);

        // 변환된 데이터를 fifo에 추가
        av_audio_fifo_realloc(inputCtx->audioFifo, curFifoSize + inputFrameSize);
        av_audio_fifo_write(inputCtx->audioFifo, (void**)convertedSamples, inputFrameSize);
    }

    // 만약 출력 코덱(AAC)이 요구하는 프레임 사이즈 이상의 데이터가 있다면
    curFifoSize = av_audio_fifo_size(inputCtx->audioFifo);
    if (curFifoSize >= outFrameSize) {
        // 프레임 기본 정보 설정
        const int readFrameSize = FFMIN(av_audio_fifo_size(inputCtx->audioFifo), outputCtx->audioCodecCtx->frame_size);
        resampledFrame->nb_samples = readFrameSize;
        resampledFrame->format = outFormat;
        resampledFrame->sample_rate = outSampleRate;
        av_channel_layout_copy(&resampledFrame->ch_layout, outChLayout);
        av_frame_get_buffer(resampledFrame, 0);

        // 데이터를 꺼내와서 반환
        av_audio_fifo_read(inputCtx->audioFifo, (void**)resampledFrame->data, readFrameSize);

        return readFrameSize;
    }

    return 0;
}
      
int main() {
    ...
    
    // SwrContext 인스턴스를 초기화 (입력의 채널/샘플포맷/샘플레이트를 출력 인코더에 맞게 설정)
    swr_alloc_set_opts2(&outputCtx->swrCtx,
        &outputCtx->audioCodecCtx->ch_layout,
        outputCtx->audioCodecCtx->sample_fmt,	// AV_SAMPLE_FMT_FLTP
        outputCtx->audioCodecCtx->sample_rate,
        &input1Ctx->audioCodecCtx->ch_layout,
        input1Ctx->audioCodecCtx->sample_fmt,	// AV_SAMPLE_FMT_FLT
        input1Ctx->audioCodecCtx->sample_rate,
        0,
        nullptr);

    swr_init(outputCtx->swrCtx);

    // 변환된 오디오 샘플 데이터를 버퍼링하기 위한 Fifo 인스턴스 생성
    outputCtx->audioFifo = av_audio_fifo_alloc(
        outputCtx->audioCodecCtx->sample_fmt,
        outputCtx->audioCodecCtx->ch_layout.nb_channels,
        1
    );

    ...
    while (!allDone) {
        ...   
        // 오디오 버퍼 싱크를 통해 필터링된 오디오 프레임을 받아와서
        if (av_buffersink_get_frame(outputCtx->audioBufferFilterCtx, filteredAudFrame) == 0) {
            // 출력 인코더의 샘플 스펙에 맞게 변환을 한 후
            int convertedSize = convert_audio_frame(filteredAudFrame, filteredResampledFrame, input1Ctx, outputCtx);
            if (convertedSize > 0) {
                // PTS를 설정하고 인코더에 전달
                filteredResampledFrame->pts = ...
                avcodec_send_frame(outputCtx->audioCodecCtx, filteredResampledFrame);
            }
        }
        ...
     }
     ...
 }

 

인코딩 & 저장

출력 코덱(AAC)이 요구하는대로 오디오 프레임을 변환했다면 PTS 를 적절히 설정하고 인코더에 전송한다. 인코딩이 다 됐다면 파일로 저장하고 계속 루프.

int main() {
    ...
    
    int64_t numAudSamples = 0;

    while (!allDone) {
        ...
        if (av_buffersink_get_frame(outputCtx->audioBufferFilterCtx, filteredAudFrame) == 0) {
            int convertedSize = convert_audio_frame(filteredAudFrame, filteredResampledFrame, input1Ctx, outputCtx);
            if (convertedSize > 0) {
                // PTS 계산시 출력 코덱의 타임베이스 및 샘플레이트를 고려하고 있는데 (직접 지정)
                // 대략의 컨셉으로 보면 #samples * timescale / samplerate 라고 볼 수 있음
                filteredResampledFrame->pts = av_rescale_q_rnd(
                    numAudSamples,
                    (AVRational){1, outputCtx->audioCodecCtx->sample_rate},
                    outputCtx->audioCodecCtx->time_base,
                    AVRounding(AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX)
                );
                numAudSamples += convertedSize;

                if (!shouldStop) {
                    avcodec_send_frame(outputCtx->audioCodecCtx, filteredResampledFrame);
                }
            }
        }

        if (avcodec_receive_packet(outputCtx->audioCodecCtx, outputAudPacket) == 0) {
            outputAudPacket->stream_index = outputCtx->audioIndex;
            
            // 그리고 파일로 패킷을 저장하기 전에 패킷의 PTS를 한번 rescale 하게 되는데
            // 이는 인코더의 타임베이스와 컨터이너의 타임베이스가 다를 수도 있기 때문이라고 함 (https://stackoverflow.com/questions/13606155/why-container-and-codec-has-different-time-base)
            // 오디오의 경우에는 둘다 1/48000(samplerate)로 설정했음
            av_packet_rescale_ts(outputAudPacket, outputCtx->audioCodecCtx->time_base, outputCtx->audioStream->time_base);
            av_interleaved_write_frame(outputCtx->formatCtx, outputAudPacket);
        }
        ...

    }
    ...
    
    return 0;
}

기존 실험과 마찬가지로 날것(raw)의 소스는 여기에 올려뒀다. 이렇게 컴파일 후 실행하여 다음과 같은 출력을 얻을 수 있었다. 하지만 (CLI 때보다는 덜한거 같지만) 여전히 끊기는 소리가 들리고, 비디오/오디오 싱크가 안맞는(비디오가 느린 것 같은 느낌) 이슈가 남아있다. 

 

후기

일단은 이것으로 ffmpeg 을 통해 크롭몬의 성능을 개선할 수 있는지에 대한 PoC 를 마치기로 한다.

 

이번 PoC를 통해,

  • 비디오 프레임 60fps 캡쳐 및 프레임(이미지) 단위로 프로세싱을 할 수 있다는 가능성을 엿볼 수 있었고
  • 무엇보다 늘 궁금했던 FFmpeg 라이브러리의 대략적인 사용법 및 개념에 대해서 알 수 있었던게 좋았다.
  • 하지만 비디오/오디오 싱크를 하는 부분에 이슈가 있고 리눅스/윈도우에서는 또 다른 이슈가 있을거라는 점
  • 확실히 러닝커브가 있으며 이렇게 삽질했는데도 여전히 걸음마 정도라는게 어렵고 아쉬운 점이었다.

PoC 초반에 "가장 빠르게 가는 유일한 방법은 제대로 가는 것이다" 라며 의기양양하게 시작했는데 제대로 뚜드러 맞은 기분이다. 결과적으로 아직 내가 제대로 갈 준비가 안되어 있는 것 같다. 사소한 삽질로 엄청난 시간과 노력과 정신적인 에너지를 쓰는 시간에 좀 더 사용자 경험에 집중하는게 맞지 않나라는게 현재 나의 위치임을 실감했다.

 

어쨌든 좋은 실패였다.

 

 

 

참고

ffmpeg tutorial (dranger.com)

video - Why container and codec has different time base? - Stack Overflow

FFmpeg: doc/examples/transcode_aac.c Source File

AudioChannelManipulation – FFmpeg

FFmpeg Filters Documentation