Media Foundation으로 MP4 동영상의 각 프레임에 이미지와 문자를 구워 넣는 방법 - Source Reader / 드로잉 / 색 변환 / Sink Writer 정리와 .cpp에 그대로 붙일 수 있는 1파일 완결판
로고 워터마크, 검사 결과, 장비 번호, 작업자 이름, 타임스탬프.
이런 정보를 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가지 이야기가 섞여 있습니다.
-
컨테이너와 코덱 이야기
mp4는 컨테이너이지 프레임 그 자체가 아닙니다. 내용물은 대개H.264나H.265의 압축 데이터입니다. -
디코드 / 인코드 이야기
압축된 채로는 일반적인 2D 드로잉 API로 문자나 PNG를 그대로 얹을 수 없습니다. 먼저 비압축 프레임으로 되돌릴 필요가 있습니다. -
드로잉 이야기
문자, 로고, PNG의 투명 합성, 안티앨리어스된 텍스트 드로잉은 Media Foundation 본체의 역할이 아닙니다. 여기는GDI+나Direct2D / DirectWrite / WIC의 일입니다. -
색공간과 픽셀 형식 이야기
그리기 편한 형식과 인코더가 선호하는 형식은 일치하지 않습니다. 여기가 수수하게 막히기 쉬운 지점입니다.
거칠게 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을 쓰는 구성이 명료합니다.
여기서 맨 먼저 정해야 할 건 그리기 편한 형식으로 받을지, 인코더용 형식으로 받을지 입니다.
- 구현을 간단히 하려면
RGB32나ARGB32 - 인코드 효율을 우선하면
NV12같은 YUV
단, 문자나 PNG의 합성은 RGB 계열이 압도적으로 생각하기 쉬우므로 첫 수는 RGB32 / ARGB32를 받는 구성이 꽤 다루기 쉽습니다.
MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING을 활성화하면 Source Reader가 YUV -> RGB32 변환과 인터레이스 해제를 해 줍니다.
이는 「먼저 프레임을 뽑아 다뤄 보고 싶다」 단계에서는 편리하지만, 긴 동영상이나 고해상도 동영상에서는 무거워지기 쉬우므로 본번에서 속도가 필요하다면 나중에 구성을 재검토할 가치가 있습니다.
4.2 이미지나 문자의 합성은 GDI+나 Direct2D / DirectWrite로 생각한다
Media Foundation에서 받은 IMFSample에서 버퍼를 꺼내 그 위에 로고 이미지나 텍스트를 얹습니다.
이번 샘플은 1파일 완결로 붙이기 쉬움 을 우선해 드로잉에 GDI+를 씁니다.
- 이미지 로드가 가능
- 문자 드로잉이 가능
- 추가 준비가 비교적 적음
- 콘솔 앱의
.cpp1개로 담기 쉬움
한편 장시간 동영상이나 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 사용법
- Visual Studio에서 Console App 을 만든다
- 이
.cpp를 통째로 붙인다 - 그
.cpp의 프리컴파일 헤더를 「사용하지 않음」 으로 한다 x64로 빌드한다- 다음과 같이 실행한다
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, ¤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);
}
}
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, ¤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 버퍼로 정규화한 뒤 드로잉 합니다.
여기를 먼저 맞춰 두면 드로잉 쪽 코드를 꽤 자연스럽게 할 수 있습니다.
7.3 ReadSample은 HRESULT뿐 아니라 flags와 sample을 본다
ReadSample은 S_OK라도 sample == nullptr가 될 수 있습니다.
전형적인 예는,
MF_SOURCE_READERF_STREAMTICKMF_SOURCE_READERF_ENDOFSTREAM- 그 외 스트림 이벤트
입니다.
그래서 루프에서는 HRESULT, flags, inputSample 3개를 모두 봐야 합니다.
특히 STREAMTICK과 ENDOFSTREAM을 놓치면 후단의 타임라인 처리가 무너지기 쉬워집니다.
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
라는 구성은 꽤 자연스럽습니다.
본번으로 다음에 확장할 거라면 이 순서로 생각하면 무너지기 어렵습니다.
- 음성 remux를 더한다
GDI+를Direct2D / DirectWrite로 교체한다NV12변환을Video Processor MFT나 GPU 쪽으로 옮긴다- 장시간·고해상도용으로
D3D11 surface기반으로 나아간다 - 재사용성이 필요하면 커스텀
MFT로 잘라낸다
대뜸 전부를 담으면 COM, stride, 색공간, 서피스 관리가 한꺼번에 밀려듭니다.
처음엔 단을 나눠 통과시키고, 뒤에 필요한 곳만 강화하는 편이 설계도 디버깅도 꽤 편합니다.
11. 관련 글
- Media Foundation이란 무엇인가 - COM과 Windows 미디어 API의 얼굴이 보이는 이유
- Media Foundation으로 MP4 동영상의 지정 시각에서 정지 이미지를 뽑는 방법 - .cpp에 그대로 붙일 수 있는 1파일 완결판
12. 참고 자료
- 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로 변환하는 방법 - Source Reader의 자동 변환과 직접 변환을 원리부터 정리
Media Foundation에서 NV12·YUY2 같은 YUV 프레임을 RGB로 옮기는 두 가지 길을 정리합니다. Source Reader의 자동 RGB32 변환과 직접 변환을 색공간·서브샘플링·stride 관점에서 비교하고 BT.601/709...
Media Foundation으로 MP4 동영상의 지정 시각에서 정지 이미지를 뽑는 방법 - .cpp에 그대로 붙일 수 있는 1파일 완결판
Media Foundation의 Source Reader로 MP4의 지정 시각에 가까운 프레임을 PNG로 꺼내는 흐름과, seek 어긋남·sample NULL·stride·RGB32 4바이트째의 함정을 정리하고, C++ 콘솔 앱용 1파일 완결 코...
Media Foundation이란 무엇인가 - COM과 Windows 미디어 API의 얼굴이 보이는 이유
Media Foundation을 만지면 왜 COM의 얼굴이 진해지는지를, CoInitializeEx와 MFStartup의 분담, IMFAttributes와 GUID, IMFActivate, Source Reader / Sink Writer / M...
공유 메모리를 사용할 때의 함정과 베스트 프랙티스 - 동기, 가시성, 수명, ABI, 보안을 먼저 정리
공유 메모리는 단순히 빠른 IPC가 아니라 동기, 가시성, 수명, ABI, 권한의 책임을 앱 측이 떠맡는 구조입니다. 본 글은 함정과 베스트 프랙티스를 정리하여 SPSC 링 버퍼나 더블 버퍼, 고정 헤더, 오프셋 참조 등 사고율을 내리는 설계 첫...
C#을 Native AOT로 네이티브 DLL로 만드는 방법 - UnmanagedCallersOnly로 C/C++에서 호출하기
.NET의 Native AOT와 UnmanagedCallersOnly로 C# 클래스 라이브러리를 네이티브 DLL로 발행해 C/C++에서 in-process로 호출하는 구성을, 핸들 기반 수명 관리와 에러 코드, C ABI 설계 요령으로 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.