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

· · Media Foundation, C++, Windows 개발, GDI+, Direct2D, DirectWrite, H.264

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

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

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

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

1. 먼저 결론

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

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

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

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

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

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

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

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

3. 먼저 볼 정리표

방침 구성 적합한 장면 주의할 점
먼저 올바르게 돌린다 Source Reader -> RGB32 -> 합성 -> NV12 -> Sink Writer 배치 처리, 사내 도구, 초기 구현 CPU 측 복사나 변환이 늘기 쉽다
속도를 올린다 D3D11 / DXGI surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer 장시간 동영상, 고해상도, 대량 처리 D3D11과 DXGI 관리가 늘어난다
재사용 가능한 부품으로 한다 커스텀 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

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

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

4.1 입력은 IMFSourceReader로 받는다

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

여기서 맨 먼저 정해야 할 건 그리기 편한 형식으로 받을지, 인코더용 형식으로 받을지 입니다.

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

단, 문자나 PNG의 합성은 RGB 계열이 압도적으로 생각하기 쉬우므로 첫 수는 RGB32 / ARGB32를 받는 구성이 꽤 다루기 쉽습니다.

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

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

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

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

  • 이미지 로드가 가능
  • 문자 드로잉이 가능
  • 추가 준비가 비교적 적음
  • 콘솔 앱의 .cpp 1개로 담기 쉬움

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

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

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

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

그래서 구현에서는 다음 중 하나가 필요합니다.

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

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

4.4 출력은 IMFSinkWriter로 쓴다

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

사고방식은 단순해서,

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

를 나눠 설정합니다.

Sink Writer 시점에서 보면,

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

는 관계가 됩니다.

4.5 음성은 처음엔 따로 생각하면 정리가 쉽다

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

실무에서는

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

라는 구성이 꽤 쓰기 쉽습니다.

다만 이번 샘플은 프레임에 이미지와 문자를 구워 넣는 곳 에 초점을 맞추기 위해 출력은 영상만의 MP4 로 합니다.
음성을 남기는 버전은 이 다음 확장 단계에서 더하는 편이 전체를 쫓기 쉬워집니다.

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

이 코드는 다음 전제로 썼습니다.

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

NV12는 4:2:0이므로 폭·높이가 짝수 여야 합니다.
그래서 이 샘플에서는 조건을 만족하지 않으면 명시적으로 에러로 하고 있습니다.

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를 넣고 있습니다.
위치나 크기도 코드 내 상수를 건드리면 바꿀 수 있습니다.

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

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

    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,
            PixelFormat32bppRGB,
            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 버퍼로 정규화한 뒤 드로잉 합니다.
여기를 먼저 맞춰 두면 드로잉 쪽 코드를 꽤 자연스럽게 할 수 있습니다.

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

ReadSampleS_OK라도 sample == nullptr가 될 수 있습니다.
전형적인 예는,

  • MF_SOURCE_READERF_STREAMTICK
  • MF_SOURCE_READERF_ENDOFSTREAM
  • 그 외 스트림 이벤트

입니다.

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

7.4 timestamp와 duration은 입력을 물려받는 편이 안전

타임스탬프는 100ns 단위입니다.
또한 duration은 별도로 IMFSample에서 취할 필요가 있습니다.

고정 fps 전제로 매번 결정 가산하기보다 입력 sample의 timestamp / duration을 최대한 물려받는 편이 무너지기 어렵습니다.
이 샘플에서도 duration을 못 얻을 때만 fps에서 계산한 기본값으로 폴백합니다.

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

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

  • 먼저 GDI+로 전체를 통한다
  • 뒤에 필요해지면 Direct2D / DirectWrite로 교체한다
  • 색 변환은 Video Processor MFT나 GPU 쪽으로 옮긴다

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

7.6 이 샘플은 영상에만 좁히고 있다

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

실무에서는 다음 단계로

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

라는 구성으로 확장하는 게 다루기 쉽습니다.

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

이번 코드는 MFCreateSourceReaderFromURL을 쓰므로 입력은 파일 경로입니다.

단, 요건이 「API에서 받은 mp4 바이트 배열에 대해 같은 일을 하고 싶다」라면 사고방식은 바뀌지 않습니다.
바꾸는 건 입구뿐입니다.

  • IStream이나 자체 스트림을 준비한다
  • 그걸 IMFByteStream으로서 Source Reader에 넘긴다
  • 이후는 똑같이 RGB32 -> 드로잉 -> NV12 -> Sink Writer

즉 본질은 동영상 데이터를 어떻게 가지느냐가 아니라 디코드 후의 각 프레임에 어떻게 그려 넣을 것인가 입니다.

9. 본번으로 확장하려면

9.1 음성 remux를 더한다

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

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 커스텀 MFT는 「돌려 쓰고 싶은 동영상 효과」가 됐을 때 검토한다

Media Foundation에서는 효과를 IMFTransform으로 구현할 수 있습니다.
그래서 여러 앱이나 pipeline에서 같은 오버레이 처리를 돌려 쓰고 싶다면 커스텀 MFT는 깔끔한 선택지입니다.

다만 첫 1개로는,

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

므로 먼저 Source Reader + 합성 + Sink Writer로 제대로 돌리고, 필요해지면 MFT로 잘라내는 편이 실무에서는 진행하기 쉬운 경우가 많습니다.

10. 정리

Media Foundation으로 MP4 동영상의 모든 프레임에 이미지나 문자를 구워 넣을 때는 다음 분해로 생각하면 정리하기 쉬워집니다.

  • 꺼내기: IMFSourceReader
  • 그리기: GDI+Direct2D / DirectWrite
  • 인코더가 받기 쉬운 형태로 맞추기: NV12
  • 다시 쓰기: IMFSinkWriter

그리고 「1개의 .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. 재사용성이 필요하면 커스텀 MFT로 잘라낸다

대뜸 전부를 담으면 COM, stride, 색공간, 서피스 관리가 한꺼번에 밀려듭니다.
처음엔 단을 나눠 통과시키고, 뒤에 필요한 곳만 강화하는 편이 설계도 디버깅도 꽤 편합니다.

11. 관련 글

12. 참고 자료

관련 기사

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

관련 토픽

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

이 주제와 연결되는 서비스

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

블로그 목록으로 돌아가기