用 Media Foundation 向 MP4 视频帧叠加图片与文字的方法
· 小村 豪 · Media Foundation, C++, Windows 开发, GDI+, Direct2D, DirectWrite, H.264
Logo 水印、检验结果、设备编号、操作员姓名、时间戳。 把这些信息烧录进 MP4 视频的每一帧、生成一个新的 MP4,这类需求在监控、检验、留痕取证、分析类 UI 中相当常见。
不过,一旦开始接触 Media Foundation,IMFSourceReader、IMFSample、IMFMediaBuffer、IMFTransform、IMFSinkWriter 等一大串接口扑面而来,到底该在哪里叠加文字或 PNG 反而一下子变得难以判断。
本文先梳理 「Source Reader -> 绘制 -> 色彩转换 -> Sink Writer」 这一整体流程,然后给出一份可以直接粘贴进 Visual Studio 的 C++ 控制台应用、单文件即可运行的示例。
该示例会读取指定的 MP4,在每一帧上绘制指定的图片和 HelloWorld 文字,并生成输出 MP4。
需要说明的是,为了优先保证粘贴后就能直接跑起来,这份示例采用的是只对视频重新编码的结构。 虽然也可以把音频 remux 一起塞进同一个程序,但本文的主题是「向每一帧叠加图片和文字」,所以先把范围收窄到这一点上。
本文中出现的代码,已作为完整示例代码(单文件 .cpp 与 CMake 构建配置)发布在 GitHub 上。
media-foundation-overlay-image-text-on-mp4-frames - komurasoft-blog-samples (GitHub)
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 管线的场景 | 实现、注册、调试的难度上升 |
本文的示例聚焦于最上面这行——「先正确跑通」的结构。
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 中取出缓冲区,然后在其上叠加 Logo 图片或文字。
这次的示例优先考虑单文件、易于粘贴使用,因此绘制部分使用了 GDI+。
- 能加载图片
- 能绘制文字
- 需要的额外准备比较少
- 容易收纳进控制台应用的单个
.cpp文件
另一方面,在长时长视频或大批量处理 4K 视频的场景下,D3D11 + Direct2D + DirectWrite 会有更大的提升空间。
比较自然的做法是:最初的实现用 GDI+,等需要提升速度时再转向 Direct2D / DirectWrite。
4.3 未必能直接用 RGB32 写入 H.264
这是最容易卡住的地方。
在转换回 MP4(H.264) 时,微软的 H.264 编码器大多假定输入是 I420 / IYUV / NV12 / YUY2 / YV12 等 YUV 系列格式。
也就是说,用便于绘制的 RGB32 / ARGB32 完成合成之后,未必能直接扔给 IMFSinkWriter 就结束。
因此,实现中需要进行以下两种转换之一。
- 插入
Video Processor MFT,完成RGB32 / ARGB32 -> NV12转换 - 自己实现
RGB -> NV12转换
这次的示例优先考虑单文件即可运行,采用了后者,即自己实现转换。
在生产环境中,插入能够统一处理色彩空间转换、尺寸调整、去隔行的 Video Processor MFT 也是很有力的方案。
4.4 用 IMFSinkWriter 写出输出
视频输出交给 IMFSinkWriter 来处理会比较顺手。
思路很简单:
- 输出流类型……想写入文件的格式
例如:
MFVideoFormat_H264 - 输入流类型……应用传给
Sink Writer的格式 例如:MFVideoFormat_NV12
把这两者分开设置。
也就是说,从 Sink Writer 的角度来看,
- 应用侧传入
NV12格式的未压缩帧 Sink Writer将其编码为 H.264 并写入 MP4
两者是这样的关系。
4.5 一开始把音频单独分开考虑会更清晰
只想在视频中叠加 Logo 或文字、并不想改动音频本身——这种需求相当常见。
在实务中,
- 只对视频流走
Source Reader -> 合成 -> Sink Writer - 音频流保持压缩状态直接 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);
for (UINT32 x = 0; x < videoInfo.width; ++x)
{
dstRow[static_cast<size_t>(x) * 4 + 3] = 0xFF;
}
}
}
void DrawOverlay(std::vector<BYTE>& bgra, UINT32 width, UINT32 height, Gdiplus::Image& overlayImage)
{
const INT stride = static_cast<INT>(width * 4);
Gdiplus::Bitmap frameBitmap(
static_cast<INT>(width),
static_cast<INT>(height),
stride,
PixelFormat32bppPARGB,
bgra.data());
ThrowIfGdiplusError(frameBitmap.GetLastStatus(), "Create frame bitmap");
Gdiplus::Graphics graphics(&frameBitmap);
ThrowIfGdiplusError(graphics.GetLastStatus(), "Create graphics");
graphics.SetCompositingMode(Gdiplus::CompositingModeSourceOver);
graphics.SetCompositingQuality(Gdiplus::CompositingQualityHighQuality);
graphics.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic);
graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit);
const Gdiplus::REAL margin = std::max<Gdiplus::REAL>(16.0f, static_cast<Gdiplus::REAL>(height) * kMarginRatio);
const Gdiplus::REAL maxImageW = static_cast<Gdiplus::REAL>(width) * kImageMaxWidthRatio;
const Gdiplus::REAL maxImageH = static_cast<Gdiplus::REAL>(height) * kImageMaxHeightRatio;
const Gdiplus::REAL srcW = static_cast<Gdiplus::REAL>(overlayImage.GetWidth());
const Gdiplus::REAL srcH = static_cast<Gdiplus::REAL>(overlayImage.GetHeight());
if (srcW <= 0.0f || srcH <= 0.0f)
{
throw std::runtime_error("Overlay image has invalid size.");
}
const Gdiplus::REAL imageScale =
std::min<Gdiplus::REAL>(1.0f, std::min(maxImageW / srcW, maxImageH / srcH));
const Gdiplus::REAL drawW = srcW * imageScale;
const Gdiplus::REAL drawH = srcH * imageScale;
Gdiplus::RectF imageRect(margin, margin, drawW, drawH);
Gdiplus::SolidBrush imagePlate(Gdiplus::Color(96, 0, 0, 0));
graphics.FillRectangle(
&imagePlate,
imageRect.X - 8.0f,
imageRect.Y - 8.0f,
imageRect.Width + 16.0f,
imageRect.Height + 16.0f);
graphics.DrawImage(&overlayImage, imageRect);
const Gdiplus::REAL fontPx =
std::max<Gdiplus::REAL>(kMinFontPx, static_cast<Gdiplus::REAL>(height) * 0.06f);
Gdiplus::Font font(L"Segoe UI", fontPx, Gdiplus::FontStyleBold, Gdiplus::UnitPixel);
ThrowIfGdiplusError(font.GetLastStatus(), "Create font");
Gdiplus::StringFormat stringFormat;
stringFormat.SetAlignment(Gdiplus::StringAlignmentNear);
stringFormat.SetLineAlignment(Gdiplus::StringAlignmentNear);
Gdiplus::RectF measureLayout(
margin,
static_cast<Gdiplus::REAL>(height) - margin - fontPx * 2.0f,
static_cast<Gdiplus::REAL>(width) - margin * 2.0f,
fontPx * 2.0f);
Gdiplus::RectF measured;
graphics.MeasureString(kOverlayText, -1, &font, measureLayout, &stringFormat, &measured);
Gdiplus::RectF textBg(
measured.X - 12.0f,
measured.Y - 8.0f,
measured.Width + 24.0f,
measured.Height + 16.0f);
Gdiplus::SolidBrush textPlate(Gdiplus::Color(128, 0, 0, 0));
graphics.FillRectangle(&textPlate, textBg);
Gdiplus::SolidBrush shadowBrush(Gdiplus::Color(220, 0, 0, 0));
Gdiplus::RectF shadowLayout = measureLayout;
shadowLayout.X += 2.0f;
shadowLayout.Y += 2.0f;
graphics.DrawString(kOverlayText, -1, &font, shadowLayout, &stringFormat, &shadowBrush);
Gdiplus::SolidBrush textBrush(Gdiplus::Color(235, 255, 255, 255));
graphics.DrawString(kOverlayText, -1, &font, measureLayout, &stringFormat, &textBrush);
}
void BgraToNv12(const BYTE* bgra, UINT32 width, UINT32 height, BYTE* nv12)
{
const bool useBt709 = (width > 1024 || height > 576);
const int yR = useBt709 ? 47 : 66;
const int yG = useBt709 ? 157 : 129;
const int yB = useBt709 ? 16 : 25;
const int uR = useBt709 ? -26 : -38;
const int uG = useBt709 ? -87 : -74;
const int uB = 112;
const int vR = 112;
const int vG = useBt709 ? -102 : -94;
const int vB = useBt709 ? -10 : -18;
BYTE* yPlane = nv12;
BYTE* uvPlane = nv12 + static_cast<size_t>(width) * height;
const size_t srcStride = static_cast<size_t>(width) * 4;
for (UINT32 y = 0; y < height; ++y)
{
const BYTE* srcRow = bgra + static_cast<size_t>(y) * srcStride;
BYTE* dstY = yPlane + static_cast<size_t>(y) * width;
for (UINT32 x = 0; x < width; ++x)
{
const BYTE b = srcRow[x * 4 + 0];
const BYTE g = srcRow[x * 4 + 1];
const BYTE r = srcRow[x * 4 + 2];
const int Y = ((yR * r + yG * g + yB * b + 128) >> 8) + 16;
dstY[x] = ClampToByte(Y);
}
}
for (UINT32 y = 0; y < height; y += 2)
{
const BYTE* row0 = bgra + static_cast<size_t>(y) * srcStride;
const BYTE* row1 = bgra + static_cast<size_t>(y + 1) * srcStride;
BYTE* dstUV = uvPlane + static_cast<size_t>(y / 2) * width;
for (UINT32 x = 0; x < width; x += 2)
{
int b = 0;
int g = 0;
int r = 0;
for (UINT32 dy = 0; dy < 2; ++dy)
{
const BYTE* row = (dy == 0) ? row0 : row1;
for (UINT32 dx = 0; dx < 2; ++dx)
{
const UINT32 ix = x + dx;
b += row[ix * 4 + 0];
g += row[ix * 4 + 1];
r += row[ix * 4 + 2];
}
}
b = (b + 2) / 4;
g = (g + 2) / 4;
r = (r + 2) / 4;
const int U = ((uR * r + uG * g + uB * b + 128) >> 8) + 128;
const int V = ((vR * r + vG * g + vB * b + 128) >> 8) + 128;
dstUV[x + 0] = ClampToByte(U);
dstUV[x + 1] = ClampToByte(V);
}
}
}
ComPtr<IMFSample> CreateNv12Sample(
const std::vector<BYTE>& bgra,
const VideoFormatInfo& videoInfo,
LONGLONG sampleTime,
LONGLONG sampleDuration)
{
const DWORD bufferSize =
static_cast<DWORD>(videoInfo.width * videoInfo.height * 3 / 2);
ComPtr<IMFMediaBuffer> buffer;
ThrowIfFailed(MFCreateMemoryBuffer(bufferSize, &buffer), "MFCreateMemoryBuffer");
BYTE* dst = nullptr;
DWORD maxLength = 0;
DWORD currentLength = 0;
ThrowIfFailed(buffer->Lock(&dst, &maxLength, ¤tLength), "Lock(NV12 buffer)");
try
{
BgraToNv12(bgra.data(), videoInfo.width, videoInfo.height, dst);
}
catch (...)
{
buffer->Unlock();
throw;
}
ThrowIfFailed(buffer->Unlock(), "Unlock(NV12 buffer)");
ThrowIfFailed(buffer->SetCurrentLength(bufferSize), "SetCurrentLength(NV12 buffer)");
ComPtr<IMFSample> sample;
ThrowIfFailed(MFCreateSample(&sample), "MFCreateSample");
ThrowIfFailed(sample->AddBuffer(buffer.Get()), "AddBuffer(output sample)");
ThrowIfFailed(sample->SetSampleTime(sampleTime), "SetSampleTime");
ThrowIfFailed(sample->SetSampleDuration(sampleDuration), "SetSampleDuration");
return sample;
}
}
int wmain(int argc, wchar_t* argv[])
{
if (argc != 4)
{
std::wcerr << L"Usage: OverlayMp4.exe <input.mp4> <overlayImage.png> <output.mp4>" << std::endl;
return 1;
}
const std::wstring inputPath = argv[1];
const std::wstring imagePath = argv[2];
const std::wstring outputPath = argv[3];
try
{
if (_wcsicmp(inputPath.c_str(), outputPath.c_str()) == 0)
{
throw std::runtime_error("Input and output paths must be different.");
}
ScopedMf mf;
ScopedGdiplus gdiplus;
ComPtr<IMFAttributes> readerAttributes;
ThrowIfFailed(MFCreateAttributes(&readerAttributes, 1), "MFCreateAttributes(reader)");
ThrowIfFailed(
readerAttributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE),
"SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING)");
ComPtr<IMFSourceReader> reader;
ThrowIfFailed(
MFCreateSourceReaderFromURL(inputPath.c_str(), readerAttributes.Get(), &reader),
"MFCreateSourceReaderFromURL");
VideoFormatInfo videoInfo = ConfigureSourceReader(reader.Get());
if ((videoInfo.width % 2) != 0 || (videoInfo.height % 2) != 0)
{
throw std::runtime_error(
"This sample requires even video width and height because NV12 is 4:2:0.");
}
Gdiplus::Image overlayImage(imagePath.c_str());
ThrowIfGdiplusError(overlayImage.GetLastStatus(), "Load overlay image");
DWORD videoStreamIndex = 0;
ComPtr<IMFSinkWriter> writer =
CreateSinkWriter(outputPath, videoInfo, &videoStreamIndex);
std::vector<BYTE> bgra;
LONGLONG firstTimestamp = -1;
unsigned long long frameCount = 0;
while (true)
{
DWORD flags = 0;
LONGLONG timestamp = 0;
ComPtr<IMFSample> inputSample;
ThrowIfFailed(
reader->ReadSample(
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
0,
nullptr,
&flags,
×tamp,
&inputSample),
"ReadSample(video)");
if ((flags & MF_SOURCE_READERF_CURRENTMEDIATYPECHANGED) != 0)
{
throw std::runtime_error("Dynamic video format change is not supported in this sample.");
}
if ((flags & MF_SOURCE_READERF_NATIVEMEDIATYPECHANGED) != 0)
{
throw std::runtime_error("Native video format change is not supported in this sample.");
}
if ((flags & MF_SOURCE_READERF_STREAMTICK) != 0)
{
if (firstTimestamp < 0)
{
firstTimestamp = timestamp;
}
ThrowIfFailed(
writer->SendStreamTick(videoStreamIndex, timestamp - firstTimestamp),
"SendStreamTick");
}
if (inputSample)
{
if (firstTimestamp < 0)
{
firstTimestamp = timestamp;
}
LONGLONG duration = 0;
if (FAILED(inputSample->GetSampleDuration(&duration)) || duration <= 0)
{
duration = videoInfo.defaultFrameDuration;
}
CopySampleToTopDownBgra(inputSample.Get(), videoInfo, bgra);
DrawOverlay(bgra, videoInfo.width, videoInfo.height, overlayImage);
ComPtr<IMFSample> outputSample =
CreateNv12Sample(bgra, videoInfo, timestamp - firstTimestamp, duration);
ThrowIfFailed(
writer->WriteSample(videoStreamIndex, outputSample.Get()),
"WriteSample(video)");
++frameCount;
}
if ((flags & MF_SOURCE_READERF_ENDOFSTREAM) != 0)
{
break;
}
}
ThrowIfFailed(writer->Finalize(), "Finalize");
std::wcout
<< L"Done. frames=" << frameCount
<< L", output=" << outputPath
<< std::endl;
return 0;
}
catch (const std::exception& ex)
{
std::cerr << ex.what() << std::endl;
return 1;
}
}
7. 阅读这份实现时需要把握的要点
7.1 便于绘制的格式和编码器容易接受的格式是两件事
这份示例中,
Source Reader输出:RGB32- 绘制:
GDI+ Sink Writer输入:NV12
采用的是这样的流程。
理由很简单:如果要叠加文字或 PNG,RGB 系列格式更容易处理;如果要传给 H.264 编码,NV12 更容易处理。
阅读实现时,把这部分拆分成「绘制阶段」和「编码前整理阶段」来看,会更容易跟上思路。
7.2 先统一 stride 与上下方向,再进行绘制
视频帧在内存中的排列方式,未必和视觉上的样子一致。
- stride 有时不等于
width * 4 - 上下方向有时会是反过来的
IMF2DBuffer和IMFMediaBuffer的处理方式略有不同
因此,这份代码会先归一化为 top-down 的 BGRA 缓冲区,然后再进行绘制。 先把这一步统一好,绘制部分的代码就能写得相当直接。
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 中获取。
与其按固定帧率每次强行累加,尽量沿用输入 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 - 音频保持压缩状态直接 remux
这样的结构会比较好处理。
8. 如果「拿到的视频数据」不是文件而是内存中的 MP4 字节流
这次的代码使用的是 MFCreateSourceReaderFromURL,所以输入是文件路径。
不过,如果需求是「想对从 API 拿到的 mp4 字节流做同样的处理」,思路并不会改变。 需要改变的只是入口部分。
- 准备一个
IStream或自定义的流 - 将其作为
IMFByteStream传给Source Reader - 之后同样是
RGB32 -> 绘制 -> NV12 -> Sink Writer
也就是说,本质不在于如何持有视频数据,而在于解码后如何向每一帧写入内容。
9. 若要在生产环境中扩展
9.1 添加音频 remux
作为最初的扩展方向,最实用的是保留音频原样。 只对视频重新编码,音频保持压缩状态以相同格式写回,这样既能满足需求,又不会大幅增加实现量。
9.2 插入 Video Processor MFT
这次的示例优先考虑单文件即可运行,自行实现了 BGRA -> NV12 转换,但在生产环境中,插入 Video Processor MFT 的结构也相当有力。
使用 Video Processor MFT 之后,
- 色彩空间转换
- 尺寸调整
- 去隔行
- 帧率转换
都能统一处理,会更方便。
9.3 将 GDI+ 替换为 Direct2D / DirectWrite
对于 Logo 图片、字幕、时间戳这类叠加内容,很多场景下 GDI+ 已经足够,但如果要压榨性能,Direct2D / DirectWrite 会更有优势。
尤其是在以下情况下:
- 高分辨率
- 长时长
- 大批量
- 未来想转向 GPU 路径
在这些条件下,使用 D3D11 / DXGI surface 的结构就值得考虑。
9.4 当需要「可复用的视频特效」时再考虑 custom MFT
在 Media Foundation 中,可以把特效实现为 IMFTransform。
因此,如果想在多个应用或管线中复用同一套叠加处理逻辑,custom MFT 是一个干净的选择。
不过,作为第一版实现,
- 需要满足
IMFTransform契约 - 输入输出媒体类型的管理成本会增加
- 注册和调试的难度会上升
所以在实务中,先用 Source Reader + 合成 + Sink Writer 正确跑通,等真正需要时再拆分成 MFT,往往会更好推进。
10. 小结
用 Media Foundation 向 MP4 视频的每一帧叠加图片或文字时,把问题拆分成以下 4 部分来思考,思路会更清晰。
- 取出:
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. 参考资料
- 本文的完整示例代码(单文件
.cpp与 CMake 构建配置) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/media-foundation-overlay-image-text-on-mp4-frames - Microsoft Learn: Using the Source Reader to Process Media Data
- Microsoft Learn: MFCreateSourceReaderFromByteStream
- Microsoft Learn: MFCreateMFByteStreamOnStream
- Microsoft Learn: IMFSourceReader::SetCurrentMediaType
- Microsoft Learn: MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING
- Microsoft Learn: MF_SOURCE_READER_ENABLE_ADVANCED_VIDEO_PROCESSING
- Microsoft Learn: IMFSourceReader::ReadSample
- Microsoft Learn: Working with Media Samples
- Microsoft Learn: IMF2DBuffer::Lock2D
- Microsoft Learn: Video Subtype GUIDs
- Microsoft Learn: H.264 Video Encoder
- Microsoft Learn: Video Processor MFT
- Microsoft Learn: Using the Sink Writer
- Microsoft Learn: Tutorial: Using the Sink Writer to Encode Video
- Microsoft Learn: Interoperability Overview (Direct2D)
- Microsoft Learn: Text Rendering with Direct2D and DirectWrite
- Microsoft Learn: Writing a Custom MFT
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
在 Media Foundation 中将 YUV 转换为 RGB 的方法 - 从原理讲透 Source Reader 自动转换与手动转换
本文整理在 Media Foundation 中把解码后的 YUV 帧转换为 RGB 的两种方式:交给 Source Reader 自动转换为 RGB32,或者自己接收 NV12/YUY2 手动转换。文中还讲解 stride、BT.601/BT.709 矩阵、limited...
用 Media Foundation 从 MP4 视频中提取指定时间点静止图像的方法
本文整理了使用 Source Reader 从 MP4 中取出接近指定时间点的帧,并处理好 stride 与 RGB32 的 alpha 后保存为 PNG 的实现步骤。
共享内存的陷阱与实务最佳实践
整理在实务中使用共享内存时的陷阱,以及涵盖同步、可见性、生命周期、ABI、权限等降低事故率的设计方法。
Media Foundation 入门:用 COM 视角理解 API
本文按照最先需要掌握的顺序,结合 COM、HRESULT、IMFSourceReader、MFT 等 Windows 媒体 API 的基础术语,整理 Media Foundation 到底是什么。
不只是 appsettings.json ── Windows 业务应用的配置管理实务(环境差异化设置・敏感信息・写入位置)
本文从实务角度整理 Windows 业务应用的配置管理,涵盖 appsettings.json 的分层、IConfiguration 与 IOptions/IOptionsSnapshot/IOptionsMonitor 的选用方式、环境差异化设置、可写入配置的存放位置、敏...
常见问题
汇总了咨询这一主题时常见的问题。
- 用 Media Foundation 向 MP4 每一帧叠加图片和文字的基本流程是什么?
- 基本形式是「用 Source Reader 解码 → 合成到未压缩帧 → 需要时进行色彩转换 → 用 Sink Writer 重新编码」。叠加图片或文字本身并不是 Media Foundation 的工作,而是 GDI+、Direct2D/DirectWrite、WIC 等绘制 API 的工作。如果只是想先跑通第一版,采用「Source Reader → RGB32 → 用 GDI+ 绘制 → NV12 → Sink Writer」这种结构比较容易理解;如果要优先考虑速度和可扩展性,则可以转向使用 D3D11/DXGI surface 配合 Direct2D/DirectWrite 的结构,这样后续会有更大的提升空间。
- 可以把 RGB32 的帧直接传给 H.264 编码器吗?
- 不一定可以。微软的 H.264 编码器大多假定输入是 I420/IYUV/NV12/YUY2/YV12 等 YUV 系列格式,因此在用容易绘制的 RGB32/ARGB32 完成合成之后,往往还需要一个转换环节。可以插入 Video Processor MFT 把 RGB32 转换成 NV12,也可以自己实现 RGB → NV12 的转换。另外,NV12 是 4:2:0 采样,所以帧的宽度和高度都必须是偶数。
- 叠加图像的绘制应该用 GDI+ 还是 Direct2D?
- 在最初的实现中,GDI+ 既能加载图片又能绘制文字,前期准备也比较少,适合做成单文件示例。另一方面,在长时长视频、4K、大批量处理的场景下,D3D11 + Direct2D + DirectWrite 在性能上往往更有优势。比较合理的推进方式是:先用 GDI+ 把整体流程跑通,等需要提升速度时再替换成 Direct2D/DirectWrite,并把色彩转换交给 Video Processor MFT 或 GPU 处理——这样分阶段推进不容易破坏原有设计,也更便于扩展。
- 使用 IMFSourceReader 的 ReadSample 时需要注意什么?
- 即使 ReadSample 返回 S_OK,sample 也可能是 nullptr。典型情况是 MF_SOURCE_READERF_STREAMTICK 或 MF_SOURCE_READERF_ENDOFSTREAM 等流事件。因此在循环中需要同时检查 HRESULT、flags 和 sample 这三者。另外,时间戳的单位是 100ns,duration 与其按固定帧率每次强行累加,不如尽量沿用输入 sample 自带的 timestamp 和 duration,这样更不容易出现时序错乱。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。