用 Media Foundation 把圖片和文字烙印到 MP4 影片每一幀的方法 - 整理 Source Reader / 繪製 / 色彩轉換 / Sink Writer 與可直接貼進 .cpp 的單檔完整版
Logo 浮水印、檢驗結果、機台編號、作業人員、時間戳。
想把這類資訊 烙印到 MP4 影片的每一幀,再產出一支新 MP4 的需求,在監視、檢驗、留證、分析 UI 裡其實相當常見。
不過一碰到 Media Foundation,IMFSourceReader、IMFSample、IMFMediaBuffer、IMFTransform、IMFSinkWriter 一字排開,到底要在哪裡疊上文字或 PNG 反而突然變得不清楚。
本文先整理一張 Source Reader -> 繪製 -> 色彩轉換 -> Sink Writer 的整體圖,接著附上一份 可以直接貼進 Visual Studio C++ 主控台應用程式、單檔完整的範例。
範例會讀取指定的 MP4、把指定的圖片和 HelloWorld 畫到每一幀,然後產出輸出 MP4。
另外,這份範例以 能直接貼著跑 為優先,採用 只重新編碼影像的構成。
雖然可以把音訊 remux 一起塞進單檔,但本篇的主題是「把圖片和文字烙印到每一幀」,所以先聚焦在這件事上。
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,延伸空間比較大。
2. 為什麼這個問題有點複雜
「在影片裡加字」這件事,實際上是下面 4 件事混在一起:
-
容器與編解碼的事
mp4是容器,不等於畫面本身。裡頭通常是H.264、H.265的壓縮資料。 -
解碼 / 編碼的事
壓縮資料直接狀態下,一般 2D 繪製 API 沒辦法直接把文字或 PNG 疊上去。得先還原成未壓縮畫面。 -
繪製的事
文字、Logo、PNG 的透明合成、帶反鋸齒的文字繪製,這些不是 Media Foundation 本身的工作。是GDI+或Direct2D / DirectWrite / WIC的活。 -
色彩空間與像素格式的事
好畫的格式與編碼器愛吃的格式不一樣。這一點低調卻很容易卡。
很粗略地用一句話說就是,不要想成「用 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 管理負擔會增加 |
| 做成可重用元件 | 實作為 custom MFT 塞進 topology |
多個應用程式都要用的特效、想接進 MF pipeline | 實作、註冊、除錯難度會上升 |
本文的範例先聚焦在最上面的 「先正確跑起來」 這一段。
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[音訊 sample]
H --> I[直接複製<br/>或重新編碼]
I --> F
這裡的重點是:繪製本身不是 Media Foundation 的工作。
Media Foundation 負責把畫面拿進拿出,疊圖片和文字則交給繪製 API 做。
4. 要怎麼拆解 pipeline
4.1 輸入用 IMFSourceReader 接住
若輸入是檔案路徑,MFCreateSourceReaderFromURL 最直覺;若輸入是記憶體中的影片資料,做一個 IMFByteStream 再用 MFCreateSourceReaderFromByteStream 也很清楚。
這裡要先決定的是 接成「好畫」的格式,還是接成「編碼器友善」的格式。
- 想讓實作簡單就用
RGB32或ARGB32 - 想顧編碼效率就用
NV12這類 YUV
不過 疊文字或 PNG 在 RGB 系上想起來壓倒性地輕鬆,所以第一步用 RGB32 / ARGB32 接住會相當好處理。
開啟 MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING 後,Source Reader 會幫忙做 YUV -> RGB32 轉換與 deinterlace。
在「先把畫面拿出來玩一下」的階段很方便,但對長片或高解析度容易變慢,正式要效能時值得再回頭看。
4.2 圖片或文字的合成用 GDI+ 或 Direct2D / DirectWrite 想
把 Media Foundation 拿到的 IMFSample 裡的緩衝區取出,在上面疊上 Logo 影像或文字。
這次的範例為了 能單檔完整貼上執行,繪製部分用的是 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轉換
本次範例為了 單檔完整,採用後者,也就是 自行轉換。
正式環境中,用 Video Processor MFT 一併處理色彩轉換、縮放、deinterlace 也是相當有力的選擇。
4.4 輸出用 IMFSinkWriter 寫
影片輸出用 IMFSinkWriter 相當好處理。
概念很簡單:
- 輸出 stream 型 … 想寫到檔案裡的格式
例:MFVideoFormat_H264 - 輸入 stream 型 … 應用程式丟給
Sink Writer的格式
例:MFVideoFormat_NV12
分開設定就好。
從 Sink Writer 的角度來看就是:
- 應用程式端送
NV12的未壓縮畫面 Sink Writer負責編成 H.264 寫進 MP4
這樣的關係。
4.5 音訊一開始先分開想比較好整理
若只是想在影片上加 Logo 或文字,而不想改動音訊,其實相當常見。
實務上:
- 影像 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 的單檔完整程式碼
#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 buffer 再去繪製。
這邊先整齊了,繪製程式碼就能寫得相當直覺。
7.3 ReadSample 要看的不只是 HRESULT,還要看 flags 和 sample
ReadSample 即使回 S_OK,也可能 sample == nullptr。
常見情況有:
MF_SOURCE_READERF_STREAMTICKMF_SOURCE_READERF_ENDOFSTREAM- 其他串流事件
所以迴圈要同時看 HRESULT、flags、inputSample 三件事。
特別是 STREAMTICK 和 ENDOFSTREAM 沒看,後段的時間軸處理就容易炸掉。
7.4 timestamp 與 duration 盡量沿用輸入比較安全
時間戳是 100ns 為單位。
另外 duration 必須從 IMFSample 另外取得。
比起固定 fps 前提每次加一個死值,盡量沿用輸入 sample 的 timestamp / duration 比較不容易走樣。
本範例也只有在拿不到 duration 時才退回用 fps 算出來的預設值。
7.5 GDI+ 導入輕鬆,但長片或高解析度有下一階段
GDI+ 在單檔範例裡非常合適,但對長片或 4K 大量處理的情境,D3D11 + Direct2D + DirectWrite 會更有優勢。
- 先用
GDI+把整條流程跑通 - 之後有需要再替換成
Direct2D / DirectWrite - 色彩轉換則搬去
Video Processor MFT或 GPU 側
依這種階段性的做法,比較能在不破壞設計的情況下擴充。
7.6 本範例只聚焦在影像
如果把音訊也塞進同一篇,討論軸會太發散。
所以本範例聚焦在 把圖片和文字烙進影像幀,輸出只留影像的 MP4。
實務上,下一階段可以延伸成:
- 影像走
Source Reader -> 合成 -> Sink Writer - 音訊維持 compressed 直接 remux
這種組態比較好處理。
8. 若「拿到的影片資料」不是檔案而是記憶體中的 MP4 bytes
這次程式用的是 MFCreateSourceReaderFromURL,輸入是檔案路徑。
但若需求改成「要對 API 收到的 mp4 bytes 做同一件事」,想法不會改變。
變的只有入口而已。
- 準備一個
IStream或自訂 stream - 包成
IMFByteStream餵給Source Reader - 之後一樣
RGB32 -> 繪製 -> NV12 -> Sink Writer
也就是說,本質並非「影片資料怎麼拿在手上」,而是 解碼後每一幀要怎麼畫上去。
9. 若要做成正式環境版本
9.1 加上音訊 remux
最實務的第一個延伸方向是 保留音訊。
讓影像重新編碼、音訊維持 compressed 用相同格式寫回,能在不大量增加實作的前提下滿足需求。
9.2 塞入 Video Processor MFT
本範例為了單檔自給自足,自行實作 BGRA -> NV12,但正式環境放一個 Video Processor MFT 也相當有力。
用 Video Processor MFT 可以一併處理:
- 色彩空間轉換
- 縮放
- deinterlace
- 影格率轉換
9.3 把 GDI+ 換成 Direct2D / DirectWrite
Logo、字幕、時間戳這類 overlay,用 GDI+ 就夠用的情境其實不少;但要擠效能,Direct2D / DirectWrite 會比較佔優勢。
特別是在:
- 高解析度
- 長片
- 大量片源
- 未來要往 GPU 路徑靠
這些條件下,會開始考慮 D3D11 / DXGI surface 的架構。
9.4 custom MFT 等到「想重用的影片特效」出現時再考慮
Media Foundation 可以把特效實作為 IMFTransform。
所以當同樣的 overlay 處理想在多個應用程式或 pipeline 間共用時,custom MFT 是乾淨的選項。
不過做為第一個實作,它需要:
- 滿足
IMFTransform契約 - 處理輸入輸出 media type 的管理
- 註冊與除錯難度變高
所以 先用 Source Reader + 合成 + Sink Writer 跑對,之後再切成 MFT 在實務中通常比較好推進。
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、色彩空間、Surface 管理會瞬間全部壓上來。
先把一條路走通、再對需要的地方加強,設計與除錯都會輕鬆許多。
11. 相關文章
- Media Foundation 是什麼 - 為什麼看得到 COM 與 Windows 媒體 API 的影子
- 用 Media Foundation 從 MP4 影片指定時間點抽出靜止畫面的方法 - 可直接貼進 .cpp 的單檔完整版
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 中把解碼後的 YUV 影像轉成 RGB 的兩種做法。讀完之後,可以看清楚何時該交給 Source Reader 自動產生 RGB32、何時需要自行接住 NV12 或 YUY2,並掌握 BT.601 與 BT.709 矩陣、lim...
用 Media Foundation 從 MP4 影片指定時間點抽出靜止畫面的方法 - 可直接貼進 .cpp 的單檔完整版
整理用 Media Foundation 的 Source Reader 從 MP4 抽取最靠近指定時間靜止畫面的做法,涵蓋 SetCurrentPosition 非精準 seek、ReadSample 可能回 NULL、stride 與影像上下方向、RGB32 第 4 b...
Media Foundation 是什麼 - COM 和 Windows 媒體 API 的臉浮現的原因
整理 Media Foundation 為何讓人覺得像 COM:其本體是媒體處理平台,但 source、transform、sink、callback 之間的邊界以 COM 介面表現,因此 HRESULT、GUID、apartment 自然浮現,並指出 Source Rea...
使用共享記憶體時的陷阱與最佳實踐 - 先整理同步、可見性、壽命、ABI、安全性
整理在同一機器內以共享記憶體交換大型資料時的陷阱與設計要點。把 control plane 和 data plane 分離、縮小並行模型、用固定寬度整數和標頭設計 ABI、以 offset 取代指標、明示 commit protocol、為當機復原放入 generation...
用 Native AOT 把 C# 做成原生 DLL 的方法 - 用 UnmanagedCallersOnly 從 C/C++ 呼叫
從現有 C/C++ 應用程式以 in-process 方式呼叫 C# 邏輯時,本文示範以 Native AOT 將類別庫發佈為原生 DLL,並用 UnmanagedCallersOnly 公開 cdecl 進入點。透過 handle、錯誤碼與扁平 C ABI 設計交界面,整...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。