Media Foundation으로 MP4에 이미지와 텍스트를 오버레이하는 방법
· 업데이트: · Go Komura · 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까지 한 샘플에 넣을 수도 있지만, 글의 주제는 「각 프레임에 이미지와 텍스트를 합성하는 것」이므로 우선 거기에 한정합니다.
flowchart TB
accTitle: 이 샘플의 범위를 좁히는 방법
accDescr: 이 글의 샘플은 먼저 그대로 붙여 동작시키는 것을 우선해 영상만 재인코딩하는 구성이며, 음성 remux는 주제인 합성을 통과시킨 뒤의 확장으로 미룬다는 것을 나타내는 그림.
fo1["먼저 붙여 동작시키는 것을 우선"] --> fo2["영상만 재인코딩"]
fo2 --> fo3["각 프레임 합성에 한정"]
fo1 -.-> fo4["음성 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는 영상의 구워 넣기를 먼저 안정시킨 뒤에 추가해야 할 절차로 자리매김되어 있습니다.
flowchart LR
accTitle: MP4에 이미지·문자 구워 넣기(Media Foundation)
accDescr: IMFSourceReader에서의 RGB32 디코드와 GDI+에 의한 합성, RGB 계열과 NV12의 변환이 H.264 인코더로의 입력으로 필요해진다는 것, IMFSinkWriter에 의한 MP4 출력, stride와 top-down/bottom-up의 흡수, Direct2D/DirectWrite나 Video Processor MFT, custom MFT로의 확장, 음성 remux를 후단에 두는 진행 방식까지의 관계를 보여주는 그림.
video_overlay_compositing["동영상 프레임에 이미지·텍스트 합성"]
imfsinkwriter["IMFSinkWriter(Sink Writer)"]
imfsourcereader["IMFSourceReader(Source Reader)"]
gdiplus["GDI+"]
direct2d_directwrite["Direct2D / DirectWrite"]
video_stride["stride(이미지 행 바이트 수)"]
imf2dbuffer_lock2d["IMF2DBuffer::Lock2D"]
top_down_bottom_up_orientation["top-down / bottom-up(이미지 상하 방향)"]
mfvideoformat_rgb32["MFVideoFormat_RGB32"]
h264_video_encoder["H.264 Video Encoder(Media Foundation)"]
nv12_pixel_format["NV12 픽셀 형식"]
rgb_to_nv12_conversion["BGRA에서 NV12로의 색 변환"]
video_processor_mft["Video Processor MFT"]
readsample["IMFSourceReader::ReadSample"]
null_sample_result["ReadSample의 null 샘플"]
audio_remux["오디오 리먹스"]
custom_mft["custom MFT"]
media_foundation["Media Foundation"]
video_overlay_compositing -->|"이용한다"| imfsourcereader
video_overlay_compositing -->|"이용한다"| gdiplus
direct2d_directwrite -.->|"권장되는 대응"| video_overlay_compositing
video_overlay_compositing -->|"전제로 한다"| video_stride
video_stride -->|"이용한다"| imf2dbuffer_lock2d
video_stride -->|"전제로 한다"| top_down_bottom_up_orientation
video_overlay_compositing -->|"이용한다"| mfvideoformat_rgb32
mfvideoformat_rgb32 -.->|"양립하지 않는다"| h264_video_encoder
h264_video_encoder -.->|"전제로 한다"| nv12_pixel_format
rgb_to_nv12_conversion -->|"전제로 한다"| mfvideoformat_rgb32
video_overlay_compositing -->|"이용한다"| rgb_to_nv12_conversion
video_processor_mft -->|"권장되는 대응"| rgb_to_nv12_conversion
imfsinkwriter -.->|"전제로 한다"| nv12_pixel_format
imfsinkwriter -.->|"이용한다"| h264_video_encoder
video_overlay_compositing -->|"전제로 한다"| imfsinkwriter
imfsourcereader -->|"이용한다"| readsample
readsample -->|"원인이 될 수 있다"| null_sample_result
video_overlay_compositing -->|"보다 먼저 해야 한다"| audio_remux
custom_mft -->|"권장되는 대응"| video_overlay_compositing
imfsourcereader -->|"전제로 한다"| media_foundation
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 20건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 이 문제가 조금 복잡한 이유
「동영상에 텍스트를 넣는다」는 실제로는 다음 네 이야기가 섞여 있습니다.
-
컨테이너와 코덱 이야기
mp4는 컨테이너이지 프레임 그 자체가 아닙니다. 내용은 대개H.264나H.265압축 데이터입니다. -
디코드 / 인코드 이야기 압축된 상태로는 일반적인 2D 드로잉 API로 텍스트나 PNG를 그대로 올릴 수 없습니다. 먼저 비압축 프레임으로 되돌려야 합니다.
-
그리기 이야기 텍스트, 로고, PNG의 투명 합성, 안티앨리어스가 있는 텍스트 그리기는 Media Foundation 본체의 역할이 아닙니다. 여기는
GDI+나Direct2D / DirectWrite / WIC의 일입니다. -
색 공간과 픽셀 형식 이야기 그리기 쉬운 형식과 인코더가 선호하는 형식은 일치하지 않습니다. 여기가 은근히 막히기 쉬운 지점입니다.
거칠게 한 줄로 말하면, 「Media Foundation으로 텍스트를 넣는다」가 아니라 「Media Foundation으로 프레임을 돌리고, 드로잉 API로 올린 뒤, 필요한 색 변환을 넣고 인코딩한다」고 생각하는 편이 가장 정리하기 쉽습니다.
flowchart TB
accTitle: 섞여 있는 네 가지 이야기
accDescr: 동영상에 텍스트를 넣는다는 요건에는 컨테이너와 코덱, 디코드와 인코드, 그리기, 색 공간과 픽셀 형식이라는 네 이야기가 섞여 있음을 나타내는 그림.
mixq["동영상에 텍스트를 넣는다"] --> t1["컨테이너와 코덱 이야기"]
mixq --> t2["디코드와 인코드 이야기"]
mixq --> t3["그리기 이야기"]
t3 -.-> t4["색 공간과 픽셀 형식 이야기"]
그림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 처리 이미지
flowchart LR
A[input.mp4] --> B[IMFSourceReader]
B --> C[비압축 프레임<br/>RGB32]
C --> D[GDI+로 이미지 + HelloWorld를 그림]
D --> E[BGRA -> NV12 변환]
E --> F[IMFSinkWriter]
F --> G[output.mp4]
B --> H[음성 샘플]
H --> I[그대로 복사<br/>또는 재인코딩]
I --> F
그림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로 받아 두는 편이 편합니다.
flowchart TB
accTitle: 어느 형식으로 받을지의 첫 선택
accDescr: 구현을 쉽게 하려면 RGB32나 ARGB32, 인코딩 효율을 우선하면 NV12 같은 YUV이지만, 텍스트나 PNG 합성은 RGB 계열이 생각하기 쉬우므로 처음에는 RGB 계열로 받는 편이 편하다는 것을 나타내는 그림.
rq1{"무엇을 우선할 것인가"}
rq1 -->|"구현의 쉬움"| rf1["RGB32 / ARGB32로 받는다"]
rq1 -->|"인코딩 효율"| rf2["NV12 등 YUV로 받는다"]
rf1 -.-> rf3["합성은 RGB 계열이 생각하기 쉽다"]
그림4: 망설이면 그리기 쉬움을 우선해 RGB 계열로 받기 시작합니다.
MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING을 켜면 Source Reader가 YUV -> RGB32 변환과 디인터레이스를 해 줍니다.
「먼저 프레임을 꺼내 다루고 싶다」는 단계에서는 편리하지만, 긴 동영상이나 고해상도에서는 무거워지기 쉬우므로, 제품에서 속도가 필요하면 나중에 구성을 다시 볼 가치가 있습니다.
flowchart TB
accTitle: ENABLE_VIDEO_PROCESSING의 이득과 대가
accDescr: MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING을 켜면 Source Reader가 YUV에서 RGB32로의 변환과 디인터레이스를 해 주지만, 긴 동영상이나 고해상도에서는 무거워지기 쉽고, 제품에서 속도가 필요하면 구성을 다시 볼 가치가 있음을 나타내는 그림.
ev1["플래그를 켠다"] --> ev2["YUV에서 RGB32로의 변환을 맡긴다"]
ev1 --> ev3["디인터레이스도 맡긴다"]
ev2 -.-> ev4["장시간·고해상도에서는 무거워지기 쉽다"]
그림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를 끼우는 구성도 유력합니다.
flowchart TB
accTitle: RGB에서 NV12로 잇는 두 길
accDescr: 그리기 쉬운 RGB32나 ARGB32로 합성한 뒤 Video Processor MFT를 끼워 변환하거나, 직접 RGB에서 NV12로 변환하는 둘 중 하나가 필요하고, 이 샘플은 1파일 완결을 우선해 자체 변환을 골랐음을 나타내는 그림.
cv1["RGB 계열로 합성을 끝냈다"] --> cv2["Video Processor MFT로 변환"]
cv1 --> cv3["직접 NV12로 변환"]
cv3 -.-> cv4["샘플은 1파일 완결을 우선"]
cv2 -.-> cv5["제품에서는 유력한 구성"]
그림6: 변환 단은 반드시 필요하다는 전제에서, 어디에 둘지만 고릅니다.
4.4 출력은 IMFSinkWriter로 쓴다
동영상 출력은 IMFSinkWriter가 다루기 쉽습니다.
생각은 단순합니다.
- 출력 스트림 형 … 파일에 쓰고 싶은 형식
예:
MFVideoFormat_H264 - 입력 스트림 형 … 앱이
Sink Writer에 넘기는 형식 예:MFVideoFormat_NV12
을 나눠 설정합니다.
즉 Sink Writer에서 보면
- 앱 쪽은
NV12비압축 프레임을 넘긴다 Sink Writer는 그것을 H.264로 인코딩해 MP4에 쓴다
는 관계가 됩니다.
flowchart TB
accTitle: Sink Writer 입출력 형의 역할 분담
accDescr: Sink Writer에는 앱이 넘기는 입력 스트림 형으로 NV12를, 파일에 쓰고 싶은 출력 스트림 형으로 H.264를 나눠 설정하고, Sink Writer가 인코딩을 맡아 MP4에 쓴다는 것을 나타내는 그림.
ap1["앱이 NV12 프레임을 넘긴다"] --> sw1["Sink Writer"]
sw1 --> sw2["H.264로 인코딩"]
sw2 --> sw3["MP4에 쓴다"]
sw1 -.-> sw4["입력 형과 출력 형을 나눠 설정"]
그림7: 넘기는 형식과 쓰이는 형식을 나눠 설정하는 것이 Sink Writer의 방식입니다.
4.5 음성은 처음에는 나눠 생각하면 정리하기 쉽다
동영상에 로고나 텍스트만 넣고 음성 자체는 바꾸고 싶지 않은 경우는 꽤 많습니다.
실무에서는
- 영상 stream만
Source Reader -> 합성 -> Sink Writer - 음성 stream은 compressed 그대로 remux
구성이 쓰기 쉽습니다.
다만 이번 샘플은 프레임에 이미지와 텍스트를 합성하는 곳에 초점을 맞추므로 출력은 영상만 있는 MP4입니다. 음성을 남기는 판은 이후 확장 단계에서 더하는 편이 전체를 따라가기 쉽습니다.
flowchart TB
accTitle: 영상과 음성을 나눠 생각한다
accDescr: 실무에서는 영상 스트림만 Source Reader에서 합성을 거쳐 Sink Writer로 흘리고, 음성 스트림은 압축 그대로 remux하는 구성이 쓰기 쉽고, 이 샘플은 초점을 좁히기 위해 출력을 영상만으로 했음을 나타내는 그림.
vs1["영상 스트림"] --> vs2["합성해 Sink Writer로"]
as1["음성 스트림"] --> as2["압축 그대로 remux"]
as2 -.-> as3["샘플에서는 다루지 않는다"]
그림8: 바꾸고 싶은 것이 영상뿐이라면 음성은 건드리지 않고 그릇째 옮깁니다.
5. 이 샘플의 전제와 사용법
이 코드의 전제는 이렇습니다.
- Windows 10 / 11
- Visual Studio 2022의 C++ 콘솔 앱
x64빌드- 이
.cpp파일은 미리 컴파일된 헤더를 사용하지 않는다 - 입력 동영상의 너비와 높이는 짝수
- 입력은 일반적인 MP4 동영상 파일
- 출력은 영상만 있는 MP4
- 이미지는 PNG / JPEG / BMP / GIF 등 GDI+가 읽을 수 있는 형식
NV12는 4:2:0이므로 너비·높이가 짝수여야 합니다.
그래서 이 샘플에서는 조건을 충족하지 않으면 명시적으로 오류로 멈춥니다.
flowchart TB
accTitle: 너비와 높이가 짝수라는 전제
accDescr: NV12는 4:2:0 서브샘플링이므로 입력 동영상의 너비와 높이가 짝수여야 하고, 이 샘플에서는 조건을 충족하지 않으면 명시적으로 오류로 멈춘다는 것을 나타내는 그림.
nvq1["NV12는 4:2:0"] --> nvq2["너비와 높이는 짝수가 필요"]
nvq2 --> nvq3{"입력은 짝수인가"}
nvq3 -->|"짝수"| nvq4["처리를 계속한다"]
nvq3 -->|"홀수가 포함"| nvq5["명시적으로 오류로 멈춘다"]
그림9: 조용히 깨지기보다, 전제를 충족하지 않는 입력은 입구에서 멈춥니다.
5.1 사용법
- Visual Studio에서 Console App을 만든다
- 이
.cpp를 통째로 붙인다 - 그
.cpp의 미리 컴파일된 헤더를 「사용하지 않음」으로 한다 x64로 빌드한다- 다음과 같이 실행한다
OverlayMp4.exe input.mp4 overlay.png output.mp4
input.mp4원본 동영상overlay.png겹칠 이미지output.mp4출력 경로
문자열은 코드 앞머리의 kOverlayText에 고정으로 HelloWorld를 넣었습니다.
위치와 크기도 코드 안 상수를 건드리면 바꿀 수 있습니다. 명령줄 인자로 넘기도록 바꾸는 개조는 9.5에 두었습니다.
5.2 올바르게 동작했는지 확인한다
「오류 없이 끝났다」와 「올바르게 합성됐다」는 별개입니다. 다음 네 가지를 순서대로 보면 대부분의 실패를 찾을 수 있습니다.
- 종료 시 프레임 수를 본다. 이 샘플은 처리를 마치면
Done. frames=에 이어 쓴 프레임 수를 출력합니다. 입력 동영상의 총 프레임 수와 크게 어긋나면ReadSample루프 어딘가에서 놓친 것입니다 - 출력 파일의 기본 정보를 입력과 비교한다. 탐색기에서 출력 MP4를 오른쪽 클릭하고 속성 > 자세히를 열면 길이, 프레임 너비, 프레임 높이, 프레임 율이 나옵니다. 입력과 길이가 맞지 않으면 timestamp 처리를 의심합니다(7.4)
- 앞·중간·끝 세 곳을 눈으로 본다. 첫 프레임만 확인하고 안심하면 중간에 오버레이가 사라지는 버그를 놓칩니다. 같은 시각의 정지 이미지를 입력과 출력 양쪽에서 잘라 나란히 보는 것이 확실하고, 그 절차는 「Media Foundation으로 MP4의 지정 시각에서 정지 이미지를 잘라내는 방법」에 정리되어 있습니다
- 색이 이상한지 본다. 피부나 하늘이 부자연스러우면
BgraToNv12의 계수 선택(BT.601과 BT.709)이 입력과 맞지 않을 수 있습니다
처음 시험할 때는 몇 초짜리 짧은 MP4와 윤곽이 분명한 PNG를 쓰는 것을 권합니다. 긴 동영상으로 첫 샘플을 통과시키려 하면 원인 분리에 시간이 걸립니다.
flowchart TB
accTitle: 올바르게 동작했는지의 확인 절차
accDescr: 오류 없이 끝난 것과 올바르게 합성된 것은 별개이므로, 프레임 수 대조, 출력 파일 기본 정보 비교, 앞과 중간과 끝 육안 확인, 색 이상 확인이라는 네 가지를 순서대로 보는 흐름을 나타내는 그림.
ck1["프레임 수를 입력과 대조"] --> ck2["속성에서 길이나 크기를 비교"]
ck2 --> ck3["앞·중간·끝을 육안 확인"]
ck3 --> ck4["색의 부자연스러움을 확인"]
ck4 -.-> ck5["이상하면 계수 선택을 의심"]
그림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 |
wmain의 while 루프 |
ReadSample의 HRESULT / flags / sample을 보면서 1프레임씩 돌린다 |
7.3 / 7.4 |
3장의 처리 이미지와 나란히 보면 CopySampleToTopDownBgra가 「비압축 프레임」, DrawOverlay가 「GDI+로 그리기」, BgraToNv12가 「BGRA에서 NV12 변환」에 각각 대응합니다.
flowchart TB
accTitle: 읽어야 할 중심 3함수
accDescr: 긴 코드 가운데 실제로 읽어야 할 중심은 CopySampleToTopDownBgra와 DrawOverlay와 BgraToNv12 셋과 wmain 루프이며, 각각 비압축 프레임으로의 정규화, 그리기, NV12 변환에 대응함을 나타내는 그림.
lp1["wmain 루프가 1프레임씩 돌린다"] --> fn1["CopySampleToTopDownBgra"]
fn1 --> fn2["DrawOverlay"]
fn2 --> fn3["BgraToNv12"]
fn1 -.-> ro1["비압축 프레임으로 정규화"]
fn2 -.-> ro2["이미지와 텍스트를 그린다"]
fn3 -.-> ro3["인코딩용으로 맞춘다"]
그림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, ¤tType), "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, ¤tLength), "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,
×tamp,
&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와 일치하지 않는 경우가 있다 - 위아래 방향이 뒤집혀 있는 경우가 있다
IMF2DBuffer와IMFMediaBuffer에서 다루기가 조금 다르다
그래서 이 코드에서는 일단 top-down BGRA 버퍼로 정규화한 뒤에 그립니다. 여기를 먼저 맞춰 두면 그리기 쪽 코드를 꽤 단순하게 만들 수 있습니다.
flowchart TB
accTitle: 그리기 전에 정규화하는 이유
accDescr: stride가 너비의 4배와 일치하지 않음, 위아래가 뒤집힘, IMF2DBuffer와 IMFMediaBuffer에서 다루기가 다르다는 흔들림을 top-down BGRA 버퍼로의 정규화로 먼저 흡수한 뒤 그린다는 것을 나타내는 그림.
ir1["stride가 일치하지 않는다"] --> nr1["top-down BGRA로 정규화"]
ir2["위아래가 뒤집힌 경우가 있다"] --> nr1
ir3["버퍼 종류에 따라 다루기가 다르다"] --> nr1
nr1 --> nr2["그리기 코드가 단순해진다"]
그림12: 메모리상의 흔들림을 한곳에서 흡수하면, 그리는 쪽은 아무것도 몰라도 됩니다.
7.3 ReadSample은 HRESULT뿐 아니라 flags와 sample을 본다
ReadSample은 S_OK여도 sample == nullptr인 경우가 있습니다.
전형적인 예는
MF_SOURCE_READERF_STREAMTICKMF_SOURCE_READERF_ENDOFSTREAM- 그 밖의 스트림 이벤트
입니다.
그래서 루프에서는 HRESULT, flags, inputSample 셋을 맞춰 봐야 합니다.
특히 STREAMTICK과 ENDOFSTREAM을 놓치면 이후 타임라인 처리가 어긋나기 쉽습니다.
flowchart TB
accTitle: ReadSample에서 볼 세 점
accDescr: ReadSample은 S_OK여도 sample이 nullptr인 경우가 있고 STREAMTICK이나 ENDOFSTREAM 같은 스트림 이벤트가 전형이므로, 루프에서는 HRESULT와 flags와 sample 셋을 맞춰 확인한다는 것을 나타내는 그림.
rs1["ReadSample이 반환된다"] --> rs2["HRESULT를 확인"]
rs2 --> rs3["flags를 확인"]
rs3 --> rs4["sample 유무를 확인"]
rs4 -.-> rs5["S_OK여도 nullptr일 수 있다"]
그림13: 반환값 하나로 판단하지 않고, 세 점 세트로 한 프레임을 다룹니다.
7.4 timestamp와 duration은 입력을 이어받는 편이 안전하다
타임스탬프는 100ns 단위입니다.
또한 duration은 따로 IMFSample에서 가져와야 합니다.
고정 fps 전제로 매번 무조건 더하기보다 입력 sample의 timestamp / duration을 가능한 한 이어받는 편이 덜 어긋납니다.
이 샘플에서도 duration을 얻지 못할 때만 fps에서 계산한 기본값으로 떨어집니다.
flowchart TB
accTitle: timestamp와 duration 다루기
accDescr: 고정 fps 전제로 무조건 더하지 않고 입력 sample의 timestamp와 duration을 가능한 한 이어받으며, duration을 얻지 못할 때만 fps에서 계산한 기본값으로 떨어진다는 것을 나타내는 그림.
ts1["입력 sample에서 꺼낸다"] --> ts2{"duration을 얻었는가"}
ts2 -->|"얻었다"| ts3["그대로 이어받는다"]
ts2 -->|"얻지 못한다"| ts4["fps 유래 기본값을 쓴다"]
ts3 -.-> ts5["무조건 더하기보다 덜 어긋난다"]
그림14: 시각은 직접 만들지 않고 입력에서 이어받는 것이 기본 자세입니다.
7.5 GDI+는 도입이 가볍지만 장시간·고해상도에는 다음 단계가 있다
GDI+는 1파일 완결 샘플에 꽤 맞지만, 장시간 동영상이나 4K를 대량 처리하는 용도에서는 D3D11 + Direct2D + DirectWrite가 유리해질 수 있습니다.
- 우선
GDI+로 전체를 통과시킨다 - 그다음 필요하면
Direct2D / DirectWrite로 바꾼다 - 색 변환은
Video Processor MFT나 GPU 쪽으로 옮긴다
단계로 진행하면 설계를 깨지 않고 확장하기 쉽습니다.
flowchart TB
accTitle: 드로잉 API의 단계적 진행
accDescr: 먼저 GDI+로 전체를 통과시키고, 필요하면 Direct2D와 DirectWrite로 바꾸며, 색 변환은 Video Processor MFT나 GPU 쪽으로 옮긴다는, 설계를 깨지 않는 단계적 진행을 나타내는 그림.
gd1["먼저 GDI+로 전체를 통과시킨다"] --> gd2["필요하면 Direct2D로 바꾼다"]
gd2 --> gd3["색 변환을 MFT나 GPU로 옮긴다"]
gd1 -.-> gd4["장시간·4K 대량 처리가 다음 단계"]
그림15: 도입의 가벼움으로 시작해, 성능이 필요한 곳부터 순서대로 바꿉니다.
7.6 이 샘플은 영상에만 한정한다
음성까지 같은 글에 전부 넣으면 이야기의 축이 흩어지기 쉽습니다. 그래서 이 샘플에서는 영상 프레임에 이미지와 텍스트를 합성하는 것에 초점을 맞추고, 출력은 영상만 있는 MP4로 했습니다.
실무에서는 다음 단계로
- 영상만
Source Reader -> 합성 -> Sink Writer - 음성은 compressed 그대로 remux
구성으로 늘리는 편이 다루기 쉽습니다.
8. 「주어진 동영상 데이터」가 파일이 아니라 메모리상의 MP4 바이트열이면
이번 코드는 MFCreateSourceReaderFromURL을 쓰므로 입력은 파일 경로입니다.
다만 요건이 「API로 받은 mp4 바이트열에 같은 일을 하고 싶다」여도 생각은 바뀌지 않습니다. 바꾸는 것은 입구뿐입니다.
IStream이나 독자 스트림을 마련한다- 그것을
IMFByteStream으로Source Reader에 넘긴다 - 이후는 마찬가지로
RGB32 -> 그리기 -> NV12 -> Sink Writer
즉 본질은 동영상 데이터를 어떻게 들고 있느냐가 아니라, 디코드 후 각 프레임에 어떻게 그리느냐에 있습니다.
flowchart TB
accTitle: 입력이 바이트열이어도 입구만 바꾼다
accDescr: 입력이 파일 경로면 MFCreateSourceReaderFromURL, 메모리상의 MP4 바이트열이면 IMFByteStream을 마련해 Source Reader에 넘기고, 이후 RGB32에서 그리기, NV12, Sink Writer까지의 흐름은 같음을 나타내는 그림.
in1["파일 경로"] --> sr1["Source Reader"]
in2["메모리상의 바이트열"] --> bs1["IMFByteStream으로 만든다"]
bs1 --> sr1
sr1 --> same1["이후 흐름은 같다"]
그림16: 데이터를 들고 있는 방식이 바뀌어도, 바뀌는 것은 입구 한 단뿐입니다.
9. 제품에서 늘린다면
9.1 음성 remux를 더한다
첫 확장처로 가장 실무적인 것은 음성을 그대로 남기는 것입니다. 영상만 재인코딩하고 음성은 compressed 그대로 같은 형식으로 다시 쓰는 구성이면, 요건을 충족하면서 구현을 크게 늘리지 않아도 됩니다.
Sink Writer는 압축된 입력을 같은 형식 그대로 출력에 쓰는 조합을 재인코딩 없는 remux용으로 명시적으로 지원합니다. 더하는 곳은 다음 세 곳입니다.
- 음성 스트림을 압축 그대로 받는다.
reader->SetStreamSelection(MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE)로 켜고,GetNativeMediaType으로 얻은 형을 그대로SetCurrentMediaType에 넘깁니다. 디코드시키고 싶지 않을 때는 native 형을 지정하는 것이 Source Reader 쪽 방식입니다 - Sink Writer에 그 형을 입력에도 출력에도 설정한다.
writer->AddStream(audioType.Get(), &audioStreamIndex)와writer->SetInputMediaType(audioStreamIndex, audioType.Get(), nullptr)에 같은 미디어 형을 넘깁니다 - timestamp 기준을 영상과 공통으로 한다. 6.2 코드가 영상에서 쓰는
firstTimestamp를 음성 샘플에서도 같은 방식으로 뺍니다. 여기를 서로 다른 기준으로 두면 소리와 영상이 어긋납니다
ReadSample 호출도 영상만 지정하는 현재 형태에서 MF_SOURCE_READER_ANY_STREAM을 쓰고, 반환값의 stream index로 나누는 형태로 바꿉니다.
참고로 Sink Writer는 인코더가 제공하지 않는 한 음성 리샘플링이나 영상 크기 변경·프레임 레이트 변환을 하지 않습니다. 입력 음성 형식을 MP4 싱크가 받지 못하면 remux가 아니라 재인코딩이 필요합니다.
flowchart TB
accTitle: 음성 remux에서 더하는 세 곳
accDescr: 음성을 그대로 남기려면 음성 스트림을 압축 그대로 받고, Sink Writer에 같은 형을 입력에도 출력에도 설정하며, timestamp 기준을 영상과 공통으로 한다는 세 곳을 더한다는 것을 나타내는 그림.
ar1["음성을 압축 그대로 받는다"] --> ar2["같은 형을 입력과 출력에 설정"]
ar2 --> ar3["timestamp 기준을 영상과 공통으로"]
ar3 -.-> ar4["기준을 나누면 소리가 어긋난다"]
그림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로 잘라내는 편이 실무에서는 진행하기 쉬운 경우가 많습니다.
flowchart TB
accTitle: custom MFT를 검토하는 시점
accDescr: 우선 Source Reader와 합성과 Sink Writer로 올바르게 돌리고, 여러 앱이나 파이프라인에서 같은 오버레이 처리를 돌려 쓰고 싶어지면 custom MFT로 잘라낸다는 판단 흐름을 나타내는 그림.
mf1["우선 지금 구성으로 올바르게 돌린다"] --> mf2{"돌려 쓰고 싶어졌는가"}
mf2 -->|"여러 앱에서 쓰고 싶다"| mf3["custom MFT로 잘라낸다"]
mf2 -->|"한 구현으로 충분하다"| mf4["지금 구성 그대로"]
mf3 -.-> mf5["구현·등록·디버깅 난도는 올라간다"]
그림18: 부품화는 깔끔하지만, 필요해진 뒤에 해도 늦지 않습니다.
9.5 문자열을 인자화하고 일본어를 그릴 수 있게 한다
샘플 문자열은 kOverlayText에 HelloWorld로 고정되어 있습니다. 장비 번호나 작업자 이름을 합성하는 용도에서는 먼저 여기를 인자로 만들고 싶어집니다. 일본어를 그릴 때는 폰트도 바꿔야 합니다.
변경은 네 곳입니다.
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으로 받으므로 이 문제는 일어나지 않습니다- 서체는 환경에 들어 있다고 단정할 수 없습니다. 위 코드처럼 반드시 폴백을 마련합니다. 폰트가 없는 환경에서 말없이 다른 서체가 되면, 레이아웃이 어긋난 원인을 쫓기 어려워집니다
flowchart TB
accTitle: 일본어를 그리기 위한 변경점
accDescr: 문자열을 인자화해 일본어를 그리려면 폰트 이름 상수 추가, DrawOverlay에 인자 추가, 폴백이 있는 폰트 생성으로의 교체, wmain에서 인자 수신이라는 네 곳을 바꾸고, 글자 깨짐과 폰트 부재에 주의한다는 것을 나타내는 그림.
jp1["폰트 이름 상수를 더한다"] --> jp2["DrawOverlay에 인자를 더한다"]
jp2 --> jp3["폴백을 두고 폰트를 생성"]
jp3 --> jp4["wmain에서 인자를 받아 넘긴다"]
jp3 -.-> jp5["없는 서체는 기본으로 떨어뜨린다"]
그림19: 변경은 네 곳이고, 폰트 마련과 문자 코드만 발목을 잡기 쉽습니다.
10. 정리
Media Foundation으로 MP4 동영상의 모든 프레임에 이미지나 텍스트를 합성할 때는, 이 넷으로 나눠 생각하면 전망이 좋아집니다.
- 꺼낸다:
IMFSourceReader - 그린다:
GDI+또는Direct2D / DirectWrite - 인코더가 받기 쉬운 형태로 고친다:
NV12등 - 다시 쓴다:
IMFSinkWriter
그리고 「.cpp 하나에 전부 붙여 그대로 동작하는 샘플」이 필요하면, 이번처럼
Source Reader -> RGB32 -> GDI+로 이미지 + HelloWorld -> BGRA to NV12 -> Sink Writer
구성이 꽤 자연스럽습니다.
제품에서 다음에 늘린다면 이 순서로 생각하면 덜 무너집니다.
- 음성 remux를 더한다
GDI+를Direct2D / DirectWrite로 바꾼다NV12변환을Video Processor MFT나 GPU 쪽으로 옮긴다- 장시간·고해상도용으로
D3D11 surface기반으로 나아간다 - 재사용이 필요하면 custom
MFT로 잘라낸다
처음부터 전부 넣으면 COM, stride, 색 공간, 서피스 관리가 한꺼번에 몰려옵니다. 처음에는 단을 나눠 통과시키고, 나중에 필요한 곳만 강하게 하는 편이 설계도 디버깅도 꽤 수월합니다.
flowchart TB
accTitle: 제품에서 늘리는 순서
accDescr: 음성 remux를 더하고, GDI+를 Direct2D와 DirectWrite로 바꾸며, NV12 변환을 Video Processor MFT나 GPU 쪽으로 옮기고, 장시간이나 고해상도용으로 D3D11 surface 기반으로 나아가며, 재사용이 필요하면 custom MFT로 잘라내는 순서로 늘리면 덜 무너진다는 것을 나타내는 그림.
ex1["음성 remux를 더한다"] --> ex2["그리기를 Direct2D로 바꾼다"]
ex2 --> ex3["변환을 MFT나 GPU로 옮긴다"]
ex3 --> ex4["D3D11 surface 기반으로 나아간다"]
ex4 --> ex5["필요하면 custom MFT로 잘라낸다"]
그림20: 전부 넣기를 피하고, 이 순서로 한 단씩 강하게 합니다.
11. 관련 글
12. 참고 자료
- 이 글의 샘플 코드 일체(1파일 완결
.cpp와 CMake 빌드 구성) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/media-foundation-overlay-image-text-on-mp4-frames - Microsoft Learn: Using the Source Reader to Process Media Data
- Microsoft Learn: MFCreateSourceReaderFromByteStream
- Microsoft Learn: MFCreateMFByteStreamOnStream
- Microsoft Learn: IMFSourceReader::SetCurrentMediaType
- Microsoft Learn: MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING
- Microsoft Learn: MF_SOURCE_READER_ENABLE_ADVANCED_VIDEO_PROCESSING
- Microsoft Learn: IMFSourceReader::ReadSample
- Microsoft Learn: Working with Media Samples
- Microsoft Learn: IMF2DBuffer::Lock2D
- Microsoft Learn: Video Subtype GUIDs
- Microsoft Learn: H.264 Video Encoder
- Microsoft Learn: Video Processor MFT
- Microsoft Learn: Using the Sink Writer
- Microsoft Learn: Tutorial: Using the Sink Writer to Encode Video
- Microsoft Learn: Interoperability Overview (Direct2D)
- Microsoft Learn: Text Rendering with Direct2D and DirectWrite
- Microsoft Learn: Writing a Custom MFT
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Media Foundation에서 YUV를 RGB로 변환하는 방법
Media Foundation에서 YUV 프레임을 RGB로 변환하는 방법을 Source Reader의 자동 변환과 NV12/YUY2 직접 변환, stride, 색공간 관점에서 정리합니다.
Media Foundation으로 MP4의 지정 시각에서 정지 이미지를 잘라내는 방법
Source Reader로 MP4의 지정 시각에 가까운 프레임을 꺼내고, stride와 RGB32의 alpha를 맞춘 뒤 PNG로 저장하는 구현 절차를 정리합니다.
Media Foundation 입문 - COM 관점으로 API 이해하기
Media Foundation이 무엇인지, COM, HRESULT, IMFSourceReader, MFT 등 Windows 미디어 API의 기본 용어와 함께, 먼저 짚어야 할 순서로 정리합니다.
Win32 스레드 풀 API ── CreateThreadpoolWork로 「스레드를 만들지 않는」 병렬 처리
네이티브 코드에서 CreateThread를 마구 늘리고 있지는 않은가요. Vista에서 개편된 Win32 스레드 풀 API의 work·timer·wait·io 네 객체, 클린업 그룹, 콜백에서 해서는 안 되는 일까지 1차 정보를 바탕으로 설명합니다.
Named Pipe 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows의 프로세스 간 통신의 정석인 Named Pipe를 실무 관점에서 해설합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 처리하는 서버 설계, ACL과 impersonation 보안, .NET NamedPipeStream까지 1차 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
Media Foundation, GDI+, Direct2D / DirectWrite, 색 변환, 동영상 출력을 아우르는 Windows 앱 구현과 직결되는 주제입니다.
기술 상담 & 설계 리뷰
1파일 구현에서 제품용 구성으로 어떻게 확장할지, 음성 remux나 GPU화를 어디서 나눌지의 설계 정리에도 맞습니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 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을 가능한 한 이어받는 편이 덜 어긋납니다.