Media Foundation으로 MP4에 이미지와 텍스트를 오버레이하는 방법

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

수정 이력(7건, 최종 수정 2026년 09월 03일)

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

permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635173)
파이프라인의 역할 분담이나 형식 변환, 확인 절차의 흐름을 그림으로도 따라갈 수 있도록, Mermaid 그림을 19개 추가했습니다(본문 500~750자당 1그림 규약에 맞춘 것입니다). 본문 문장은 바꾸지 않았습니다.
글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
외부 리뷰(1283건) 대응으로 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
코드의 어느 함수가 본문의 어느 절에 대응하는지를 나타내는 「코드 지도」를 추가했습니다. 올바르게 동작했는지 확인하는 절차(프레임 수 대조, 속성에서 비교, 세 곳 육안 확인, 색 이상에서 계수 선택을 의심)를 새로 두고, 문자열을 인자화해 일본어를 그릴 수 있게 하는 예와 음성 remux에서 더하는 세 곳을 덧붙였습니다. 맨 앞에 필요 환경과 용어표를 두었습니다.
본문 속 관련 글 링크 문구가 링크 대상의 현재 제목과 어긋나 있던 것을, 실제 제목에 맞췄습니다. 본문 내용은 바꾸지 않았습니다.
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635172)

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

Go Komura (2026). 「Media Foundation으로 MP4에 이미지와 텍스트를 오버레이하는 방법」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635172 https://comcomponent.com/ko/blog/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까지 한 샘플에 넣을 수도 있지만, 글의 주제는 「각 프레임에 이미지와 텍스트를 합성하는 것」이므로 우선 거기에 한정합니다.

이 샘플의 범위를 좁히는 방법이 글의 샘플은 먼저 그대로 붙여 동작시키는 것을 우선해 영상만 재인코딩하는 구성이며, 음성 remux는 주제인 합성을 통과시킨 뒤의 확장으로 미룬다는 것을 나타내는 그림.먼저 붙여 동작시키는 것을 우선영상만 재인코딩각 프레임 합성에 한정음성 remux는 이후 확장으로 미룸

그림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 이미지 한 줄이 메모리에서 차지하는 바이트 수입니다. 너비 × 4와 일치한다고 단정할 수 없고, 행 끝에 여백이 들어가는 경우가 있습니다

1. 먼저 결론

  • MP4의 각 프레임에 이미지나 텍스트를 넣는 기본형은 Source Reader로 디코드 -> 비압축 프레임에 합성 -> 필요하면 색 변환 -> Sink Writer로 재인코딩입니다.
  • 이미지나 텍스트를 그리는 처리 자체는 Media Foundation의 일이 아닙니다. 여기는 GDI+, Direct2D, DirectWrite, WIC 같은 드로잉 API로 생각하는 편이 자연스럽습니다.
  • MP4(H.264)로 되돌릴 거라면, 그리기 쉬운 RGB32 / ARGB32와 인코더가 받기 쉬운 NV12 / I420 / YUY2 사이를 잇는 변환 단이 필요해지기 쉽습니다.
  • 첫 샘플을 돌리려면 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. 이 문제가 조금 복잡한 이유

「동영상에 텍스트를 넣는다」는 실제로는 다음 네 이야기가 섞여 있습니다.

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

  2. 디코드 / 인코드 이야기 압축된 상태로는 일반적인 2D 드로잉 API로 텍스트나 PNG를 그대로 올릴 수 없습니다. 먼저 비압축 프레임으로 되돌려야 합니다.

  3. 그리기 이야기 텍스트, 로고, PNG의 투명 합성, 안티앨리어스가 있는 텍스트 그리기는 Media Foundation 본체의 역할이 아닙니다. 여기는 GDI+Direct2D / DirectWrite / WIC의 일입니다.

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

거칠게 한 줄로 말하면, 「Media Foundation으로 텍스트를 넣는다」가 아니라 「Media Foundation으로 프레임을 돌리고, 드로잉 API로 올린 뒤, 필요한 색 변환을 넣고 인코딩한다」고 생각하는 편이 가장 정리하기 쉽습니다.

섞여 있는 네 가지 이야기동영상에 텍스트를 넣는다는 요건에는 컨테이너와 코덱, 디코드와 인코드, 그리기, 색 공간과 픽셀 형식이라는 네 이야기가 섞여 있음을 나타내는 그림.동영상에 텍스트를 넣는다컨테이너와 코덱 이야기디코드와 인코드 이야기그리기 이야기색 공간과 픽셀 형식 이야기

그림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을 쓰는 구성이 이해하기 쉽습니다.

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

  • 구현을 쉽게 하려면 RGB32 또는 ARGB32
  • 인코딩 효율을 우선하면 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 한 파일에 넣기 쉽다

한편 장시간 동영상이나 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 올바르게 동작했는지 확인한다

「오류 없이 끝났다」와 「올바르게 합성됐다」는 별개입니다. 다음 네 가지를 순서대로 보면 대부분의 실패를 찾을 수 있습니다.

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

처음 시험할 때는 몇 초짜리 짧은 MP4와 윤곽이 분명한 PNG를 쓰는 것을 권합니다. 긴 동영상으로 첫 샘플을 통과시키려 하면 원인 분리에 시간이 걸립니다.

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

그림10: 오류 없음과 올바름은 별개이므로, 네 관점으로 순서대로 확인합니다.

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

6.1 코드 지도

먼저 지도를 내놓습니다. 긴 코드이지만, 실제로 읽어야 할 중심은 CopySampleToTopDownBgra, DrawOverlay, BgraToNv12 셋과 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을 보면서 1프레임씩 돌린다 7.3 / 7.4

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

읽어야 할 중심 3함수긴 코드 가운데 실제로 읽어야 할 중심은 CopySampleToTopDownBgra와 DrawOverlay와 BgraToNv12 셋과 wmain 루프이며, 각각 비압축 프레임으로의 정규화, 그리기, NV12 변환에 대응함을 나타내는 그림.wmain 루프가 1프레임씩 돌린다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 셋을 맞춰 봐야 합니다. 특히 STREAMTICKENDOFSTREAM을 놓치면 이후 타임라인 처리가 어긋나기 쉽습니다.

ReadSample에서 볼 세 점ReadSample은 S_OK여도 sample이 nullptr인 경우가 있고 STREAMTICK이나 ENDOFSTREAM 같은 스트림 이벤트가 전형이므로, 루프에서는 HRESULT와 flags와 sample 셋을 맞춰 확인한다는 것을 나타내는 그림.ReadSample이 반환된다HRESULT를 확인flags를 확인sample 유무를 확인S_OK여도 nullptr일 수 있다

그림13: 반환값 하나로 판단하지 않고, 세 점 세트로 한 프레임을 다룹니다.

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용으로 명시적으로 지원합니다. 더하는 곳은 다음 세 곳입니다.

  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에서 더하는 세 곳음성을 그대로 남기려면 음성 스트림을 압축 그대로 받고, Sink Writer에 같은 형을 입력에도 출력에도 설정하며, timestamp 기준을 영상과 공통으로 한다는 세 곳을 더한다는 것을 나타내는 그림.음성을 압축 그대로 받는다같은 형을 입력과 출력에 설정timestamp 기준을 영상과 공통으로기준을 나누면 소리가 어긋난다

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

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로 잘라낸다는 판단 흐름을 나타내는 그림.여러 앱에서 쓰고 싶다한 구현으로 충분하다우선 지금 구성으로 올바르게 돌린다돌려 쓰고 싶어졌는가custom MFT로 잘라낸다지금 구성 그대로구현·등록·디버깅 난도는 올라간다

그림18: 부품화는 깔끔하지만, 필요해진 뒤에 해도 늦지 않습니다.

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

샘플 문자열은 kOverlayTextHelloWorld로 고정되어 있습니다. 장비 번호나 작업자 이름을 합성하는 용도에서는 먼저 여기를 인자로 만들고 싶어집니다. 일본어를 그릴 때는 폰트도 바꿔야 합니다.

변경은 네 곳입니다.

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과 두 번의 DrawString에 넘기는 kOverlayText를 모두 overlayText.c_str()로 바꿉니다. 세 곳 모두 고치지 않으면 그림자만 옛 글자가 남습니다.

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);

일본어를 다룰 때 주의가 두 가지 있습니다.

  • .cpp에 리터럴로 일본어를 쓰면 소스를 UTF-8 with BOM으로 저장하거나 MSVC에 /utf-8을 붙여 빌드합니다. 여기를 빼면 글자가 깨집니다. 명령줄 인자로 넘기는 경우는 wmain이 UTF-16으로 받으므로 이 문제는 일어나지 않습니다
  • 서체는 환경에 들어 있다고 단정할 수 없습니다. 위 코드처럼 반드시 폴백을 마련합니다. 폰트가 없는 환경에서 말없이 다른 서체가 되면, 레이아웃이 어긋난 원인을 쫓기 어려워집니다
일본어를 그리기 위한 변경점문자열을 인자화해 일본어를 그리려면 폰트 이름 상수 추가, DrawOverlay에 인자 추가, 폴백이 있는 폰트 생성으로의 교체, wmain에서 인자 수신이라는 네 곳을 바꾸고, 글자 깨짐과 폰트 부재에 주의한다는 것을 나타내는 그림.폰트 이름 상수를 더한다DrawOverlay에 인자를 더한다폴백을 두고 폰트를 생성wmain에서 인자를 받아 넘긴다없는 서체는 기본으로 떨어뜨린다

그림19: 변경은 네 곳이고, 폰트 마련과 문자 코드만 발목을 잡기 쉽습니다.

10. 정리

Media Foundation으로 MP4 동영상의 모든 프레임에 이미지나 텍스트를 합성할 때는, 이 넷으로 나눠 생각하면 전망이 좋아집니다.

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

그리고 「.cpp 하나에 전부 붙여 그대로 동작하는 샘플」이 필요하면, 이번처럼

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의 일입니다. 첫 샘플을 돌리려면 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 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기