Media Foundation으로 MP4 동영상의 각 프레임에 이미지와 문자를 구워 넣는 방법 - Source Reader / 드로잉 / 색 변환 / Sink Writer 정리와 .cpp에 그대로 붙일 수 있는 1파일 완결판

· 업데이트: · · Media Foundation, C++, Windows 개발, GDI+, Direct2D, DirectWrite, H.264

수정 이력(1건, 최종 수정 2026년 09월 01일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635173)
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635172)

이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.

小村 豪 (2026). 「Media Foundation으로 MP4 동영상의 각 프레임에 이미지와 문자를 구워 넣는 방법 - Source Reader / 드로잉 / 색 변환 / Sink Writer 정리와 .cpp에 그대로 붙일 수 있는 1파일 완결판」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635172 https://comcomponent.com/ko/blog/2026/03/16/009-media-foundation-overlay-image-text-on-mp4-frames/

DOI(최신 버전)
10.5281/zenodo.21635172
DOI(이 버전)
10.5281/zenodo.22217455

로고 워터마크, 검사 결과, 장비 번호, 작업자 이름, 타임스탬프. 이런 정보를 MP4 동영상의 모든 프레임에 구워 넣은 새 MP4를 만들고 싶다 는 요건은 감시, 검사, 증거, 분석 UI에서 꽤 흔하게 있습니다.

다만 Media Foundation을 건드리기 시작하면 IMFSourceReader, IMFSample, IMFMediaBuffer, IMFTransform, IMFSinkWriter가 나란히 나와서 결국 어디서 문자나 PNG를 겹치면 되는지 가 갑자기 알기 어려워집니다.

이 글에서는 먼저 Source Reader -> 드로잉 -> 색 변환 -> Sink Writer 라는 전체상을 정리하고, 그 뒤에 Visual Studio의 C++ 콘솔 앱에 그대로 붙일 수 있는 1파일 완결 샘플 을 싣습니다. 샘플은 지정한 MP4를 읽고, 지정한 이미지와 HelloWorld를 각 프레임에 그려 넣어 출력 MP4를 만드는 구성입니다.

또한 이 샘플은 먼저 그대로 붙여서 돌리는 것 을 우선해 영상만 재인코딩하는 구성 으로 되어 있습니다. 음성 remux까지 1개에 다 넣을 수도 있지만, 이 글의 주제는 「각 프레임에 이미지와 문자를 구워 넣는 것」이므로 먼저 거기에 집중합니다.

이 샘플의 범위 좁히기이 글의 샘플은 먼저 그대로 붙여서 돌리는 것을 우선해 영상만 재인코딩하는 구성으로 되어 있으며 음성remux는 주제인 구워 넣기를 통과시킨 뒤의 확장으로 미룬다는 것을 보여주는 그림.먼저 붙여서 돌리는 것을 우선영상만 재인코딩각 프레임에 구워 넣는 것에 집중음성remux는 나중의 확장으로 미룸

그림 1: 첫 1개는 구워 넣기라는 주제만 통과하는 최소 구성으로 한다.

이 글에 등장하는 코드는 샘플 코드 일체(1파일 완결 .cpp와 CMake 빌드 구성)로 GitHub에 공개되어 있습니다.

media-foundation-overlay-image-text-on-mp4-frames - komurasoft-blog-samples (GitHub)

대상 독자와 필요 환경

C++로 Windows의 동영상 처리를 이제 막 시작하는 중급 개발자 를 대상으로 씁니다. COM의 기본(ComPtr, HRESULT, 참조 카운트)을 다뤄 본 적이 있고, Media Foundation은 이제부터라는 단계를 상정하고 있습니다.

샘플을 돌리는 데 필요한 환경은 다음과 같습니다. 자세한 내용과 입력 데이터 조건은 5장에 정리되어 있습니다.

항목 전제
OS Windows 10 / 11
개발 환경 Visual Studio 2022의 C++ 콘솔 앱
빌드 구성 x64
미리 컴파일된 헤더 .cpp에서는 사용하지 않는 설정으로 한다
입력 동영상 일반 MP4. 너비와 높이가 짝수 여야 함(NV12가 4:2:0이기 때문)
출력 영상만 있는 MP4. 음성은 붙지 않음

먼저 짚어둘 용어

3장의 표부터 설명 없는 영어가 나옵니다. 먼저 한 줄씩 정리해 둡니다.

용어 의미
remux 내부 압축 데이터는 그대로 두고 그릇(컨테이너)만 다시 만드는 것입니다. 재인코딩하지 않으므로 화질도 음질도 열화되지 않고 처리도 가볍게 끝납니다
topology Media Foundation이 「어느 부품에서 어느 부품으로 데이터를 흘려보낼지」를 나타내는 그래프입니다. 소스, 변환, 싱크를 이은 구성도에 해당합니다
custom MFT 직접 작성하는 Media Foundation Transform을 말합니다. IMFTransform을 구현하면 이펙트를 Media Foundation 파이프라인에 부품으로 끼워 넣을 수 있게 됩니다
stride 이미지 1행분이 메모리상에서 차지하는 바이트 수입니다. 너비 × 4와 일치한다고는 할 수 없고, 행 끝에 여백이 들어가는 경우가 있습니다

1. 먼저 결론

  • MP4의 각 프레임에 이미지나 문자를 넣는 기본형은 Source Reader로 디코드 -> 비압축 프레임에 합성 -> 필요하면 색 변환 -> Sink Writer로 재인코딩 입니다.
  • 이미지나 문자를 놓는 처리 자체는 Media Foundation의 일이 아닙니다. 여기는 GDI+, Direct2D, DirectWrite, WIC 같은 드로잉 API로 생각하는 편이 자연스럽습니다.
  • MP4(H.264)로 되돌리려면 그리기 편한 RGB32 / ARGB32와 인코더가 받기 쉬운 NV12 / I420 / YUY2 사이를 잇는 변환 단이 필요해지기 쉽습니다.
  • 첫 1개를 움직이고 싶다Source Reader -> RGB32 -> GDI+로 드로잉 -> NV12 -> Sink Writer라는 구성이 명료합니다.
  • 속도나 확장성을 우선한다D3D11 / DXGI surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer 쪽으로 가면 확장 여지가 있습니다.

이 글의 지식 맵

이 글은 Media Foundation으로 MP4의 각 프레임에 이미지와 문자를 구워 넣어 새로운 MP4를 만드는 구성을 정리합니다. 처리는 IMFSourceReader로 RGB32 프레임을 디코드하고, 1파일 완결 샘플에서는 GDI+로 이미지와 텍스트를 합성하며, H.264 인코더가 NV12 같은 YUV 계열 입력을 전제로 하는 경우가 많기 때문에 BGRA에서 NV12로 변환한 뒤, IMFSinkWriter가 H.264로 MP4에 써 넣습니다. stride와 상하 방향의 차이는 IMF2DBuffer::Lock2D로 흡수해 top-down의 BGRA로 정규화하며, ReadSample은 HRESULT·flags·sample 세 가지를 확인할 필요가 있습니다. 실제 운영에서는 색 변환을 Video Processor MFT로 옮기거나 드로잉을 Direct2D/DirectWrite로 대체하거나 재사용성이 필요하면 custom MFT로 분리하는 것과 같은 확장이 단계적으로 제시되며, 음성 remux는 영상의 구워 넣기를 먼저 안정시킨 뒤에 추가해야 할 절차로 자리매김되어 있습니다.

MP4에 이미지·문자 구워 넣기(Media Foundation)IMFSourceReader에서의 RGB32 디코드와 GDI+에 의한 합성, RGB 계열과 NV12의 변환이 H.264 인코더로의 입력으로 필요해진다는 것, IMFSinkWriter에 의한 MP4 출력, stride와 top-down/bottom-up의 흡수, Direct2D/DirectWrite나 Video Processor MFT, custom MFT로의 확장, 음성 remux를 후단에 두는 진행 방식까지의 관계를 보여주는 그림.이용한다이용한다권장되는 대응전제로 한다이용한다전제로 한다이용한다양립하지 않는다전제로 한다전제로 한다이용한다권장되는 대응전제로 한다이용한다전제로 한다이용한다원인이 될 수 있다보다 먼저 해야 한다권장되는 대응전제로 한다동영상 프레임에 이미지·텍스트 합성IMFSinkWriter(Sink Writer)IMFSourceReader(Source Reader)GDI+Direct2D / DirectWritestride(이미지 행 바이트 수)IMF2DBuffer::Lock2Dtop-down / bottom-up(이미지 상하 방향)MFVideoFormat_RGB32H.264 Video Encoder(Media Foundation)NV12 픽셀 형식BGRA에서 NV12로의 색 변환Video Processor MFTIMFSourceReader::ReadSampleReadSample의 null 샘플오디오 리먹스custom MFTMedia Foundation

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 20건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

2. 이 문제가 조금 복잡한 이유

「동영상에 문자를 넣는다」는 실제로는 다음 4가지 이야기가 섞여 있습니다.

  1. 컨테이너와 코덱 이야기 mp4는 컨테이너이지 프레임 그 자체가 아닙니다. 내용물은 대개 H.264H.265의 압축 데이터입니다.

  2. 디코드 / 인코드 이야기 압축된 상태로는 일반적인 2D 드로잉 API로 문자나 PNG를 그대로 얹을 수 없습니다. 먼저 비압축 프레임으로 되돌릴 필요가 있습니다.

  3. 드로잉 이야기 문자, 로고, PNG의 투명 합성, 안티앨리어싱이 들어간 텍스트 드로잉은 Media Foundation 본체의 역할이 아닙니다. 여기는 GDI+Direct2D / DirectWrite / WIC의 일입니다.

  4. 색공간과 픽셀 형식 이야기 그리기 편한 형식과 인코더가 선호하는 형식은 일치하지 않습니다. 여기가 은근히 막히기 쉬운 지점입니다.

거칠게 한 줄로 말하면 「Media Foundation으로 문자를 넣는다」가 아니라 「Media Foundation으로 프레임을 돌리고, 드로잉 API로 얹은 뒤, 필요한 색 변환을 넣고 나서 인코딩한다」 고 생각하는 것이 가장 정리하기 쉽습니다.

섞여 있는 4가지 이야기동영상에 문자를 넣는다는 요건에는 컨테이너와 코덱, 디코드와 인코드, 드로잉, 색공간과 픽셀 형식이라는 4가지 이야기가 섞여 있음을 보여주는 그림.동영상에 문자를 넣는다컨테이너와 코덱 이야기디코드와 인코드 이야기드로잉 이야기색공간과 픽셀 형식 이야기

그림 2: 막히면 지금 어느 이야기 안에 있는지부터 가려낸다.

3. 먼저 볼 정리표

방침 구성 적합한 장면 주의할 점
먼저 올바르게 돌린다 Source Reader -> RGB32 -> 합성 -> NV12 -> Sink Writer 배치 처리, 사내 도구, 초기 구현 CPU 쪽 복사나 변환이 늘어나기 쉽다
속도를 높인다 D3D11 / DXGI surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer 장시간 동영상, 고해상도, 대량 처리 D3D11과 DXGI 관리가 늘어난다
재사용 가능한 부품으로 만든다 custom MFT로 구현해 topology에 끼워 넣는다 여러 앱에서 쓰는 이펙트, MF 파이프라인에 조립하고 싶은 경우 구현, 등록, 디버깅 난도가 오른다

이 글의 샘플은 맨 위의 「먼저 올바르게 돌린다」 구성 으로 한정합니다.

3.1 처리 이미지

input.mp4IMFSourceReader비압축 프레임RGB32GDI+로 이미지 + HelloWorld 드로잉BGRA -> NV12 변환IMFSinkWriteroutput.mp4음성 샘플그대로 복사또는 재인코딩

그림 3: Source Reader로 꺼내고, GDI+로 그린 뒤, NV12로 변환해 Sink Writer로 다시 써 넣는다.

여기서 중요한 것은 드로잉 자체는 Media Foundation의 일이 아니라는 점입니다. Media Foundation은 프레임을 넣고 빼는 담당이고, 이미지와 문자를 얹는 일은 드로잉 API에 맡깁니다.

4. 파이프라인을 어떻게 나눠 생각할까

4.1 입력은 IMFSourceReader로 받는다

입력이 파일 경로라면 MFCreateSourceReaderFromURL, 메모리상의 동영상 데이터라면 IMFByteStream을 만들어 MFCreateSourceReaderFromByteStream을 쓰는 구성이 명료합니다.

여기서 가장 먼저 정해야 하는 것은 그리기 편한 형식으로 받을지, 인코더용 형식으로 받을지 입니다.

  • 구현을 간단히 하고 싶다면 RGB32ARGB32
  • 인코드 효율을 우선한다면 NV12 같은 YUV

다만 문자나 PNG의 합성은 RGB 계열이 압도적으로 다루기 쉬우므로 첫수는 RGB32 / ARGB32로 받아 두는 편이 편합니다.

어떤 형식으로 받을지의 첫수구현을 간단히 하고 싶다면 RGB32나 ARGB32, 인코드 효율을 우선한다면 NV12 같은 YUV지만 문자나 PNG의 합성은 RGB 계열이 다루기 쉬우므로 첫수는 RGB 계열로 받는 편이 편함을 보여주는 그림.구현의 간단함인코드 효율무엇을 우선할까RGB32 / ARGB32로 받는다NV12 등 YUV로 받는다합성은 RGB 계열이 다루기 쉽다

그림 4: 망설여지면 그리기 쉬움을 우선해 RGB 계열로 받기 시작한다.

MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING을 활성화하면 Source ReaderYUV -> RGB32 변환과 인터레이스 해제를 해 줍니다. 이는 「먼저 프레임을 꺼내서 다뤄 보고 싶다」는 단계에서는 편리하지만, 긴 동영상이나 고해상도 동영상에서는 무거워지기 쉬우므로 실제 운영에서 속도가 필요하다면 나중에 구성을 재검토할 가치가 있습니다.

ENABLE_VIDEO_PROCESSING의 득실MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING을 활성화하면 Source Reader가 YUV에서 RGB32로의 변환과 인터레이스 해제를 해 주지만 긴 동영상이나 고해상도에서는 무거워지기 쉬워 실제 운영에서 속도가 필요하다면 구성을 재검토할 가치가 있음을 보여주는 그림.플래그를 활성화한다YUV에서 RGB32로의 변환을 맡긴다인터레이스 해제도 맡긴다장시간이나 고해상도에서는 무거워지기 쉽다

그림 5: 꺼내는 작업을 편하게 해 주는 편의 플래그에는 속도 면의 대가가 있다.

4.2 이미지나 문자의 합성은 GDI+Direct2D / DirectWrite로 생각한다

Media Foundation에서 받은 IMFSample에서 버퍼를 꺼내, 그 위에 로고 이미지나 텍스트를 얹습니다.

이번 샘플은 1파일 완결로 붙이기 쉬운 것 을 우선해 드로잉에 GDI+를 씁니다.

  • 이미지 로드가 가능하다
  • 문자 드로잉이 가능하다
  • 추가 준비가 비교적 적다
  • 콘솔 앱의 .cpp 1개에 담기 쉽다

한편 장시간 동영상이나 4K를 대량 처리하는 용도에서는 D3D11 + Direct2D + DirectWrite 쪽이 확장 여지가 있습니다. 처음 구현에서는 GDI+, 속도를 짜내는 단계에서 Direct2D / DirectWrite로 옮긴다는 흐름이 자연스러울 것입니다.

4.3 RGB32 그대로 H.264로 쓸 수 있다고는 할 수 없다

여기가 가장 막히기 쉬운 지점입니다.

MP4(H.264)로 되돌릴 때 Microsoft의 H.264 인코더는 I420 / IYUV / NV12 / YUY2 / YV12 같은 YUV 계열 입력 을 전제로 하는 경우가 많습니다. 즉 그리기 편한 RGB32 / ARGB32로 합성한 뒤, 그대로 IMFSinkWriter에 넘기면 끝 이라고는 할 수 없습니다.

그래서 구현에서는 둘 중 하나의 변환이 필요해집니다.

  • Video Processor MFT를 끼워 RGB32 / ARGB32 -> NV12
  • 직접 RGB -> NV12 변환을 넣는다

이번 샘플은 1파일 완결 을 우선해 후자인 직접 변환 을 넣고 있습니다. 실제 운영에서는 색공간 변환, 크기 변경, 인터레이스 해제까지 한꺼번에 다룰 수 있는 Video Processor MFT를 끼우는 구성도 유력합니다.

RGB에서 NV12로 잇는 두 갈래 길그리기 편한 RGB32나 ARGB32로 합성을 마친 뒤 Video Processor MFT를 끼워 변환하거나 직접 RGB에서 NV12로의 변환을 넣는 것 중 하나가 필요하며 이 샘플은 1파일 완결을 우선해 직접 변환을 선택했음을 보여주는 그림.RGB 계열로 합성을 마쳤다Video Processor MFT로 변환직접 NV12로 변환샘플은 1파일 완결을 우선실제 운영에서는 유력한 구성

그림 6: 변환 단은 반드시 필요하다는 전제로, 어느 쪽에 맡길지만 고른다.

4.4 출력은 IMFSinkWriter로 쓴다

동영상 출력은 IMFSinkWriter가 다루기 쉽습니다.

사고방식은 단순해서,

  • 출력 스트림 타입 … 파일에 쓰고 싶은 형식 예: MFVideoFormat_H264
  • 입력 스트림 타입 … 앱이 Sink Writer에 넘기는 형식 예: MFVideoFormat_NV12

를 나누어 설정합니다.

Sink Writer 쪽에서 보면,

  • 앱 쪽은 NV12의 비압축 프레임을 넘긴다
  • Sink Writer는 그것을 H.264로 인코딩해 MP4에 쓴다

는 관계가 됩니다.

Sink Writer 입출력 타입의 분담Sink Writer에는 앱이 넘기는 입력 스트림 타입으로 NV12를, 파일에 쓰고 싶은 출력 스트림 타입으로 H.264를 나누어 설정하며 Sink Writer가 인코딩을 맡아 MP4로 씀을 보여주는 그림.앱이 NV12 프레임을 넘긴다Sink WriterH.264로 인코딩MP4로 쓴다입력 타입과 출력 타입을 나눠 설정

그림 7: 넘기는 형식과 쓰이는 형식을 나누어 설정하는 것이 Sink Writer의 작법이다.

4.5 음성은 처음에는 나누어 생각하면 정리하기 쉽다

동영상에 로고나 문자를 넣고 싶을 뿐, 음성 자체는 바꾸고 싶지 않은 경우가 꽤 많습니다.

실무에서는

  • 영상 stream만 Source Reader -> 합성 -> Sink Writer
  • 음성 stream은 compressed 상태 그대로 remux

라는 구성이 다루기 쉽습니다.

다만 이번 샘플은 프레임에 이미지와 문자를 구워 넣는 부분 에 초점을 맞추기 위해 출력은 영상만 있는 MP4 로 하고 있습니다. 음성을 남기는 버전은 이후 확장하는 단계에서 추가하는 편이 전체를 따라가기 쉬워집니다.

영상과 음성을 나누어 생각한다실무에서는 영상 스트림만 Source Reader에서 합성을 거쳐 Sink Writer로 흘리고 음성 스트림은 압축된 상태로 remux하는 구성이 다루기 쉬우며 이 샘플은 초점을 좁히기 위해 출력을 영상만으로 했음을 보여주는 그림.영상 스트림합성해서 Sink Writer로음성 스트림압축된 상태로 remux샘플에서는 다루지 않는다

그림 8: 바꾸고 싶은 것이 영상뿐이라면 음성은 건드리지 않고 그릇째로 옮긴다.

5. 이 샘플의 전제와 사용법

이 코드의 전제는 다음과 같습니다.

  • Windows 10 / 11
  • Visual Studio 2022의 C++ 콘솔 앱
  • x64 빌드
  • .cpp 파일은 미리 컴파일된 헤더를 쓰지 않음
  • 입력 동영상의 너비와 높이는 짝수
  • 입력은 일반 MP4 동영상 파일
  • 출력은 영상만 있는 MP4
  • 이미지는 PNG / JPEG / BMP / GIF 등 GDI+가 읽을 수 있는 형식

NV12는 4:2:0이므로 너비·높이가 짝수 여야 합니다. 그래서 이 샘플에서는 조건을 만족하지 않으면 명시적으로 오류로 처리합니다.

너비와 높이가 짝수라는 전제NV12는 4:2:0 서브샘플링이므로 입력 동영상의 너비와 높이가 짝수여야 하며 이 샘플은 조건을 만족하지 않을 때 명시적으로 오류로 멈춘다는 것을 보여주는 그림.짝수홀수를 포함NV12는 4:2:0너비와 높이는 짝수가 필요입력은 짝수인가처리를 계속한다명시적으로 오류로 멈춘다

그림 9: 조용히 깨지는 것보다, 전제를 만족하지 않는 입력은 입구에서 멈춘다.

5.1 사용법

  1. Visual Studio에서 Console App 을 만든다
  2. .cpp를 통째로 붙여 넣는다
  3. .cpp미리 컴파일된 헤더를 “사용 안 함” 으로 설정한다
  4. x64로 빌드한다
  5. 다음과 같이 실행한다
OverlayMp4.exe input.mp4 overlay.png output.mp4
  • input.mp4 원본 동영상
  • overlay.png 겹치고 싶은 이미지
  • output.mp4 출력 위치

문자열은 코드 맨 앞의 kOverlayText에 고정으로 HelloWorld가 들어 있습니다. 위치나 크기도 코드 안의 상수를 건드리면 바꿀 수 있습니다. 명령줄 인자로 넘길 수 있게 하는 개조는 9.5에 실었습니다.

5.2 올바르게 동작했는지 확인한다

「오류 없이 끝났다」와 「올바르게 구워 넣었다」는 다릅니다. 다음 4가지를 순서대로 보면 대부분의 실패를 찾을 수 있습니다.

  1. 종료 시 프레임 수를 본다. 이 샘플은 처리를 마치면 Done. frames=에 이어 기록한 프레임 수를 출력합니다. 입력 동영상의 총 프레임 수와 크게 어긋나 있다면 ReadSample 루프의 어딘가에서 놓친 것입니다
  2. 출력 파일의 기본 정보를 입력과 비교한다. 탐색기에서 출력 MP4를 우클릭하고 속성 > 자세히를 열면 길이, 프레임 너비, 프레임 높이, 프레임 레이트가 나옵니다. 입력과 길이가 맞지 않는다면 timestamp 처리를 의심합니다(7.4)
  3. 처음·중간·끝 3곳을 눈으로 확인한다. 첫 프레임만 확인하고 안심하면 도중에 오버레이가 사라지는 결함을 놓칩니다. 같은 시각의 정지 이미지를 입력과 출력 양쪽에서 잘라내 나란히 놓는 것이 확실하며, 그 절차는 「Media Foundation으로 MP4의 지정 시각에서 정지 이미지를 추출하는 방법」에 정리되어 있습니다
  4. 색이 이상하지 않은지 본다. 사람의 피부나 하늘이 부자연스러운 색이 되어 있다면 BgraToNv12의 계수 선택(BT.601과 BT.709)이 입력과 맞지 않을 가능성이 있습니다

처음 시도할 때는 몇 초짜리 짧은 MP4와 윤곽이 뚜렷한 PNG 를 쓰는 것을 추천합니다. 긴 동영상으로 첫 1개를 통과시키려 하면 문제를 가려내는 데 시간이 걸립니다.

올바르게 동작했는지 확인하는 절차오류 없이 끝난 것과 올바르게 구워 넣은 것은 다르므로 프레임 수 대조, 출력 파일 기본 정보 비교, 처음과 중간과 끝의 육안 확인, 색상 이상 확인이라는 4가지를 순서대로 보는 흐름을 보여주는 그림.프레임 수를 입력과 대조속성에서 길이나 크기를 비교처음·중간·끝을 육안 확인색의 부자연스러움을 확인이상하면 계수 선택을 의심

그림 10: 오류 없음과 올바름은 별개이므로 4가지 관점으로 차례로 확인한다.

6. .cpp에 그대로 붙일 수 있는 1파일 완결 코드

6.1 코드의 지도

먼저 지도를 보여드립니다. 코드는 길지만 실제로 읽어야 할 핵심은 CopySampleToTopDownBgra, DrawOverlay, BgraToNv12 이 3개와 wmain의 루프뿐입니다. 나머지는 초기화와 뒷정리입니다.

함수 / 클래스 역할 자세한 설명
ScopedMf / ScopedGdiplus MFStartup과 GDI+의 초기화·종료를 RAII로 짝지음
ConfigureSourceReader Source Reader의 출력을 RGB32로 설정하고, 너비·높이·fps·기본 frame duration을 꺼냄 4.1
GetDefaultStride 미디어 타입에서 기본 stride를 구함 7.2
BufferLock IMF2DBuffer가 있으면 그쪽으로, 없으면 IMFMediaBuffer로 버퍼를 잠금 7.2
CopySampleToTopDownBgra stride와 상하 방향을 흡수해 top-down BGRA로 정규화 7.2
DrawOverlay GDI+로 이미지와 문자를 그림. 여기만이 「그리는 단계」 4.2 / 7.1
BgraToNv12 다 그린 BGRA를 NV12로 변환 4.3 / 7.1
CreateNv12Sample NV12 버퍼를 IMFSample로 감싸고 timestamp와 duration을 붙임 7.4
ChooseBitrate 입력 정보로 출력 비트레이트를 정함
CreateSinkWriter 출력 쪽 H.264 타입과 이쪽이 넘기는 NV12 타입을 설정 4.4
wmainwhile 루프 ReadSampleHRESULT / flags / sample을 보면서 프레임을 하나씩 돌림 7.3 / 7.4

3장의 처리 이미지와 나란히 놓으면 CopySampleToTopDownBgra가 「비압축 프레임」, DrawOverlay가 「GDI+로 드로잉」, BgraToNv12가 「BGRA에서 NV12 변환」 에 각각 대응합니다.

읽어야 할 핵심 3개 함수긴 코드 중 실제로 읽어야 할 핵심은 CopySampleToTopDownBgra와 DrawOverlay와 BgraToNv12 3개와 wmain의 루프이며 각각 비압축 프레임으로의 정규화, 드로잉, NV12 변환에 대응함을 보여주는 그림.wmain의 루프가 프레임을 하나씩 돌린다CopySampleToTopDownBgraDrawOverlayBgraToNv12비압축 프레임으로 정규화이미지와 문자를 그린다인코딩용으로 정리한다

그림 11: 나머지는 초기화와 뒷정리이며, 핵심은 이 3개 함수와 루프뿐이다.

6.2 전체 코드

#define NOMINMAX
#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mferror.h>
#include <gdiplus.h>
#include <wrl/client.h>

#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cwchar>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfreadwrite.lib")
#pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "mf.lib")
#pragma comment(lib, "gdiplus.lib")

using Microsoft::WRL::ComPtr;

namespace
{
    const wchar_t* kOverlayText = L"HelloWorld";
    const float kMarginRatio = 0.03f;
    const float kImageMaxWidthRatio = 0.20f;
    const float kImageMaxHeightRatio = 0.20f;
    const float kMinFontPx = 24.0f;

    std::string HrToHex(HRESULT hr)
    {
        char buf[32]{};
        std::snprintf(buf, sizeof(buf), "0x%08X", static_cast<unsigned int>(hr));
        return std::string(buf);
    }

    void ThrowIfFailed(HRESULT hr, const char* message)
    {
        if (FAILED(hr))
        {
            throw std::runtime_error(std::string(message) + " failed. HRESULT=" + HrToHex(hr));
        }
    }

    void ThrowIfGdiplusError(Gdiplus::Status status, const char* message)
    {
        if (status != Gdiplus::Ok)
        {
            char buf[128]{};
            std::snprintf(buf, sizeof(buf), "%s failed. GDI+ status=%d", message, static_cast<int>(status));
            throw std::runtime_error(buf);
        }
    }

    BYTE ClampToByte(int value)
    {
        if (value < 0) return 0;
        if (value > 255) return 255;
        return static_cast<BYTE>(value);
    }

    class ScopedGdiplus
    {
    public:
        ScopedGdiplus()
        {
            Gdiplus::GdiplusStartupInput input;
            ThrowIfGdiplusError(Gdiplus::GdiplusStartup(&token_, &input, nullptr), "GdiplusStartup");
        }

        ~ScopedGdiplus()
        {
            if (token_ != 0)
            {
                Gdiplus::GdiplusShutdown(token_);
            }
        }

    private:
        ULONG_PTR token_ = 0;
    };

    class ScopedMf
    {
    public:
        ScopedMf()
        {
            ThrowIfFailed(CoInitializeEx(nullptr, COINIT_MULTITHREADED), "CoInitializeEx");
            comInitialized_ = true;

            ThrowIfFailed(MFStartup(MF_VERSION), "MFStartup");
            mfStarted_ = true;
        }

        ~ScopedMf()
        {
            if (mfStarted_)
            {
                MFShutdown();
            }

            if (comInitialized_)
            {
                CoUninitialize();
            }
        }

    private:
        bool comInitialized_ = false;
        bool mfStarted_ = false;
    };

    class BufferLock
    {
    public:
        explicit BufferLock(IMFMediaBuffer* buffer)
            : buffer_(buffer)
        {
            if (!buffer_)
            {
                throw std::runtime_error("BufferLock received a null buffer.");
            }

            buffer_.As(&buffer2D_);
        }

        HRESULT LockBuffer(LONG defaultStride, DWORD heightInPixels, BYTE** scanline0, LONG* actualStride)
        {
            if (scanline0 == nullptr || actualStride == nullptr)
            {
                return E_POINTER;
            }

            HRESULT hr = S_OK;

            if (buffer2D_)
            {
                hr = buffer2D_->Lock2D(scanline0, actualStride);
            }
            else
            {
                BYTE* data = nullptr;
                hr = buffer_->Lock(&data, nullptr, nullptr);
                if (SUCCEEDED(hr))
                {
                    *actualStride = defaultStride;
                    if (defaultStride < 0)
                    {
                        *scanline0 = data + (static_cast<LONG>(heightInPixels) - 1) * std::abs(defaultStride);
                    }
                    else
                    {
                        *scanline0 = data;
                    }
                }
            }

            locked_ = SUCCEEDED(hr);
            return hr;
        }

        ~BufferLock()
        {
            if (!locked_)
            {
                return;
            }

            if (buffer2D_)
            {
                buffer2D_->Unlock2D();
            }
            else
            {
                buffer_->Unlock();
            }
        }

    private:
        ComPtr<IMFMediaBuffer> buffer_;
        ComPtr<IMF2DBuffer> buffer2D_;
        bool locked_ = false;
    };

    struct VideoFormatInfo
    {
        UINT32 width = 0;
        UINT32 height = 0;
        UINT32 fpsNum = 0;
        UINT32 fpsDen = 0;
        UINT32 parNum = 1;
        UINT32 parDen = 1;
        LONG sourceStride = 0;
        LONGLONG defaultFrameDuration = 0;
        UINT32 bitrate = 0;
    };

    LONG GetDefaultStride(IMFMediaType* type)
    {
        LONG stride = 0;

        HRESULT hr = type->GetUINT32(MF_MT_DEFAULT_STRIDE, reinterpret_cast<UINT32*>(&stride));
        if (SUCCEEDED(hr))
        {
            return stride;
        }

        GUID subtype = GUID_NULL;
        UINT32 width = 0;
        UINT32 height = 0;

        ThrowIfFailed(type->GetGUID(MF_MT_SUBTYPE, &subtype), "GetGUID(MF_MT_SUBTYPE)");
        ThrowIfFailed(MFGetAttributeSize(type, MF_MT_FRAME_SIZE, &width, &height), "MFGetAttributeSize(MF_MT_FRAME_SIZE)");
        ThrowIfFailed(MFGetStrideForBitmapInfoHeader(subtype.Data1, width, &stride), "MFGetStrideForBitmapInfoHeader");
        ThrowIfFailed(type->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(stride)), "SetUINT32(MF_MT_DEFAULT_STRIDE)");

        return stride;
    }

    UINT32 ChooseBitrate(IMFMediaType* nativeType, UINT32 width, UINT32 height, UINT32 fpsNum, UINT32 fpsDen)
    {
        UINT32 srcBitrate = 0;
        if (SUCCEEDED(nativeType->GetUINT32(MF_MT_AVG_BITRATE, &srcBitrate)) && srcBitrate > 0)
        {
            return srcBitrate;
        }

        const double fps = static_cast<double>(fpsNum) / static_cast<double>(fpsDen);
        double estimated = static_cast<double>(width) * static_cast<double>(height) * fps * 0.07;

        if (estimated < 1500000.0)
        {
            estimated = 1500000.0;
        }

        if (estimated > 25000000.0)
        {
            estimated = 25000000.0;
        }

        return static_cast<UINT32>(estimated);
    }

    VideoFormatInfo ConfigureSourceReader(IMFSourceReader* reader)
    {
        ThrowIfFailed(reader->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE), "SetStreamSelection(all,false)");
        ThrowIfFailed(reader->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE), "SetStreamSelection(video,true)");

        ComPtr<IMFMediaType> nativeType;
        ThrowIfFailed(reader->GetNativeMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, 0, &nativeType), "GetNativeMediaType(video)");

        ComPtr<IMFMediaType> requestedType;
        ThrowIfFailed(MFCreateMediaType(&requestedType), "MFCreateMediaType(video requested)");
        ThrowIfFailed(requestedType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "SetGUID(video requested major)");
        ThrowIfFailed(requestedType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32), "SetGUID(video requested subtype RGB32)");
        ThrowIfFailed(reader->SetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, nullptr, requestedType.Get()), "SetCurrentMediaType(video RGB32)");

        ComPtr<IMFMediaType> currentType;
        ThrowIfFailed(reader->GetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, &currentType), "GetCurrentMediaType(video)");

        VideoFormatInfo info;
        ThrowIfFailed(MFGetAttributeSize(currentType.Get(), MF_MT_FRAME_SIZE, &info.width, &info.height), "Get video frame size");

        HRESULT hr = MFGetAttributeRatio(currentType.Get(), MF_MT_FRAME_RATE, &info.fpsNum, &info.fpsDen);
        if (FAILED(hr))
        {
            ThrowIfFailed(MFGetAttributeRatio(nativeType.Get(), MF_MT_FRAME_RATE, &info.fpsNum, &info.fpsDen), "Get video frame rate");
        }

        if (info.fpsNum == 0 || info.fpsDen == 0)
        {
            throw std::runtime_error("Video frame rate is zero.");
        }

        hr = MFGetAttributeRatio(currentType.Get(), MF_MT_PIXEL_ASPECT_RATIO, &info.parNum, &info.parDen);
        if (FAILED(hr) || info.parNum == 0 || info.parDen == 0)
        {
            info.parNum = 1;
            info.parDen = 1;
        }

        info.sourceStride = GetDefaultStride(currentType.Get());
        info.defaultFrameDuration = (10000000LL * info.fpsDen) / info.fpsNum;
        if (info.defaultFrameDuration <= 0)
        {
            throw std::runtime_error("Calculated frame duration is invalid.");
        }

        info.bitrate = ChooseBitrate(nativeType.Get(), info.width, info.height, info.fpsNum, info.fpsDen);
        return info;
    }

    ComPtr<IMFSinkWriter> CreateSinkWriter(const std::wstring& outputPath, const VideoFormatInfo& videoInfo, DWORD* streamIndex)
    {
        if (streamIndex == nullptr)
        {
            throw std::runtime_error("streamIndex is null.");
        }

        ComPtr<IMFAttributes> attributes;
        ThrowIfFailed(MFCreateAttributes(&attributes, 1), "MFCreateAttributes(sink)");
        ThrowIfFailed(attributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE), "SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS)");

        ComPtr<IMFSinkWriter> writer;
        ThrowIfFailed(MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, attributes.Get(), &writer), "MFCreateSinkWriterFromURL");

        ComPtr<IMFMediaType> outputType;
        ThrowIfFailed(MFCreateMediaType(&outputType), "MFCreateMediaType(video output)");
        ThrowIfFailed(outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "SetGUID(output major)");
        ThrowIfFailed(outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264), "SetGUID(output subtype H264)");
        ThrowIfFailed(outputType->SetUINT32(MF_MT_AVG_BITRATE, videoInfo.bitrate), "SetUINT32(output bitrate)");
        ThrowIfFailed(outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive), "SetUINT32(output interlace)");
        ThrowIfFailed(MFSetAttributeSize(outputType.Get(), MF_MT_FRAME_SIZE, videoInfo.width, videoInfo.height), "MFSetAttributeSize(output frame size)");
        ThrowIfFailed(MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, videoInfo.fpsNum, videoInfo.fpsDen), "MFSetAttributeRatio(output fps)");
        ThrowIfFailed(MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, videoInfo.parNum, videoInfo.parDen), "MFSetAttributeRatio(output PAR)");
        ThrowIfFailed(writer->AddStream(outputType.Get(), streamIndex), "AddStream(video)");

        ComPtr<IMFMediaType> inputType;
        ThrowIfFailed(MFCreateMediaType(&inputType), "MFCreateMediaType(video input)");
        ThrowIfFailed(inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "SetGUID(input major)");
        ThrowIfFailed(inputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_NV12), "SetGUID(input subtype NV12)");
        ThrowIfFailed(inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive), "SetUINT32(input interlace)");
        ThrowIfFailed(MFSetAttributeSize(inputType.Get(), MF_MT_FRAME_SIZE, videoInfo.width, videoInfo.height), "MFSetAttributeSize(input frame size)");
        ThrowIfFailed(MFSetAttributeRatio(inputType.Get(), MF_MT_FRAME_RATE, videoInfo.fpsNum, videoInfo.fpsDen), "MFSetAttributeRatio(input fps)");
        ThrowIfFailed(MFSetAttributeRatio(inputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, videoInfo.parNum, videoInfo.parDen), "MFSetAttributeRatio(input PAR)");
        ThrowIfFailed(writer->SetInputMediaType(*streamIndex, inputType.Get(), nullptr), "SetInputMediaType(video)");

        ThrowIfFailed(writer->BeginWriting(), "BeginWriting");
        return writer;
    }

    void CopySampleToTopDownBgra(IMFSample* sample, const VideoFormatInfo& videoInfo, std::vector<BYTE>& bgra)
    {
        ComPtr<IMFMediaBuffer> buffer;
        ThrowIfFailed(sample->ConvertToContiguousBuffer(&buffer), "ConvertToContiguousBuffer");

        BufferLock lock(buffer.Get());

        BYTE* scanline0 = nullptr;
        LONG actualStride = 0;
        ThrowIfFailed(lock.LockBuffer(videoInfo.sourceStride, videoInfo.height, &scanline0, &actualStride), "LockBuffer");

        const size_t dstStride = static_cast<size_t>(videoInfo.width) * 4;
        bgra.resize(dstStride * videoInfo.height);

        for (UINT32 y = 0; y < videoInfo.height; ++y)
        {
            const BYTE* srcRow = scanline0 + static_cast<LONG>(y) * actualStride;
            BYTE* dstRow = bgra.data() + static_cast<size_t>(y) * dstStride;
            std::memcpy(dstRow, srcRow, dstStride);

            for (UINT32 x = 0; x < videoInfo.width; ++x)
            {
                dstRow[static_cast<size_t>(x) * 4 + 3] = 0xFF;
            }
        }
    }

    void DrawOverlay(std::vector<BYTE>& bgra, UINT32 width, UINT32 height, Gdiplus::Image& overlayImage)
    {
        const INT stride = static_cast<INT>(width * 4);

        Gdiplus::Bitmap frameBitmap(
            static_cast<INT>(width),
            static_cast<INT>(height),
            stride,
            PixelFormat32bppPARGB,
            bgra.data());
        ThrowIfGdiplusError(frameBitmap.GetLastStatus(), "Create frame bitmap");

        Gdiplus::Graphics graphics(&frameBitmap);
        ThrowIfGdiplusError(graphics.GetLastStatus(), "Create graphics");

        graphics.SetCompositingMode(Gdiplus::CompositingModeSourceOver);
        graphics.SetCompositingQuality(Gdiplus::CompositingQualityHighQuality);
        graphics.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic);
        graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
        graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit);

        const Gdiplus::REAL margin = std::max<Gdiplus::REAL>(16.0f, static_cast<Gdiplus::REAL>(height) * kMarginRatio);
        const Gdiplus::REAL maxImageW = static_cast<Gdiplus::REAL>(width) * kImageMaxWidthRatio;
        const Gdiplus::REAL maxImageH = static_cast<Gdiplus::REAL>(height) * kImageMaxHeightRatio;

        const Gdiplus::REAL srcW = static_cast<Gdiplus::REAL>(overlayImage.GetWidth());
        const Gdiplus::REAL srcH = static_cast<Gdiplus::REAL>(overlayImage.GetHeight());
        if (srcW <= 0.0f || srcH <= 0.0f)
        {
            throw std::runtime_error("Overlay image has invalid size.");
        }

        const Gdiplus::REAL imageScale =
            std::min<Gdiplus::REAL>(1.0f, std::min(maxImageW / srcW, maxImageH / srcH));

        const Gdiplus::REAL drawW = srcW * imageScale;
        const Gdiplus::REAL drawH = srcH * imageScale;

        Gdiplus::RectF imageRect(margin, margin, drawW, drawH);
        Gdiplus::SolidBrush imagePlate(Gdiplus::Color(96, 0, 0, 0));
        graphics.FillRectangle(
            &imagePlate,
            imageRect.X - 8.0f,
            imageRect.Y - 8.0f,
            imageRect.Width + 16.0f,
            imageRect.Height + 16.0f);

        graphics.DrawImage(&overlayImage, imageRect);

        const Gdiplus::REAL fontPx =
            std::max<Gdiplus::REAL>(kMinFontPx, static_cast<Gdiplus::REAL>(height) * 0.06f);

        Gdiplus::Font font(L"Segoe UI", fontPx, Gdiplus::FontStyleBold, Gdiplus::UnitPixel);
        ThrowIfGdiplusError(font.GetLastStatus(), "Create font");

        Gdiplus::StringFormat stringFormat;
        stringFormat.SetAlignment(Gdiplus::StringAlignmentNear);
        stringFormat.SetLineAlignment(Gdiplus::StringAlignmentNear);

        Gdiplus::RectF measureLayout(
            margin,
            static_cast<Gdiplus::REAL>(height) - margin - fontPx * 2.0f,
            static_cast<Gdiplus::REAL>(width) - margin * 2.0f,
            fontPx * 2.0f);

        Gdiplus::RectF measured;
        graphics.MeasureString(kOverlayText, -1, &font, measureLayout, &stringFormat, &measured);

        Gdiplus::RectF textBg(
            measured.X - 12.0f,
            measured.Y - 8.0f,
            measured.Width + 24.0f,
            measured.Height + 16.0f);

        Gdiplus::SolidBrush textPlate(Gdiplus::Color(128, 0, 0, 0));
        graphics.FillRectangle(&textPlate, textBg);

        Gdiplus::SolidBrush shadowBrush(Gdiplus::Color(220, 0, 0, 0));
        Gdiplus::RectF shadowLayout = measureLayout;
        shadowLayout.X += 2.0f;
        shadowLayout.Y += 2.0f;
        graphics.DrawString(kOverlayText, -1, &font, shadowLayout, &stringFormat, &shadowBrush);

        Gdiplus::SolidBrush textBrush(Gdiplus::Color(235, 255, 255, 255));
        graphics.DrawString(kOverlayText, -1, &font, measureLayout, &stringFormat, &textBrush);
    }

    void BgraToNv12(const BYTE* bgra, UINT32 width, UINT32 height, BYTE* nv12)
    {
        const bool useBt709 = (width > 1024 || height > 576);

        const int yR = useBt709 ? 47 : 66;
        const int yG = useBt709 ? 157 : 129;
        const int yB = useBt709 ? 16 : 25;

        const int uR = useBt709 ? -26 : -38;
        const int uG = useBt709 ? -87 : -74;
        const int uB = 112;

        const int vR = 112;
        const int vG = useBt709 ? -102 : -94;
        const int vB = useBt709 ? -10 : -18;

        BYTE* yPlane = nv12;
        BYTE* uvPlane = nv12 + static_cast<size_t>(width) * height;

        const size_t srcStride = static_cast<size_t>(width) * 4;

        for (UINT32 y = 0; y < height; ++y)
        {
            const BYTE* srcRow = bgra + static_cast<size_t>(y) * srcStride;
            BYTE* dstY = yPlane + static_cast<size_t>(y) * width;

            for (UINT32 x = 0; x < width; ++x)
            {
                const BYTE b = srcRow[x * 4 + 0];
                const BYTE g = srcRow[x * 4 + 1];
                const BYTE r = srcRow[x * 4 + 2];

                const int Y = ((yR * r + yG * g + yB * b + 128) >> 8) + 16;
                dstY[x] = ClampToByte(Y);
            }
        }

        for (UINT32 y = 0; y < height; y += 2)
        {
            const BYTE* row0 = bgra + static_cast<size_t>(y) * srcStride;
            const BYTE* row1 = bgra + static_cast<size_t>(y + 1) * srcStride;
            BYTE* dstUV = uvPlane + static_cast<size_t>(y / 2) * width;

            for (UINT32 x = 0; x < width; x += 2)
            {
                int b = 0;
                int g = 0;
                int r = 0;

                for (UINT32 dy = 0; dy < 2; ++dy)
                {
                    const BYTE* row = (dy == 0) ? row0 : row1;
                    for (UINT32 dx = 0; dx < 2; ++dx)
                    {
                        const UINT32 ix = x + dx;
                        b += row[ix * 4 + 0];
                        g += row[ix * 4 + 1];
                        r += row[ix * 4 + 2];
                    }
                }

                b = (b + 2) / 4;
                g = (g + 2) / 4;
                r = (r + 2) / 4;

                const int U = ((uR * r + uG * g + uB * b + 128) >> 8) + 128;
                const int V = ((vR * r + vG * g + vB * b + 128) >> 8) + 128;

                dstUV[x + 0] = ClampToByte(U);
                dstUV[x + 1] = ClampToByte(V);
            }
        }
    }

    ComPtr<IMFSample> CreateNv12Sample(
        const std::vector<BYTE>& bgra,
        const VideoFormatInfo& videoInfo,
        LONGLONG sampleTime,
        LONGLONG sampleDuration)
    {
        const DWORD bufferSize =
            static_cast<DWORD>(videoInfo.width * videoInfo.height * 3 / 2);

        ComPtr<IMFMediaBuffer> buffer;
        ThrowIfFailed(MFCreateMemoryBuffer(bufferSize, &buffer), "MFCreateMemoryBuffer");

        BYTE* dst = nullptr;
        DWORD maxLength = 0;
        DWORD currentLength = 0;
        ThrowIfFailed(buffer->Lock(&dst, &maxLength, &currentLength), "Lock(NV12 buffer)");

        try
        {
            BgraToNv12(bgra.data(), videoInfo.width, videoInfo.height, dst);
        }
        catch (...)
        {
            buffer->Unlock();
            throw;
        }

        ThrowIfFailed(buffer->Unlock(), "Unlock(NV12 buffer)");
        ThrowIfFailed(buffer->SetCurrentLength(bufferSize), "SetCurrentLength(NV12 buffer)");

        ComPtr<IMFSample> sample;
        ThrowIfFailed(MFCreateSample(&sample), "MFCreateSample");
        ThrowIfFailed(sample->AddBuffer(buffer.Get()), "AddBuffer(output sample)");
        ThrowIfFailed(sample->SetSampleTime(sampleTime), "SetSampleTime");
        ThrowIfFailed(sample->SetSampleDuration(sampleDuration), "SetSampleDuration");

        return sample;
    }
}

int wmain(int argc, wchar_t* argv[])
{
    if (argc != 4)
    {
        std::wcerr << L"Usage: OverlayMp4.exe <input.mp4> <overlayImage.png> <output.mp4>" << std::endl;
        return 1;
    }

    const std::wstring inputPath = argv[1];
    const std::wstring imagePath = argv[2];
    const std::wstring outputPath = argv[3];

    try
    {
        if (_wcsicmp(inputPath.c_str(), outputPath.c_str()) == 0)
        {
            throw std::runtime_error("Input and output paths must be different.");
        }

        ScopedMf mf;
        ScopedGdiplus gdiplus;

        ComPtr<IMFAttributes> readerAttributes;
        ThrowIfFailed(MFCreateAttributes(&readerAttributes, 1), "MFCreateAttributes(reader)");
        ThrowIfFailed(
            readerAttributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE),
            "SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING)");

        ComPtr<IMFSourceReader> reader;
        ThrowIfFailed(
            MFCreateSourceReaderFromURL(inputPath.c_str(), readerAttributes.Get(), &reader),
            "MFCreateSourceReaderFromURL");

        VideoFormatInfo videoInfo = ConfigureSourceReader(reader.Get());

        if ((videoInfo.width % 2) != 0 || (videoInfo.height % 2) != 0)
        {
            throw std::runtime_error(
                "This sample requires even video width and height because NV12 is 4:2:0.");
        }

        Gdiplus::Image overlayImage(imagePath.c_str());
        ThrowIfGdiplusError(overlayImage.GetLastStatus(), "Load overlay image");

        DWORD videoStreamIndex = 0;
        ComPtr<IMFSinkWriter> writer =
            CreateSinkWriter(outputPath, videoInfo, &videoStreamIndex);

        std::vector<BYTE> bgra;
        LONGLONG firstTimestamp = -1;
        unsigned long long frameCount = 0;

        while (true)
        {
            DWORD flags = 0;
            LONGLONG timestamp = 0;
            ComPtr<IMFSample> inputSample;

            ThrowIfFailed(
                reader->ReadSample(
                    MF_SOURCE_READER_FIRST_VIDEO_STREAM,
                    0,
                    nullptr,
                    &flags,
                    &timestamp,
                    &inputSample),
                "ReadSample(video)");

            if ((flags & MF_SOURCE_READERF_CURRENTMEDIATYPECHANGED) != 0)
            {
                throw std::runtime_error("Dynamic video format change is not supported in this sample.");
            }

            if ((flags & MF_SOURCE_READERF_NATIVEMEDIATYPECHANGED) != 0)
            {
                throw std::runtime_error("Native video format change is not supported in this sample.");
            }

            if ((flags & MF_SOURCE_READERF_STREAMTICK) != 0)
            {
                if (firstTimestamp < 0)
                {
                    firstTimestamp = timestamp;
                }

                ThrowIfFailed(
                    writer->SendStreamTick(videoStreamIndex, timestamp - firstTimestamp),
                    "SendStreamTick");
            }

            if (inputSample)
            {
                if (firstTimestamp < 0)
                {
                    firstTimestamp = timestamp;
                }

                LONGLONG duration = 0;
                if (FAILED(inputSample->GetSampleDuration(&duration)) || duration <= 0)
                {
                    duration = videoInfo.defaultFrameDuration;
                }

                CopySampleToTopDownBgra(inputSample.Get(), videoInfo, bgra);
                DrawOverlay(bgra, videoInfo.width, videoInfo.height, overlayImage);

                ComPtr<IMFSample> outputSample =
                    CreateNv12Sample(bgra, videoInfo, timestamp - firstTimestamp, duration);

                ThrowIfFailed(
                    writer->WriteSample(videoStreamIndex, outputSample.Get()),
                    "WriteSample(video)");

                ++frameCount;
            }

            if ((flags & MF_SOURCE_READERF_ENDOFSTREAM) != 0)
            {
                break;
            }
        }

        ThrowIfFailed(writer->Finalize(), "Finalize");

        std::wcout
            << L"Done. frames=" << frameCount
            << L", output=" << outputPath
            << std::endl;

        return 0;
    }
    catch (const std::exception& ex)
    {
        std::cerr << ex.what() << std::endl;
        return 1;
    }
}

7. 이 구현을 읽을 때 짚어 두고 싶은 포인트

7.1 그리기 편한 형식과 인코더가 받기 쉬운 형식은 다르다

이 샘플에서는

  • Source Reader 출력: RGB32
  • 드로잉: GDI+
  • Sink Writer 입력: NV12

라는 흐름을 취하고 있습니다.

이유는 단순한데, 문자나 PNG를 얹으려면 RGB 계열이 다루기 쉽고, H.264 인코딩에 넘기려면 NV12가 다루기 쉽기 때문입니다.

구현을 읽을 때는 이 부분을 「그리는 단계」와 「인코딩 전에 정리하는 단계」 로 나누어 보면 따라가기 쉬워집니다.

7.2 stride와 상하 방향을 먼저 흡수한 뒤에 그린다

동영상 프레임은 보이는 대로 메모리에 나열되어 있다고는 할 수 없습니다.

  • stride가 width * 4와 일치하지 않는 경우가 있다
  • 상하 방향이 반대로 되어 있는 경우가 있다
  • IMF2DBufferIMFMediaBuffer에서 다루는 방식이 조금 다르다

그래서 이 코드에서는 일단 top-down BGRA 버퍼로 정규화한 뒤 그리도록 하고 있습니다. 이 부분을 먼저 맞춰 두면 드로잉 쪽 코드를 꽤 단순하게 만들 수 있습니다.

그리기 전에 정규화하는 이유stride가 너비의 4배와 일치하지 않거나 상하 방향이 반대이거나 IMF2DBuffer와 IMFMediaBuffer에서 다루는 방식이 다른 흔들림을 top-down BGRA 버퍼로의 정규화로 먼저 흡수한 뒤 그린다는 것을 보여주는 그림.stride가 일치하지 않는다top-down BGRA로 정규화상하 방향이 반대인 경우가 있다버퍼 종류에 따라 다루는 방식이 다르다드로잉 코드가 단순해진다

그림 12: 메모리상의 흔들림을 한 곳에서 흡수하면 그리는 쪽은 아무것도 몰라도 된다.

7.3 ReadSampleHRESULT뿐 아니라 flags와 sample도 본다

ReadSampleS_OK를 반환해도 sample == nullptr이 되는 경우가 있습니다. 전형적인 예는

  • MF_SOURCE_READERF_STREAMTICK
  • MF_SOURCE_READERF_ENDOFSTREAM
  • 그 밖의 스트림 이벤트

입니다.

그래서 루프에서는 HRESULT, flags, inputSample 이 3가지를 모두 갖춰 봐야 합니다. 특히 STREAMTICKENDOFSTREAM을 놓치면 뒤쪽의 타임라인 처리가 무너지기 쉬워집니다.

ReadSample에서 확인할 3가지ReadSample은 S_OK를 반환해도 sample이 nullptr이 될 수 있고 STREAMTICK이나 ENDOFSTREAM 같은 스트림 이벤트가 전형적이므로 루프에서는 HRESULT와 flags와 sample 3가지를 모두 확인해야 함을 보여주는 그림.ReadSample이 반환된다HRESULT를 확인flags를 확인sample의 유무를 확인S_OK여도 nullptr일 수 있다

그림 13: 반환값 하나로 판단하지 말고 3가지 세트로 한 프레임을 다룬다.

7.4 timestamp와 duration은 입력을 이어받는 편이 안전하다

타임스탬프는 100ns 단위입니다. 또한 duration은 별도로 IMFSample에서 가져와야 합니다.

고정 fps를 전제로 매번 정해진 값을 더하는 것보다 입력 sample의 timestamp / duration을 최대한 그대로 이어받는 편이 흐트러지지 않습니다. 이 샘플에서도 duration을 가져올 수 없을 때만 fps로 계산한 기본값으로 폴백하고 있습니다.

timestamp와 duration의 처리고정 fps를 전제로 정해진 값을 더하는 것이 아니라 입력 sample의 timestamp와 duration을 최대한 이어받고 duration을 가져올 수 없을 때만 fps에서 계산한 기본값으로 폴백함을 보여주는 그림.가져왔다못 가져왔다입력 sample에서 꺼낸다duration을 가져왔는가그대로 이어받는다fps에서 나온 기본값을 쓴다정해진 값을 더하는 것보다 흐트러지지 않는다

그림 14: 시각은 스스로 만들지 않고 입력에서 이어받는 것이 기본자세다.

7.5 GDI+는 도입이 가볍지만, 장시간이나 고해상도에서는 다음 단계가 있다

GDI+는 1파일 완결 샘플에는 꽤 적합하지만, 장시간 동영상이나 4K를 대량 처리하는 용도에서는 D3D11 + Direct2D + DirectWrite 쪽이 유리해지는 경우가 있습니다.

  • 먼저 GDI+로 전체를 통과시킨다
  • 그 뒤 필요해지면 Direct2D / DirectWrite로 바꾼다
  • 색 변환은 Video Processor MFT나 GPU 쪽으로 옮긴다

는 단계적인 진행 방식으로 하면 설계를 무너뜨리지 않고 확장하기 쉬워집니다.

드로잉 API의 단계적인 진행 방식먼저 GDI+로 전체를 통과시키고 필요해지면 Direct2D와 DirectWrite로 바꾸고 색 변환은 Video Processor MFT나 GPU 쪽으로 옮긴다는 설계를 무너뜨리지 않는 단계적인 진행 방식을 보여주는 그림.먼저 GDI+로 전체를 통과시킨다필요하면 Direct2D로 바꾼다색 변환을 MFT나 GPU로 옮긴다장시간이나 4K 대량 처리가 다음 단계

그림 15: 도입의 가벼움으로 시작해서 성능이 필요한 곳부터 차례로 바꾼다.

7.6 이 샘플은 영상에만 초점을 맞추고 있다

음성까지 같은 글에 다 담으면 이야기의 축이 흩어지기 쉽습니다. 그래서 이 샘플에서는 영상 프레임에 이미지와 문자를 구워 넣는 것 에 초점을 맞추고, 출력은 영상만 있는 MP4로 하고 있습니다.

실무에서는 다음 단계로

  • 영상만 Source Reader -> 합성 -> Sink Writer
  • 음성은 compressed 상태 그대로 remux

라는 구성으로 늘려 가는 편이 다루기 쉽습니다.

8. 「주어진 동영상 데이터」가 파일이 아니라 메모리상의 MP4 바이트열이라면

이번 코드는 MFCreateSourceReaderFromURL을 쓰고 있으므로 입력은 파일 경로입니다. 다만 요건이 「API로 받은 mp4의 바이트열에 대해 같은 처리를 하고 싶다」는 것이라면 사고방식은 달라지지 않습니다. 바뀌는 것은 입구뿐입니다.

  • IStream이나 자체 스트림을 준비한다
  • 그것을 IMFByteStream으로 Source Reader에 넘긴다
  • 이후는 마찬가지로 RGB32 -> 드로잉 -> NV12 -> Sink Writer

즉 본질은 동영상 데이터를 어떻게 들고 있는지가 아니라, 디코드 후 각 프레임에 어떻게 그려 넣는지 에 있습니다.

입력이 바이트열이어도 입구만 바꾼다입력이 파일 경로면 MFCreateSourceReaderFromURL, 메모리상의 MP4 바이트열이면 IMFByteStream을 준비해 Source Reader로 넘기며 이후 RGB32에서 드로잉, NV12, Sink Writer까지의 흐름은 같음을 보여주는 그림.파일 경로Source Reader메모리상의 바이트열IMFByteStream으로 만든다이후 흐름은 같다

그림 16: 데이터를 들고 있는 방식이 바뀌어도, 바뀌는 것은 입구의 한 단뿐이다.

9. 실제 운영에서 확장한다면

9.1 음성 remux를 추가한다

첫 확장 대상으로 가장 실무적인 것은 음성을 그대로 남기는 것입니다. 동영상만 재인코딩하고 음성은 compressed 상태 그대로 같은 형식으로 다시 써 넣는 구성으로 하면, 요건을 만족하면서도 구현을 크게 늘리지 않고 끝낼 수 있습니다.

Sink Writer는 압축된 입력을 같은 형식 그대로 출력에 쓰는 조합을, 재인코딩 없는 remux용으로 명시적으로 지원합니다. 추가할 곳은 다음 3곳입니다.

  1. 음성 스트림을 압축된 채로 받는다. reader->SetStreamSelection(MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE)로 활성화하고, GetNativeMediaType으로 얻은 타입을 그대로 SetCurrentMediaType에 넘깁니다. 디코드시키고 싶지 않을 때는 native 타입을 지정한다는 것이 Source Reader 쪽의 작법입니다
  2. Sink Writer에 그 타입을 입력에도 출력에도 설정한다. writer->AddStream(audioType.Get(), &audioStreamIndex)writer->SetInputMediaType(audioStreamIndex, audioType.Get(), nullptr)에 같은 미디어 타입을 넘깁니다
  3. timestamp의 기준을 영상과 공통으로 한다. 6.2의 코드가 영상에서 쓰고 있는 firstTimestamp를 음성 샘플에서도 똑같이 뺍니다. 여기를 서로 다른 기준으로 하면 소리와 영상이 어긋납니다

ReadSample 호출도 영상만 지정하고 있는 현재 형태에서, MF_SOURCE_READER_ANY_STREAM을 써서 반환값의 stream index로 나누는 형태로 바꿉니다.

또한 Sink Writer는 인코더가 제공하지 않는 한 음성의 리샘플링이나 영상의 크기 변경·프레임 레이트 변환을 하지 않습니다. 입력의 음성 형식을 MP4 싱크가 받을 수 없는 경우에는 remux가 아니라 재인코딩이 필요해집니다.

음성 remux에서 추가할 3곳음성을 그대로 남기려면 음성 스트림을 압축된 채로 받고 Sink Writer에 같은 타입을 입력에도 출력에도 설정하고 timestamp의 기준을 영상과 공통으로 한다는 3곳을 추가함을 보여주는 그림.음성을 압축된 채로 받는다같은 타입을 입력과 출력에 설정timestamp 기준을 영상과 공통으로기준을 나누면 소리가 어긋난다

그림 17: remux 추가는 3곳뿐이지만 시각 기준 맞추기를 잊지 않는다.

9.2 Video Processor MFT를 끼운다

이번 샘플은 1파일 완결을 우선해 BGRA -> NV12를 직접 변환하고 있지만, 실제 운영에서는 Video Processor MFT를 끼우는 구성도 상당히 유력합니다.

Video Processor MFT를 쓰면

  • 색공간 변환
  • 크기 변경
  • 인터레이스 해제
  • 프레임 레이트 변환

을 한꺼번에 다루기 쉬워집니다.

9.3 GDI+Direct2D / DirectWrite로 대체한다

로고 이미지, 자막, 타임스탬프 같은 오버레이는 GDI+로도 충분한 경우가 많지만, 성능을 짜내려면 Direct2D / DirectWrite 쪽이 유리합니다.

특히

  • 고해상도
  • 장시간
  • 대량 편수
  • 앞으로 GPU 경로로 옮기고 싶다

같은 조건이 있다면 D3D11 / DXGI surface를 쓰는 구성이 시야에 들어옵니다.

9.4 custom MFT는 「재사용하고 싶은 동영상 이펙트」가 되면 검토한다

Media Foundation에서는 이펙트를 IMFTransform으로 구현할 수 있습니다. 그래서 여러 앱이나 pipeline에서 같은 오버레이 처리를 재사용하고 싶다면 custom MFT는 깔끔한 선택지입니다.

다만 첫 번째 구현으로서는

  • IMFTransform 계약을 만족해야 한다
  • 입출력 미디어 타입 관리가 늘어난다
  • 등록과 디버깅의 난도가 오른다

이므로, 먼저 Source Reader + 합성 + Sink Writer로 올바르게 돌려 보고, 필요해지면 MFT로 분리한다 는 쪽이 실무에서는 진행하기 쉬운 경우가 많습니다.

custom MFT를 검토하는 시점먼저 Source Reader와 합성과 Sink Writer로 올바르게 돌려 보고 여러 앱이나 파이프라인에서 같은 오버레이 처리를 재사용하고 싶어지면 custom MFT로 분리한다는 판단 흐름을 보여주는 그림.여러 앱에서 쓰고 싶다1개로 충분하다먼저 지금 구성으로 올바르게 돌린다재사용하고 싶어졌는가custom MFT로 분리한다지금 구성 그대로구현·등록·디버깅 난도는 오른다

그림 18: 부품화는 깔끔하지만 필요해지고 나서 해도 늦지 않다.

9.5 문자열을 인자화하고 일본어를 그릴 수 있게 한다

샘플의 문자열은 kOverlayTextHelloWorld로 고정되어 있습니다. 장비 번호나 작업자 이름을 구워 넣는 용도에서는 먼저 이 부분을 인자로 만들고 싶어집니다. 일본어를 그리는 경우에는 폰트도 바꿔야 합니다.

변경할 곳은 4곳입니다.

1. 무명 namespace에 사용할 폰트 이름을 추가합니다. 기존의 kOverlayText는 기본값으로 남겨 둡니다.

    const wchar_t* kOverlayText = L"HelloWorld";          // 기존. 인자를 생략했을 때의 기본값으로 사용
    const wchar_t* kFontFamilyName = L"Yu Gothic UI";     // 일본어가 나오는 서체
    const wchar_t* kFallbackFontFamilyName = L"Segoe UI"; // 위 서체가 없는 환경용

2. DrawOverlay에 그릴 문자열을 넘길 수 있게 합니다.

    void DrawOverlay(
        std::vector<BYTE>& bgra,
        UINT32 width,
        UINT32 height,
        Gdiplus::Image& overlayImage,
        const std::wstring& overlayText)      // 추가

3. DrawOverlay 안에서 폰트 생성과 문자열 참조를 교체합니다. 기존의 Gdiplus::Font font(L"Segoe UI", ...) 줄을 다음으로 바꿉니다.

        // 지정한 서체가 없으면 기본 서체로 폴백한다
        Gdiplus::FontFamily preferred(kFontFamilyName);
        Gdiplus::FontFamily fallback(kFallbackFontFamilyName);
        const Gdiplus::FontFamily& family = preferred.IsAvailable() ? preferred : fallback;
        if (!family.IsAvailable())
        {
            throw std::runtime_error("Neither the preferred nor the fallback font family is installed.");
        }

        Gdiplus::Font font(&family, fontPx, Gdiplus::FontStyleBold, Gdiplus::UnitPixel);
        ThrowIfGdiplusError(font.GetLastStatus(), "Create font");

그런 다음 MeasureString과 2번의 DrawString에 넘기고 있는 kOverlayText를 모두 overlayText.c_str()로 바꿉니다. 3곳을 다 고치지 않으면 그림자만 옛 문자가 남습니다.

4. wmain에서 인자를 받아 DrawOverlay로 넘깁니다.

    if (argc < 4 || argc > 5)
    {
        std::wcerr
            << L"Usage: OverlayMp4.exe <input.mp4> <overlayImage.png> <output.mp4> [text]"
            << std::endl;
        return 1;
    }

    const std::wstring inputPath = argv[1];
    const std::wstring imagePath = argv[2];
    const std::wstring outputPath = argv[3];
    const std::wstring overlayText = (argc == 5) ? std::wstring(argv[4]) : std::wstring(kOverlayText);

그리고 루프 안의 호출을 다음과 같이 합니다.

                DrawOverlay(bgra, videoInfo.width, videoInfo.height, overlayImage, overlayText);

일본어를 다룰 때 주의할 점이 2가지 있습니다.

  • .cpp에 리터럴로 일본어를 적는다면 소스를 UTF-8 with BOM으로 저장하거나, MSVC에 /utf-8을 붙여 빌드합니다. 이를 빠뜨리면 문자가 깨집니다. 명령줄 인자로 넘기는 경우에는 wmain이 UTF-16으로 받으므로 이 문제는 일어나지 않습니다
  • 서체가 환경에 들어 있다고는 할 수 없습니다. 위 코드처럼 반드시 폴백을 준비합니다. 폰트가 없는 환경에서 말없이 다른 서체가 되면 레이아웃이 어긋난 원인을 추적하기 어려워집니다
일본어를 그리기 위한 변경점문자열을 인자화해 일본어를 그리려면 폰트 이름 상수 추가, DrawOverlay에 인자 추가, 폴백을 갖춘 폰트 생성으로 교체, wmain에서 인자를 받는 것이라는 4곳을 바꾸고 문자 깨짐과 폰트 누락에 주의해야 함을 보여주는 그림.폰트 이름 상수를 추가한다DrawOverlay에 인자를 추가한다폴백을 갖춰 폰트를 생성한다wmain에서 인자를 받아 넘긴다없는 서체는 기본값으로 폴백한다

그림 19: 변경은 4곳이며 폰트 준비와 문자 코드에서만 발목을 잡히기 쉽다.

10. 정리

Media Foundation으로 MP4 동영상의 모든 프레임에 이미지나 문자를 구워 넣을 때는 이 4가지로 나누어 생각하면 전망이 좋아집니다.

  • 꺼낸다: IMFSourceReader
  • 그린다: GDI+Direct2D / DirectWrite
  • 인코더가 받기 쉬운 형태로 고친다: NV12
  • 다시 쓴다: IMFSinkWriter

그리고 「.cpp 1개에 전부 붙여서 그대로 동작하는 샘플」을 원한다면, 이번처럼

Source Reader -> RGB32 -> GDI+로 이미지 + HelloWorld -> BGRA to NV12 -> Sink Writer

라는 구성은 꽤 명료합니다.

실제 운영에서 다음으로 확장한다면 이 순서로 생각하면 무너지지 않습니다.

  1. 음성 remux를 추가한다
  2. GDI+Direct2D / DirectWrite로 대체한다
  3. NV12 변환을 Video Processor MFT나 GPU 쪽으로 옮긴다
  4. 장시간·고해상도용으로 D3D11 surface 기반으로 나아간다
  5. 재사용성이 필요하면 custom MFT로 분리한다

갑자기 전부 다 넣으려 하면 COM, stride, 색공간, 서페이스 관리가 한꺼번에 밀려듭니다. 처음에는 단계를 나누어 통과시키고, 나중에 필요한 부분만 강화하는 쪽이 설계도 디버깅도 훨씬 편합니다.

실제 운영에서 확장하는 순서음성remux를 추가하고 GDI+를 Direct2D와 DirectWrite로 대체하고 NV12 변환을 Video Processor MFT나 GPU 쪽으로 옮기고 장시간이나 고해상도용으로 D3D11 surface 기반으로 나아가고 재사용성이 필요하면 custom MFT로 분리한다는 순서로 확장하면 무너지지 않음을 보여주는 그림.음성remux를 추가한다드로잉을 Direct2D로 대체변환을 MFT나 GPU로 옮긴다D3D11 surface 기반으로 나아간다필요하면 custom MFT로 분리한다

그림 20: 전부 다 넣는 것을 피하고 이 순서로 한 단계씩 강화해 간다.

11. 관련 글

12. 참고 자료

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

Media Foundation으로 MP4의 각 프레임에 이미지와 문자를 구워 넣는 기본 흐름은 무엇인가요?
기본형은 「Source Reader로 디코드 -> 비압축 프레임에 합성 -> 필요하면 색 변환 -> Sink Writer로 재인코딩」입니다. 이미지나 문자를 놓는 처리 자체는 Media Foundation의 일이 아니라, GDI+나 Direct2D/DirectWrite, WIC 같은 드로잉 API의 일입니다. 첫 1개를 움직이고 싶다면 Source Reader -> RGB32 -> GDI+로 드로잉 -> NV12 -> Sink Writer라는 구성이 명료하고, 속도나 확장성을 우선한다면 D3D11/DXGI surface와 Direct2D/DirectWrite를 쓰는 구성으로 옮기면 확장 여지가 있습니다.
RGB32 프레임을 그대로 H.264 인코딩에 넘길 수 있나요?
항상 넘길 수 있는 건 아닙니다. Microsoft의 H.264 인코더는 I420/IYUV/NV12/YUY2/YV12 같은 YUV 계열 입력을 전제로 하는 경우가 많아서, 그리기 편한 RGB32/ARGB32로 합성한 뒤에 변환 단이 필요해지기 쉽습니다. Video Processor MFT를 끼워 RGB32에서 NV12로 변환하거나, 직접 RGB에서 NV12로의 변환을 넣습니다. 또한 NV12는 4:2:0이므로 프레임의 너비와 높이가 짝수여야 합니다.
오버레이 드로잉은 GDI+와 Direct2D 중 어느 쪽을 써야 하나요?
첫 구현에서는 이미지 로드와 문자 드로잉이 가능하고 추가 준비가 적은 GDI+가 1파일 완결 샘플에 적합합니다. 한편 장시간 동영상, 4K, 대량 처리 용도에서는 D3D11+Direct2D+DirectWrite 쪽이 성능면에서 유리해지는 경우가 있습니다. 먼저 GDI+로 전체를 통과시키고, 속도를 짜내는 단계에서 Direct2D/DirectWrite로 바꾸고, 색 변환을 Video Processor MFT나 GPU 쪽으로 옮기는 단계적인 진행 방식이 설계를 무너뜨리지 않고 확장하기 쉽습니다.
IMFSourceReader의 ReadSample을 쓸 때 주의점은 무엇인가요?
ReadSample은 S_OK를 반환해도 sample이 nullptr이 되는 경우가 있습니다. 전형적인 예는 MF_SOURCE_READERF_STREAMTICK이나 MF_SOURCE_READERF_ENDOFSTREAM 같은 스트림 이벤트입니다. 그래서 루프에서는 HRESULT, flags, sample 이 세 가지를 모두 갖춰 확인할 필요가 있습니다. 또한 타임스탬프는 100ns 단위이며, duration은 고정 fps를 전제로 매번 정해진 값을 더하기보다 입력 sample의 timestamp와 duration을 최대한 그대로 이어받는 쪽이 흐트러지지 않습니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기