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

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

gamz 2023. 6. 6. 14:00

배경

CropMon 은 현재 일렉트론(Electron) 프레임워크가 제공하는 desktopCapturer 를 이용해서 스크린 레코딩을 하고 있다. 이는 다시 내부적으로는 Chromium 이 제공하는 화면 레코딩 API 을 이용하고 있다.

브라우저 환경에서 화상 회의 중에 화면 공유를 한다거나 하는 기능들이 모두 이 API 를 활용하고 있다. 다만, 이 API 가 제공하는 성능적인 제약이 있어서 일반적인 화상회의 유스케이스에는 크게 문제가 안되지만 고프레임레이트를 지원하기에는 다소 아쉬운 부분이 있다.

 

  30fps 의 벽 (60fps 레코딩 불가)

  음성의 경우 mono 만 지원

❌ 영역 Crop 디코딩 -> 비디오 요소 캔바스에 그리기 -> 다시인코딩 과정이 필요한데 비효율적

 

스크린 레코딩 어플리케이션의 기본 체력을 향상 시키기 위한 과정이 필수라는 생각이 들어서 크롭몬의 주요 유스케이스 지원하면서도 레코딩 엔진을 근본적으로 개선 가능한지 리서치를 해보기로 한다.

 

대안 조사

우선 Chromium 스크린 레코딩 API 를 우회할 수 있는 대안이 무엇이 있을까 몇가지 조사해봤다.

1. Mac에서는 Aperture 라는 MacOS의 avfoundation 을 직접 사용하는 괜찮은 라이브러리가 있으나 Windows의 경우는 따로 또 커버해야하기 때문에 유지보수 비용이 훨씬 커질 것 같다.

 

2. FFmpeg CLI 를 통해서도 아주 간단하게 레코딩이 가능하다. 하지만 별도의 프로세스를 띄워야하는데 OS 권한 이슈나(Mac 의 경우 스크린 레코딩의 권한이 필요) FFmpeg 프로세스와의 통신 등을 어떻게 할지, 혹은 추후 프레임 단위의 처리 등이 필요하면 어떤식으로 할 수 있을지 리서치가 필요할 것 같다.

 

3. FFmpeg 라이브러리를 직접 사용하는 방법도 있다. 아무래도 프레임 단위로 복잡하고 다양한 니즈를 만족시킬 수 있을 것 같지만 사용 난이도가 있다는 부분이 조금 문제다.

 

고민

1번 처럼 각 플랫폼의 API를 직접 활용하는 방식(라이브러리포함)은 좀 더 플랫폼의 특이 케이스를 다루기에 괜찮을 수도 있겠지만 일단 MacOS 에서의 Aperture 처럼 성숙함을 보여주는 라이브러리가 Windows 에서는 없어보이고 이 부분을 추상화하기 위한 노력과 각 라이브러리의 이슈에 따른 유지보수 비용이 클 것으로 예상된다.

 

반면, 적어도 FFmpeg 은 매우 성숙한 툴/라이브러리이며 대부분의 플랫폼(심지어 ARM 기반 모바일에서도 가능)에서 지원되기에 2, 3번이 가장 유력한 후보로 보인다. 다만 이것을 CLI로 사용하느냐 라이브러리로 사용하느냐 고민이다.

 

가족들과 휴가보내며 생각하던 중...

아마 2번으로 진행하는게 쉬울 것 처럼 보여서 갔다가 분명히 짜치는데서 삽질하고 있을 나의 모습이 떠올랐다. 가령,

😓 현재 진행 상태 등을 뽑아내기 위해 FFmpeg 프로세스의 stdout 메시지를 파싱하고 있거나

😓 크롭몬의 특징인 여러 디스플레이의 연결된 영역 캡쳐를 위해 커맨드 조합을 하고 있다거나

😓 추후 로드맵상의 기능들을 구현하는데 필요한 프레임 단위 처리 등을 하기 위해 다시 라이브러리 카드를 꺼내는 등

 

결국엔 돌고 돌아 3번으로 갈 것 같다는 예감이 들었다.

 

가장 빠르게 가는 유일한 방법은 제대로 가는 것이다.

 

사실 시작부터 3번으로 갔었어야했는데 내가 얼마나 돌아온 것인가. 언제가는 넘어야할 산이 눈앞에 다시 찾아왔고 이번만큼은 우회할 수 없을 것만 같다. 최악의 경우 해당 라이브러리의 사용 경험이라도 남아야지 수지 맞는 장사가 아니겠는가. 지금의 나에겐 어려운 길이지만 3번으로 제대로 간다.

 

계획

뭐부터 시작해야할까?

 

1. 마침 직접 번역에 참여한 오픈소스 튜토리얼이 있으니 이것부터 다시 복습

2. 화면 전체를 프레임 단위로 읽고 인코딩하여 저장하는 기본 helloworld 구현

3. 2번에서 이번엔 특정 영역을 Crop 하여 저장하는 버전 구현

4. 두 영상 소스로부터 특정 영역을 Crop 하여 한 프레임에 담아 인코딩 및 저장하는 버전 구현

5. 라이브러리 형태로 패키징하여 electron 앱에서 호출할 수 있도록 하여 CropMon 과 연동 (다른 포스트에 따로 다루기)

 

실행

1. FFmpeg 라이브러리 튜토리얼 학습

번역 후 한동안 안보다가 다시 봤는데 정말 좋은 튜토리얼임에 틀림없다. FFmpeg 의 기본 개념을 아주 잘 정리되어있고 특히, 예제 코드가 정말 큰 도움이 되었다. 일단 완료.

 

2. 헬로월드 - 가장 기본적인 스크린 레코딩 버전

하루에 조금씩 아주 감질나게 작업해서 일주일 정도 삽질했다. 몇가지 주요 내용을 공유해보자면,

 

🍗 avfoundation 입력의 경우 encoding 되어있지 않은 raw 프레임을 줌

🍗 컬러 포맷은 UYVY422로 옴 (인코더인 libx264가 입력으로 받는 YUV420P 가 아니라 컬러 변환 필요)

🍗 인코더(디코더도 마찬가지)에 send_xxx 하고 receive_xxx 하는 함수는 비동기라 EAGAIN 응답이 올 수 있음

🍗 인코딩된 후 AVPacket 에 PTS/DTS 타임스탬프 정보를 잘 넣어주는게 중요

🍗 ChatGPT 한테 물어보면서 하니 큰 도움이 되었지만 주는 코드 그대로 믿으면 안됨 (결국 기본 개념을 알고 흐름만 참고)

 

많이 단순화한 핵심 루프(loop)는 아래와 같고 흐름을 파악하는 정도로만 참고하자. 정리 안된 아주 날 것의 코드는 여기에 올려두었다. 

 

while (!allDone) {
  // 입력(화면 프레임) 읽어 들이기
  ret = av_read_frame(inputContext, inputPacket);

  // Raw 입력 데이터 디코더에 패킷을 보내서
  int ret = avcodec_send_packet(inputCodecContext, inputPacket);
  while (ret >= 0) {
    // Raw 프레임을 획득
    ret = avcodec_receive_frame(inputCodecContext, inputFrame);

    // 컬러 포맷 변환
    // inputFrame(UYVY422 -> YUV420P)

    // PTS 설정
    yuvFrame->pts = av_rescale_rnd(numFrames, inputVideoStream->time_base.den, fps, AVRounding(AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX));

    // h264 인코더에 프레임을 보내고
    ret = avcodec_send_frame(outCodecContext, yuvFrame);
    while (ret >= 0) {
      // 인코딩된 패킷을 받아옴
      ret = avcodec_receive_packet(outCodecContext, outputPacket);

      // 타임스탬프 정보 채우기
      av_packet_rescale_ts(outputPacket, inputVideoStream->time_base, outputVideoStream->time_base);

      // 인코딩된 패킷 파일로 저장
      av_interleaved_write_frame(outputContext, outputPacket);
    }
  }
}

 

빌드 후 실행하여 화면 레코딩이 잘 되는지 확인해보자. 아래 영상은 그렇게 뽑아낸 영상이다. 60fps이 나오니 VSCode 의 커서 애니메이션이 잘 보인다. (크롭몬으로는 못 보던 퀄리티.. ㅜㅠ)

 

3. 특정 영역 Crop 하여 저장하기

libswscale 라이브러리를 알았으니 이걸 쓰면 쉽게 Crop을 할 수 있겠거니 생각했다. 그런데 이상한 점이 함수 프로토타입을 보니 y 오프셋 관련 값은 보이는데 x 오프셋 값이 안보인다. 뭔가 이상하다.

 

struct SwsContext *sws_getContext(int srcW, int srcH, enum AVPixelFormat srcFormat,
                                  int dstW, int dstH, enum AVPixelFormat dstFormat,
                                  int flags, SwsFilter *srcFilter,
                                  SwsFilter *dstFilter, const double *param);
                                  
int sws_scale(struct SwsContext *c, const uint8_t *const srcSlice[],
              const int srcStride[], int srcSliceY, int srcSliceH,
              uint8_t *const dst[], const int dstStride[]);

 

좀 더 찾아보니 Crop 같은 오퍼레이션은 libavfilter 의 영역이었다. FFmpeg 가 제공하는 필터는 정말 방대한데 이 중에 Crop 필터를 사용하면 된다. 가령 커맨드라인으로 치면 이런식으로 가능하다. 필터에 대한 자세한 설명은 공식 문서가 좋은 참고다.

 

// crop={width}:{height}:{x}:{y}
$ ffmpeg -f avfoundation -i "2:1" -vf "crop=200:200:100:100" output.mkv

 

그런데 우리는 코드로 구현해야하는데 어떻게 해야할까.. 좋은 튜토리얼이 없나.. 라는 생각을 가진채로 디아블로4를 하는 중간중간 로딩 중에 ChatGPT 한테 물어보면서 얼추 느낌을 잡았다. 그러고는 일주일 정도 숙성 시킨 후 (숙성이라고 썼지만 귀찮음이었다) 다시 키보드를 잡았다. 본격적인 코딩을 위해 제대로된 레퍼런스를 찾아봤는데 구글링 및 커뮤니티에 있는 좋은 레퍼런스들이 많지만 각각 버전도 차이가 많이 나고 유스케이스도 중구난방이라 더 혼란스러웠다. 그렇게 헤매다 FFmpeg 에서 제공하는 샘플을 봤는데 이만한게 없더라.

 

필터 사용의 핵심은 (A) 필터 그래프 셋업과 (B) 프레임 입력/출력의 두 단계로 구성된다. 마찬가지로 날것의 코드는 여기에 있다.

 

우선 필터 그래프 셋업하는 부분이다. FFmpeg 이 제공하는 필터 정보를 가지고 와서 원하는 파라미터를 통해 필터 인스턴스(컨텍스트)를 생성한 후 이들을 연결하여 그래프로 설정한다.

 

// FFmpeg 필터 시스템은 여러개의 필터들(Pads)을 그래프처럼 연결해서 복잡한 프로세싱도 가능함 👍
// 그 필터들의 연결 정보들을 관리하는 자료구조
AVFilterGraph *filterGraph = avfilter_graph_alloc();

// FFmpeg이 제공하는 필터를 불러옴, 이건 필터 자체에 대한 정보를 담은 자료구조 (마치 class)
// 필터그래프도 data stream process 개념이다보니 버퍼소스(src) / 버퍼싱크(sink)가 있음
const AVFilter *bufferSrcFilter = avfilter_get_by_name("buffer");
const AVFilter *cropFilter = avfilter_get_by_name("crop");
const AVFilter *bufferSinkFilter = avfilter_get_by_name("buffersink");

// 필터마다 정의된 파라미터를 넘기면서 생성한 필터 인스턴스
AVFilterContext *cropCtx;
AVFilterContext *bufferSinkCtx;
AVFilterContext *bufferSrcCtx;

// 버퍼소스(src) 필터의 파라미터를 정의하고 필터 인스턴스(ctx)를 생성
char filterArgs[512];
snprintf(filterArgs, sizeof(filterArgs),
    "video_size=%dx%d:pix_fmt=%d:time_base=%d/%d:pixel_aspect=%d/%d",
    inputCodecContext->width,
    inputCodecContext->height,
    outCodecContext->pix_fmt,
    outCodecContext->time_base.num,
    outCodecContext->time_base.den,
    outCodecContext->sample_aspect_ratio.num,
    outCodecContext->sample_aspect_ratio.den);

ret = avfilter_graph_create_filter(&bufferSrcCtx, bufferSrcFilter, "in", filterArgs, nullptr, filterGraph);
if (ret < 0) {
    return ret;
}

// 크롭 필터의 파라미터를 정의하고 필터 인스턴스(ctx)를 생성
snprintf(filterArgs, sizeof(filterArgs),
    "%d:%d:%d:%d",
    cropWidth,
    cropHeight,
    cropX,
    cropY);

ret = avfilter_graph_create_filter(&cropCtx, cropFilter, "crop", filterArgs, nullptr, filterGraph);
if (ret < 0) {
    return ret;
}

// 버퍼싱크(sink) 필터 인스턴스(ctx)를 생성
ret = avfilter_graph_create_filter(&bufferSinkCtx, bufferSinkFilter, "out", nullptr, nullptr, filterGraph);
if (ret < 0) {
    return ret;
}

// 버퍼소스(src) -> 크롭 필터 -> 버퍼싱크(sink)로 필터를 연결
ret = avfilter_link(bufferSrcCtx, 0, cropCtx, 0);
if (ret < 0) {
    return ret;
}

ret = avfilter_link(cropCtx, 0, bufferSinkCtx, 0);
if (ret < 0) {
    return ret;
}

/// 필터그래프 설정에 이상이 없는지 검증
ret = avfilter_graph_config(filterGraph, nullptr);
if (ret < 0) {
    return ret;
}

 

그럼 이제 이렇게 셋업한 필터 그래프에서 버퍼소스(src)에 프레임(AVFrame)을 입력하고 버퍼(sink)로부터 필터링된 프레임을 받아오는 핵심 루프를 만들자.

 

AVFrame *filteredFrame = av_frame_alloc();

while (1) {
	....
        sws_scale(swsContext,
            inputFrame->data,
            inputFrame->linesize,
            0,
            inputFrame->height,
            yuvFrame->data,
            yuvFrame->linesize
        );

        // 원래는 인코더로 바로 넘어가는 프레임인데 크롭 필터 그래프를 태움, 버퍼소스(src)에 입력
        int ret = av_buffersrc_add_frame(bufferSrcCtx, yuvFrame);
        while (ret >= 0) {
            // 필터 그래프를 타고 처리된 프레임을 버퍼싱크(sink)를 통해 받아옴
            ret = av_buffersink_get_frame(bufferSinkCtx, filteredFrame);
            if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
                break;
            }

            ret = avcodec_send_frame(outCodecContext, filteredFrame);

			....
        }
    ....
    
    av_frame_unref(filteredFrame);    
}

 

테스트로 x=100, y=100, w=900, h=400 의 사이즈로 Crop 해봤는데 잘 되었다.

 

 

4. 두 영상의 프레임을 하나로 합치기

이 부분은 크몬의 주요 유스케이스이다. 여러 스크린에 걸쳐있는 영역을 캡쳐할때 필요한데 이것들도 FFmpeg 에서 제공하는 필터들을 어떻게 잘 비벼서 그래프로 만들어 처리하면 될 것 같은 느낌이다. 한번 플로우를 생각해보자.

(1) 스크린별로 여러 영상 소스를 받게 될텐데 두개라고 가정하고 각각 a, b 라고 하자
(2) 영상 스트림 a, b 각각을 crop 필터를 태워서 특정 영역을 자르자
(3) 영상 스트림 중 하나를(a) 메인으로 잡고 다른 영상(b)을 오버레이할만큼의 패딩 영역을 추가한다
(4) 패딩이 추가된 영상 스트림(a)에 다른 영상(b)을 오버레이한다. 

 

이걸 바로 코딩하기는 전혀 감이 안오니 일단 CLI 로 시도해보자. 

 

$ ffmpeg \
    -f avfoundation -i "2:" \
    -f avfoundation -i "3:" \
    -filter_complex " \
    [0] crop=400:500:100:100, pad=800:500 [main]; \
    [1] crop=400:500:100:100 [v1]; \
    [main][v1] overlay=400:0" \
    output.mkv

 

위처럼 아주 간단하게 가능한데 한번 풀어서 설명해보겠다. 입력을 두개를 받을건데 둘다 avfoundation (MacOS) 이고 각각 디바이스 아이디가 2, 3 (스크린 1, 2) 이다. 이 입력 스트림을 filter_complex 를 통해 정의한 필터그래프에 태울 것이고 최종 출력은 output.mkv 로 한다.

 

필터그래프를 조금 더 살펴보자. 우선 각 입력 스트림은 순서대로 [0], [1] 로 인덱스가 부여되는데 [0] 에 대해서는 crop 후에 pad 를 태우고 main 이라는 이름(alias)로 내보낸다. [1] 스트림에 대해서는 crop 만 진행한 후 [v1] 이라는 이름(alias)로 내보낸다. 이렇게 내보내진 스트림은 overlay 를 통해서 main 에 v1 가 overlay 되는 식으로 그려지게 된다.

 

자 그럼 이를 코드로 적용하려면 어떻게 해야할까? 우리가 3번에서 작업했던 코드의 핵심 흐름은 input frame -> buffer source -> filters.. -> buffer sink -> output frame 인데 입력이 여러개가 될 수 있으므로 input frame -> buffer source 들이 각각 추가되는 방식으로 생각해볼 수 있겠다. 이를 좀 더 도식화해보자.

 

 

그리고 이 필터 그래프를 설정하는 코드는 대충 아래와 같다. 특이사항은 avfilter_link 의 두번째, 네번째 인자가 padnum 이라는 건데, FFmpeg 필터에서 padnum 이라고 하면 필터의 입출력 단자(소켓)의 인덱스라고 보면 된다. Overlay 필터 같은 경우는 두개의 입력 단자가 있기 때문에 Pad-> Overlay, Crop -> Overlay 연결해줄때 Overlay 의 입력 단자를 잘 지정해줄 필요가 있다.

 

AVFilterContext *bufferSrc1Ctx;
AVFilterContext *bufferSrc2Ctx;
AVFilterContext *crop1Ctx;
AVFilterContext *crop2Ctx;
AVFilterContext *padCtx;
AVFilterContext *overlayCtx;
AVFilterContext *bufferSinkCtx;

const AVFilter *bufferSrcFilter = avfilter_get_by_name("buffer");
const AVFilter *cropFilter = avfilter_get_by_name("crop");
const AVFilter *padFilter = avfilter_get_by_name("pad");
const AVFilter *overlayFilter = avfilter_get_by_name("overlay");
const AVFilter *bufferSinkFilter = avfilter_get_by_name("buffersink");

avfilter_graph_create_filter(&bufferSrc1Ctx, bufferSrcFilter, "in1", filterArgs, nullptr, filterGraph);
avfilter_graph_create_filter(&bufferSrc2Ctx, bufferSrcFilter, "in2", filterArgs, nullptr, filterGraph);
avfilter_graph_create_filter(&crop1Ctx, cropFilter, "crop1", filterArgs, nullptr, filterGraph);
avfilter_graph_create_filter(&crop2Ctx, cropFilter, "crop2", filterArgs, nullptr, filterGraph);
avfilter_graph_create_filter(&padCtx, padFilter, "pad", filterArgs, nullptr, filterGraph);
avfilter_graph_create_filter(&overlayCtx, overlayFilter, "overlay", filterArgs, nullptr, filterGraph);
avfilter_graph_create_filter(&bufferSinkCtx, bufferSinkFilter, "out", nullptr, nullptr, filterGraph);

avfilter_link(bufferSrc1Ctx, 0, crop1Ctx, 0);
avfilter_link(crop1Ctx, 0, padCtx, 0);
avfilter_link(padCtx, 0, overlayCtx, 0);
avfilter_link(bufferSrc2Ctx, 0, crop2Ctx, 0);
avfilter_link(crop2Ctx, 0, overlayCtx, 1);
avfilter_link(overlayCtx, 0, bufferSinkCtx, 0);
avfilter_graph_config(filterGraph, nullptr);

 

마지막으로 메인 루프를 살펴보자. 이제는 다뤄야할 인풋이 여럿이 되다보니 기존처럼 계속 중첩되는 방식으로 접근하면 indentation drilling 이 너무 심해진다. 이 부분을 고민하다보니 좋은 패턴을 하나 발견했다. 루프에서는 보통 send 와 receive 를 하게 되는데 기존에는 send 후 바로 receive 하면서 응답으로 주로 오는 EAGAIN 에 대한 부분을 계속 중첩해서 하고 있었다. 그런데 send 에서 한번 끊어주면 뭔가 코드가 심플하게 정리되더라.

 

while (true) {
    if (av_read_frame(input1Context, input1Packet) == 0) {
        avcodec_send_packet(input1CodecContext, input1Packet);
    }

    if (av_read_frame(input2Context, input2Packet) == 0) {
        avcodec_send_packet(input2CodecContext, input2Packet);
    }

    if (avcodec_receive_frame(input1CodecContext, input1Frame) == 0) {
        av_frame_get_buffer(yuv1Frame, 0);
        av_buffersrc_add_frame(bufferSrc1Ctx, yuv1Frame);
    }

    if (avcodec_receive_frame(input2CodecContext, input2Frame) == 0) {
        av_frame_get_buffer(yuv2Frame, 0);
        av_buffersrc_add_frame(bufferSrc2Ctx, yuv2Frame);
    }

    if (av_buffersink_get_frame(bufferSinkCtx, filteredFrame) == 0) {
        avcodec_send_frame(outCodecContext, filteredFrame);
    }

    if (avcodec_receive_packet(outCodecContext, outputPacket) == 0) {
        av_interleaved_write_frame(outputContext, outputPacket);
    }
}

 

역시나 다듬어지지 않은 온전한 코드는 여기에서 확인할 수 있다. 이렇게 구현한 버전으로 각 스크린의 (100, 0) 에서 500x800 으로 크롭한 영상을 이어붙여 캡쳐한 영상을 올리면서 포스팅을 마무리 한다.