用 Media Foundation 把图片和文字烧录进 MP4 的方法
· 更新日期: · 小村 豪 · Media Foundation, C++, Windows 开发, GDI+, Direct2D, DirectWrite, H.264
引用本文(DOI: 10.5281/zenodo.21615488)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《用 Media Foundation 把图片和文字烧录进 MP4 的方法》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615488 https://comcomponent.com/zh-CN/blog/2026/03/16/009-media-foundation-overlay-image-text-on-mp4-frames/
- DOI(最新版本)
- 10.5281/zenodo.21615488
- DOI(此版本)
- 10.5281/zenodo.22282011
Logo 水印、检验结果、设备编号、操作员姓名、时间戳。 把这些信息烧录进 MP4 视频的每一帧、生成一个新的 MP4,这类需求在监控、检验、留痕取证、分析类 UI 中相当常见。
不过,一旦开始接触 Media Foundation,IMFSourceReader、IMFSample、IMFMediaBuffer、IMFTransform、IMFSinkWriter 一字排开,到底该在哪里叠加文字或 PNG 反而一下子变得难以看清。
本文先梳理 Source Reader -> 绘制 -> 色彩转换 -> Sink Writer 这一整体流程,然后给出一份可以直接粘贴进 Visual Studio 的 C++ 控制台应用、单文件即可运行的示例。
该示例会读取指定的 MP4,在每一帧上绘制指定的图片和 HelloWorld,并生成输出 MP4。
需要说明的是,为了优先保证粘贴之后就能直接跑起来,这份示例采用的是只对视频重新编码的结构。 把音频 remux 也塞进同一个程序当然做得到,但本文的主题是“向每一帧烧录图片和文字”,所以先把范围收窄到这一点上。
flowchart TB
accTitle: 这份示例的取舍范围
accDescr: 本文的示例优先保证粘贴后就能直接运行,因此采用只对视频重新编码的结构,把音频remux留到主题也就是烧录跑通之后再作为扩展的图。
fo1["优先做到粘贴就能运行"] --> fo2["只对视频重新编码"]
fo2 --> fo3["收窄到向每一帧烧录"]
fo1 -.-> fo4["音频remux留到后续扩展"]
图1:第一版只保留烧录这一主题能够跑通的最小结构。
本文中出现的代码,已作为完整示例代码(单文件 .cpp 与 CMake 构建配置)发布在 GitHub 上。
media-foundation-overlay-image-text-on-mp4-frames - komurasoft-blog-samples (GitHub)
目标读者与所需环境
本文面向接下来要用 C++ 编写 Windows 视频处理的中级开发者。假设读者接触过 COM 的基础(ComPtr、HRESULT、引用计数),而 Media Foundation 还没怎么用过。
运行这份示例所需的环境如下。更详细的条件和对输入数据的要求整理在第 5 章。
| 项目 | 前提 |
|---|---|
| OS | Windows 10 / 11 |
| 开发环境 | Visual Studio 2022 的 C++ 控制台应用 |
| 构建配置 | x64 |
| 预编译头 | 这个 .cpp 设置为不使用 |
| 输入视频 | 普通的 MP4。宽度和高度必须是偶数(因为 NV12 是 4:2:0) |
| 输出 | 只有视频的 MP4。不带音频 |
先记住几个术语
从第 3 章的表格开始会出现没有解释的英文词。先用一行一个的方式写在这里。
| 术语 | 含义 |
|---|---|
| remux | 内部的压缩数据保持不变,只重新生成外层的容器。因为不重新编码,画质和音质都不会劣化,处理开销也小 |
| topology | Media Foundation 用来表示“数据从哪个部件流向哪个部件”的图。相当于把源、变换、接收器连起来的结构图 |
| custom MFT | 自己编写的 Media Foundation Transform。实现 IMFTransform 之后,就能把特效作为部件插入 Media Foundation 的管线 |
| stride | 图像一行在内存中占用的字节数。不一定等于 宽度 × 4,行尾可能会有填充 |
1. 先说结论
- 在 MP4 每一帧中叠加图片或文字的基本形式是:
用 Source Reader 解码 -> 合成到未压缩帧 -> 必要时进行色彩转换 -> 用 Sink Writer 重新编码。 - 叠加图片或文字这件事本身并不是 Media Foundation 的职责。 这部分交给
GDI+、Direct2D、DirectWrite、WIC等绘制 API 来考虑会更顺畅。 - 如果要写回
MP4(H.264),通常需要一个转换环节,用来连接便于绘制的RGB32 / ARGB32和编码器容易接受的NV12 / I420 / YUY2。 - 如果只是想先跑通第一个版本,
Source Reader -> RGB32 -> 用 GDI+ 绘制 -> NV12 -> Sink Writer这种结构比较容易理解。 - 如果优先考虑速度和可扩展性,转向
D3D11 / DXGI surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer会有更大的提升空间。
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 20 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
2. 为什么这个问题会有点绕
“在视频中叠加文字”实际上混杂了下面 4 个不同的话题。
-
容器与编解码器的话题
mp4是容器,本身并不是帧。其内部通常是H.264或H.265的压缩数据。 -
解码 / 编码的话题 保持压缩状态时,普通的 2D 绘制 API 无法直接叠加文字或 PNG。必须先还原成未压缩的帧。
-
绘制的话题 文字、Logo、PNG 的透明合成,以及带抗锯齿的文字绘制,都不是 Media Foundation 本体的职责。这部分是
GDI+或Direct2D / DirectWrite / WIC的工作。 -
色彩空间与像素格式的话题 便于绘制的格式和编码器偏好的格式并不一致。这里是很不起眼却容易卡住的地方。
粗略地用一句话概括就是,与其想成“用 Media Foundation 叠加文字”,不如想成“用 Media Foundation 驱动帧的流转,用绘制 API 叠加内容,再补上必要的色彩转换后进行编码”,这样最容易理清。
flowchart TB
accTitle: 混在一起的4个话题
accDescr: 在视频中叠加文字这个需求里混杂着容器与编解码器、解码与编码、绘制、色彩空间与像素格式这4个话题的图。
mixq["在视频中叠加文字"] --> t1["容器与编解码器的话题"]
mixq --> t2["解码与编码的话题"]
mixq --> t3["绘制的话题"]
t3 -.-> t4["色彩空间与像素格式的话题"]
图2:卡住的时候,先分清自己现在处在哪个话题里。
3. 先看一张整理表
| 方针 | 结构 | 适合场景 | 注意事项 |
|---|---|---|---|
| 先正确跑通 | Source Reader -> RGB32 -> 合成 -> NV12 -> Sink Writer |
批处理、内部工具、初期实现 | CPU 侧的拷贝和转换容易增多 |
| 提升速度 | D3D11 / DXGI surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer |
长时长视频、高分辨率、大批量处理 | D3D11 和 DXGI 的管理成本增加 |
| 做成可复用的组件 | 实现为 custom MFT 并接入 topology |
多个应用共用的特效,或想嵌入 MF 管线的场景 | 实现、注册、调试的难度上升 |
本文的示例聚焦在最上面那行——“先正确跑通”的结构。
3.1 处理流程示意
flowchart LR
A[input.mp4] --> B[IMFSourceReader]
B --> C[未压缩帧<br/>RGB32]
C --> D[用 GDI+ 绘制图片 + HelloWorld]
D --> E[BGRA -> NV12 转换]
E --> F[IMFSinkWriter]
F --> G[output.mp4]
B --> H[音频样本]
H --> I[直接复制<br/>或重新编码]
I --> F
图3:用 Source Reader 取出,用 GDI+ 绘制,转换成 NV12 后由 Sink Writer 写回。
这里重要的是,绘制本身并不是 Media Foundation 的职责。 Media Foundation 负责把帧取出和送回,叠加图片和文字则交给绘制 API。
4. 如何拆分管线来思考
4.1 输入用 IMFSourceReader 接收
如果输入是文件路径,用 MFCreateSourceReaderFromURL;如果是内存中的视频数据,则创建 IMFByteStream 并使用 MFCreateSourceReaderFromByteStream,这样的结构比较容易理解。
这里首先要决定的是:以便于绘制的格式接收,还是以适合编码器的格式接收。
- 想让实现简单,就用
RGB32或ARGB32 - 想优先保证编码效率,就用
NV12等 YUV 格式
不过,要合成文字或 PNG,RGB 系列格式在思路上要顺畅得多,所以第一步先用 RGB32 / ARGB32 接收会更省事。
flowchart TB
accTitle: 第一步选哪种格式接收
accDescr: 想让实现简单就用RGB32或ARGB32,想优先保证编码效率就用NV12等YUV格式,但合成文字和PNG时RGB系列更容易考虑,因此第一步用RGB系列接收更省事的图。
rq1{"优先考虑什么"}
rq1 -->|"实现的简单程度"| rf1["用RGB32 / ARGB32接收"]
rq1 -->|"编码效率"| rf2["用NV12等YUV接收"]
rf1 -.-> rf3["合成时RGB系列更容易考虑"]
图4:拿不定主意时,优先考虑好不好绘制,先用 RGB 系列接收。
启用 MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING 后,Source Reader 会替你完成 YUV -> RGB32 转换和去隔行。
在“先把帧取出来处理”的阶段这很方便,但在长视频或高分辨率视频上容易变重,如果生产环境需要速度,之后重新审视这部分结构是值得的。
flowchart TB
accTitle: ENABLE_VIDEO_PROCESSING的得失
accDescr: 启用MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING后Source Reader会完成YUV到RGB32的转换和去隔行,但在长视频和高分辨率下容易变重,生产环境需要速度时值得重新审视结构的图。
ev1["启用该标志"] --> ev2["把YUV到RGB32的转换交出去"]
ev1 --> ev3["去隔行也一并交出去"]
ev2 -.-> ev4["长时长或高分辨率下容易变重"]
图5:让取帧变轻松的便利标志,代价体现在速度上。
4.2 图片和文字的合成用 GDI+ 或 Direct2D / DirectWrite 来考虑
从 Media Foundation 拿到的 IMFSample 中取出缓冲区,然后在它上面叠加 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 的结构也很有力。
flowchart TB
accTitle: 从RGB接到NV12的两条路
accDescr: 用便于绘制的RGB32或ARGB32合成之后,要么插入Video Processor MFT转换,要么自己实现RGB到NV12的转换,这份示例为了单文件即可运行选择了自行转换的图。
cv1["用RGB系列合成完毕"] --> cv2["用Video Processor MFT转换"]
cv1 --> cv3["自己转换成NV12"]
cv3 -.-> cv4["示例优先单文件即可运行"]
cv2 -.-> cv5["生产环境中是有力的结构"]
图6:转换环节一定需要,要选的只是把它交给谁。
4.4 输出用 IMFSinkWriter 写
视频输出用 IMFSinkWriter 比较好处理。
思路很简单,
- 输出流类型 … 想写进文件的格式
例:
MFVideoFormat_H264 - 输入流类型 … 应用交给
Sink Writer的格式 例:MFVideoFormat_NV12
分开设置这两者。
也就是说,从 Sink Writer 的角度看,
- 应用侧交过来的是
NV12的未压缩帧 Sink Writer把它编码成 H.264 并写进 MP4
是这样的关系。
flowchart TB
accTitle: Sink Writer输入输出类型的分工
accDescr: 给Sink Writer分别设置应用交过来的输入流类型NV12和想写进文件的输出流类型H.264,由Sink Writer负责编码并写入MP4的图。
ap1["应用交出NV12帧"] --> sw1["Sink Writer"]
sw1 --> sw2["编码成H.264"]
sw2 --> sw3["写入MP4"]
sw1 -.-> sw4["输入类型与输出类型分开设置"]
图7:把交进去的格式和写出来的格式分开设置,这就是 Sink Writer 的用法。
4.5 音频一开始单独分开考虑会更清晰
只想给视频加上 Logo 或文字、并不想改动音频本身,这种情况相当多。
在实务中,
- 只有视频流走
Source Reader -> 合成 -> Sink Writer - 音频流保持压缩状态直接 remux
这样的结构比较好用。
不过这次的示例要把焦点对准向帧烧录图片和文字这一点,所以输出是只有视频的 MP4。 保留音频的版本,放到之后扩展的阶段再加,会更容易跟上整体脉络。
flowchart TB
accTitle: 把视频和音频分开考虑
accDescr: 实务中只让视频流从Source Reader经过合成流向Sink Writer,音频流保持压缩状态remux的结构比较好用,而这份示例为了收窄焦点把输出限定为只有视频的图。
vs1["视频流"] --> vs2["合成后交给Sink Writer"]
as1["音频流"] --> as2["保持压缩状态remux"]
as2 -.-> as3["示例中不处理"]
图8:只想改视频时,音频不做处理,连同容器一起搬过去。
5. 这份示例的前提与用法
这份代码的前提如下。
- Windows 10 / 11
- Visual Studio 2022 的 C++ 控制台应用
x64构建- 这个
.cpp文件不使用预编译头 - 输入视频的宽度和高度是偶数
- 输入是普通的 MP4 视频文件
- 输出是只有视频的 MP4
- 图片是 PNG / JPEG / BMP / GIF 等 GDI+ 能读取的格式
NV12 是 4:2:0,所以宽度和高度必须是偶数。
因此这份示例在不满足条件时会显式报错。
flowchart TB
accTitle: 宽度和高度必须是偶数的前提
accDescr: NV12是4:2:0的二次采样,所以输入视频的宽度和高度必须是偶数,这份示例在不满足条件时显式报错停止的图。
nvq1["NV12是4:2:0"] --> nvq2["宽度和高度必须是偶数"]
nvq2 --> nvq3{"输入是偶数吗"}
nvq3 -->|"偶数"| nvq4["继续处理"]
nvq3 -->|"含奇数"| nvq5["显式报错停止"]
图9:与其让画面无声无息地坏掉,不如在入口处拦下不满足前提的输入。
5.1 用法
- 在 Visual Studio 中创建 Console App
- 把这个
.cpp整份粘贴进去 - 把该
.cpp的预编译头设为“不使用” - 以
x64构建 - 按下面的方式运行
OverlayMp4.exe input.mp4 overlay.png output.mp4
input.mp4原视频overlay.png想叠加的图片output.mp4输出目标
文字串固定写在代码开头的 kOverlayText 中,值是 HelloWorld。
位置和大小也可以通过改动代码里的常量来调整。改造成从命令行参数传入的做法放在 9.5。
5.2 确认是否真的正确跑通了
“没有报错就结束了”和“正确完成了烧录”是两回事。按顺序看下面 4 点,大部分失败都能查出来。
- 看结束时的帧数。 这份示例在处理结束后会在
Done. frames=后面输出写入的帧数。如果和输入视频的总帧数相差很大,说明在ReadSample的循环某处漏掉了帧 - 把输出文件的基本信息和输入对比。 在资源管理器中右键点击输出的 MP4,打开属性 > 详细信息,可以看到长度、帧宽度、帧高度、帧速率。如果长度和输入对不上,就要怀疑时间戳的处理(7.4)
- 看开头、中间、结尾这 3 处。 只确认第一帧就放心,会漏掉中途叠加内容消失的问题。从输入和输出中各截取同一时刻的静止图像并排对照最为可靠,这一步的做法整理在“用 Media Foundation 从 MP4 指定时刻截取静止图像的方法”中
- 看颜色有没有异常。 如果人的皮肤或天空的颜色不自然,可能是
BgraToNv12的系数选择(BT.601 与 BT.709)和输入不匹配
第一次试的时候,建议用几秒钟的短 MP4 和轮廓清晰的 PNG。用长视频去跑通第一版,问题定位会花很多时间。
flowchart TB
accTitle: 确认是否正确跑通的步骤
accDescr: 没有报错和正确完成烧录是两回事,因此按帧数核对、输出文件基本信息比较、开头中间结尾的目视、颜色异常确认这4点依次检查的流程图。
ck1["把帧数与输入核对"] --> ck2["用属性比较长度和尺寸"]
ck2 --> ck3["目视开头、中间、结尾"]
ck3 --> ck4["确认颜色是否不自然"]
ck4 -.-> ck5["异常时怀疑系数选择"]
图10:没报错和结果正确是两回事,按 4 个角度依次确认。
6. 可直接粘贴进 .cpp 的单文件完整代码
6.1 代码地图
先给出一张地图。代码虽长,但真正需要读的核心只有 CopySampleToTopDownBgra、DrawOverlay、BgraToNv12 这 3 个函数,以及 wmain 里的循环。其余都是初始化和收尾。
| 函数 / 类 | 职责 | 详细说明 |
|---|---|---|
ScopedMf / ScopedGdiplus |
用 RAII 把 MFStartup 和 GDI+ 的初始化与结束成对绑定 |
— |
ConfigureSourceReader |
把 Source Reader 的输出设为 RGB32,并取出宽、高、fps 和默认的 frame duration |
4.1 |
GetDefaultStride |
从媒体类型求出默认的 stride | 7.2 |
BufferLock |
有 IMF2DBuffer 就用它,没有就用 IMFMediaBuffer 锁定缓冲区 |
7.2 |
CopySampleToTopDownBgra |
吸收 stride 与上下方向的差异,归一化为 top-down 的 BGRA | 7.2 |
DrawOverlay |
用 GDI+ 绘制图片和文字。只有这里是“绘制阶段” | 4.2 / 7.1 |
BgraToNv12 |
把绘制完成的 BGRA 转换成 NV12 | 4.3 / 7.1 |
CreateNv12Sample |
把 NV12 缓冲区包进 IMFSample,并附上 timestamp 和 duration |
7.4 |
ChooseBitrate |
根据输入信息决定输出码率 | — |
CreateSinkWriter |
设置输出侧的 H.264 类型,以及我方交过去的 NV12 类型 |
4.4 |
wmain 的 while 循环 |
一边检查 ReadSample 的 HRESULT / flags / sample,一边逐帧处理 |
7.3 / 7.4 |
和第 3 章的处理流程示意对照起来看,CopySampleToTopDownBgra 对应“未压缩帧”,DrawOverlay 对应“用 GDI+ 绘制”,BgraToNv12 对应“BGRA 到 NV12 转换”。
flowchart TB
accTitle: 需要读的3个核心函数
accDescr: 冗长的代码中真正需要读的核心是CopySampleToTopDownBgra、DrawOverlay、BgraToNv12这3个函数和wmain的循环,它们分别对应归一化为未压缩帧、绘制、NV12转换的图。
lp1["wmain的循环逐帧处理"] --> fn1["CopySampleToTopDownBgra"]
fn1 --> fn2["DrawOverlay"]
fn2 --> fn3["BgraToNv12"]
fn1 -.-> ro1["归一化为未压缩帧"]
fn2 -.-> ro2["绘制图片和文字"]
fn3 -.-> ro3["整理成适合编码的形式"]
图11:其余都是初始化和收尾,核心只有这 3 个函数和那个循环。
6.2 完整代码
#define NOMINMAX
#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mferror.h>
#include <gdiplus.h>
#include <wrl/client.h>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cwchar>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfreadwrite.lib")
#pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "mf.lib")
#pragma comment(lib, "gdiplus.lib")
using Microsoft::WRL::ComPtr;
namespace
{
const wchar_t* kOverlayText = L"HelloWorld";
const float kMarginRatio = 0.03f;
const float kImageMaxWidthRatio = 0.20f;
const float kImageMaxHeightRatio = 0.20f;
const float kMinFontPx = 24.0f;
std::string HrToHex(HRESULT hr)
{
char buf[32]{};
std::snprintf(buf, sizeof(buf), "0x%08X", static_cast<unsigned int>(hr));
return std::string(buf);
}
void ThrowIfFailed(HRESULT hr, const char* message)
{
if (FAILED(hr))
{
throw std::runtime_error(std::string(message) + " failed. HRESULT=" + HrToHex(hr));
}
}
void ThrowIfGdiplusError(Gdiplus::Status status, const char* message)
{
if (status != Gdiplus::Ok)
{
char buf[128]{};
std::snprintf(buf, sizeof(buf), "%s failed. GDI+ status=%d", message, static_cast<int>(status));
throw std::runtime_error(buf);
}
}
BYTE ClampToByte(int value)
{
if (value < 0) return 0;
if (value > 255) return 255;
return static_cast<BYTE>(value);
}
class ScopedGdiplus
{
public:
ScopedGdiplus()
{
Gdiplus::GdiplusStartupInput input;
ThrowIfGdiplusError(Gdiplus::GdiplusStartup(&token_, &input, nullptr), "GdiplusStartup");
}
~ScopedGdiplus()
{
if (token_ != 0)
{
Gdiplus::GdiplusShutdown(token_);
}
}
private:
ULONG_PTR token_ = 0;
};
class ScopedMf
{
public:
ScopedMf()
{
ThrowIfFailed(CoInitializeEx(nullptr, COINIT_MULTITHREADED), "CoInitializeEx");
comInitialized_ = true;
ThrowIfFailed(MFStartup(MF_VERSION), "MFStartup");
mfStarted_ = true;
}
~ScopedMf()
{
if (mfStarted_)
{
MFShutdown();
}
if (comInitialized_)
{
CoUninitialize();
}
}
private:
bool comInitialized_ = false;
bool mfStarted_ = false;
};
class BufferLock
{
public:
explicit BufferLock(IMFMediaBuffer* buffer)
: buffer_(buffer)
{
if (!buffer_)
{
throw std::runtime_error("BufferLock received a null buffer.");
}
buffer_.As(&buffer2D_);
}
HRESULT LockBuffer(LONG defaultStride, DWORD heightInPixels, BYTE** scanline0, LONG* actualStride)
{
if (scanline0 == nullptr || actualStride == nullptr)
{
return E_POINTER;
}
HRESULT hr = S_OK;
if (buffer2D_)
{
hr = buffer2D_->Lock2D(scanline0, actualStride);
}
else
{
BYTE* data = nullptr;
hr = buffer_->Lock(&data, nullptr, nullptr);
if (SUCCEEDED(hr))
{
*actualStride = defaultStride;
if (defaultStride < 0)
{
*scanline0 = data + (static_cast<LONG>(heightInPixels) - 1) * std::abs(defaultStride);
}
else
{
*scanline0 = data;
}
}
}
locked_ = SUCCEEDED(hr);
return hr;
}
~BufferLock()
{
if (!locked_)
{
return;
}
if (buffer2D_)
{
buffer2D_->Unlock2D();
}
else
{
buffer_->Unlock();
}
}
private:
ComPtr<IMFMediaBuffer> buffer_;
ComPtr<IMF2DBuffer> buffer2D_;
bool locked_ = false;
};
struct VideoFormatInfo
{
UINT32 width = 0;
UINT32 height = 0;
UINT32 fpsNum = 0;
UINT32 fpsDen = 0;
UINT32 parNum = 1;
UINT32 parDen = 1;
LONG sourceStride = 0;
LONGLONG defaultFrameDuration = 0;
UINT32 bitrate = 0;
};
LONG GetDefaultStride(IMFMediaType* type)
{
LONG stride = 0;
HRESULT hr = type->GetUINT32(MF_MT_DEFAULT_STRIDE, reinterpret_cast<UINT32*>(&stride));
if (SUCCEEDED(hr))
{
return stride;
}
GUID subtype = GUID_NULL;
UINT32 width = 0;
UINT32 height = 0;
ThrowIfFailed(type->GetGUID(MF_MT_SUBTYPE, &subtype), "GetGUID(MF_MT_SUBTYPE)");
ThrowIfFailed(MFGetAttributeSize(type, MF_MT_FRAME_SIZE, &width, &height), "MFGetAttributeSize(MF_MT_FRAME_SIZE)");
ThrowIfFailed(MFGetStrideForBitmapInfoHeader(subtype.Data1, width, &stride), "MFGetStrideForBitmapInfoHeader");
ThrowIfFailed(type->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(stride)), "SetUINT32(MF_MT_DEFAULT_STRIDE)");
return stride;
}
UINT32 ChooseBitrate(IMFMediaType* nativeType, UINT32 width, UINT32 height, UINT32 fpsNum, UINT32 fpsDen)
{
UINT32 srcBitrate = 0;
if (SUCCEEDED(nativeType->GetUINT32(MF_MT_AVG_BITRATE, &srcBitrate)) && srcBitrate > 0)
{
return srcBitrate;
}
const double fps = static_cast<double>(fpsNum) / static_cast<double>(fpsDen);
double estimated = static_cast<double>(width) * static_cast<double>(height) * fps * 0.07;
if (estimated < 1500000.0)
{
estimated = 1500000.0;
}
if (estimated > 25000000.0)
{
estimated = 25000000.0;
}
return static_cast<UINT32>(estimated);
}
VideoFormatInfo ConfigureSourceReader(IMFSourceReader* reader)
{
ThrowIfFailed(reader->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE), "SetStreamSelection(all,false)");
ThrowIfFailed(reader->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE), "SetStreamSelection(video,true)");
ComPtr<IMFMediaType> nativeType;
ThrowIfFailed(reader->GetNativeMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, 0, &nativeType), "GetNativeMediaType(video)");
ComPtr<IMFMediaType> requestedType;
ThrowIfFailed(MFCreateMediaType(&requestedType), "MFCreateMediaType(video requested)");
ThrowIfFailed(requestedType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "SetGUID(video requested major)");
ThrowIfFailed(requestedType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32), "SetGUID(video requested subtype RGB32)");
ThrowIfFailed(reader->SetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, nullptr, requestedType.Get()), "SetCurrentMediaType(video RGB32)");
ComPtr<IMFMediaType> currentType;
ThrowIfFailed(reader->GetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, ¤tType), "GetCurrentMediaType(video)");
VideoFormatInfo info;
ThrowIfFailed(MFGetAttributeSize(currentType.Get(), MF_MT_FRAME_SIZE, &info.width, &info.height), "Get video frame size");
HRESULT hr = MFGetAttributeRatio(currentType.Get(), MF_MT_FRAME_RATE, &info.fpsNum, &info.fpsDen);
if (FAILED(hr))
{
ThrowIfFailed(MFGetAttributeRatio(nativeType.Get(), MF_MT_FRAME_RATE, &info.fpsNum, &info.fpsDen), "Get video frame rate");
}
if (info.fpsNum == 0 || info.fpsDen == 0)
{
throw std::runtime_error("Video frame rate is zero.");
}
hr = MFGetAttributeRatio(currentType.Get(), MF_MT_PIXEL_ASPECT_RATIO, &info.parNum, &info.parDen);
if (FAILED(hr) || info.parNum == 0 || info.parDen == 0)
{
info.parNum = 1;
info.parDen = 1;
}
info.sourceStride = GetDefaultStride(currentType.Get());
info.defaultFrameDuration = (10000000LL * info.fpsDen) / info.fpsNum;
if (info.defaultFrameDuration <= 0)
{
throw std::runtime_error("Calculated frame duration is invalid.");
}
info.bitrate = ChooseBitrate(nativeType.Get(), info.width, info.height, info.fpsNum, info.fpsDen);
return info;
}
ComPtr<IMFSinkWriter> CreateSinkWriter(const std::wstring& outputPath, const VideoFormatInfo& videoInfo, DWORD* streamIndex)
{
if (streamIndex == nullptr)
{
throw std::runtime_error("streamIndex is null.");
}
ComPtr<IMFAttributes> attributes;
ThrowIfFailed(MFCreateAttributes(&attributes, 1), "MFCreateAttributes(sink)");
ThrowIfFailed(attributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE), "SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS)");
ComPtr<IMFSinkWriter> writer;
ThrowIfFailed(MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, attributes.Get(), &writer), "MFCreateSinkWriterFromURL");
ComPtr<IMFMediaType> outputType;
ThrowIfFailed(MFCreateMediaType(&outputType), "MFCreateMediaType(video output)");
ThrowIfFailed(outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "SetGUID(output major)");
ThrowIfFailed(outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264), "SetGUID(output subtype H264)");
ThrowIfFailed(outputType->SetUINT32(MF_MT_AVG_BITRATE, videoInfo.bitrate), "SetUINT32(output bitrate)");
ThrowIfFailed(outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive), "SetUINT32(output interlace)");
ThrowIfFailed(MFSetAttributeSize(outputType.Get(), MF_MT_FRAME_SIZE, videoInfo.width, videoInfo.height), "MFSetAttributeSize(output frame size)");
ThrowIfFailed(MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, videoInfo.fpsNum, videoInfo.fpsDen), "MFSetAttributeRatio(output fps)");
ThrowIfFailed(MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, videoInfo.parNum, videoInfo.parDen), "MFSetAttributeRatio(output PAR)");
ThrowIfFailed(writer->AddStream(outputType.Get(), streamIndex), "AddStream(video)");
ComPtr<IMFMediaType> inputType;
ThrowIfFailed(MFCreateMediaType(&inputType), "MFCreateMediaType(video input)");
ThrowIfFailed(inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "SetGUID(input major)");
ThrowIfFailed(inputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_NV12), "SetGUID(input subtype NV12)");
ThrowIfFailed(inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive), "SetUINT32(input interlace)");
ThrowIfFailed(MFSetAttributeSize(inputType.Get(), MF_MT_FRAME_SIZE, videoInfo.width, videoInfo.height), "MFSetAttributeSize(input frame size)");
ThrowIfFailed(MFSetAttributeRatio(inputType.Get(), MF_MT_FRAME_RATE, videoInfo.fpsNum, videoInfo.fpsDen), "MFSetAttributeRatio(input fps)");
ThrowIfFailed(MFSetAttributeRatio(inputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, videoInfo.parNum, videoInfo.parDen), "MFSetAttributeRatio(input PAR)");
ThrowIfFailed(writer->SetInputMediaType(*streamIndex, inputType.Get(), nullptr), "SetInputMediaType(video)");
ThrowIfFailed(writer->BeginWriting(), "BeginWriting");
return writer;
}
void CopySampleToTopDownBgra(IMFSample* sample, const VideoFormatInfo& videoInfo, std::vector<BYTE>& bgra)
{
ComPtr<IMFMediaBuffer> buffer;
ThrowIfFailed(sample->ConvertToContiguousBuffer(&buffer), "ConvertToContiguousBuffer");
BufferLock lock(buffer.Get());
BYTE* scanline0 = nullptr;
LONG actualStride = 0;
ThrowIfFailed(lock.LockBuffer(videoInfo.sourceStride, videoInfo.height, &scanline0, &actualStride), "LockBuffer");
const size_t dstStride = static_cast<size_t>(videoInfo.width) * 4;
bgra.resize(dstStride * videoInfo.height);
for (UINT32 y = 0; y < videoInfo.height; ++y)
{
const BYTE* srcRow = scanline0 + static_cast<LONG>(y) * actualStride;
BYTE* dstRow = bgra.data() + static_cast<size_t>(y) * dstStride;
std::memcpy(dstRow, srcRow, dstStride);
for (UINT32 x = 0; x < videoInfo.width; ++x)
{
dstRow[static_cast<size_t>(x) * 4 + 3] = 0xFF;
}
}
}
void DrawOverlay(std::vector<BYTE>& bgra, UINT32 width, UINT32 height, Gdiplus::Image& overlayImage)
{
const INT stride = static_cast<INT>(width * 4);
Gdiplus::Bitmap frameBitmap(
static_cast<INT>(width),
static_cast<INT>(height),
stride,
PixelFormat32bppPARGB,
bgra.data());
ThrowIfGdiplusError(frameBitmap.GetLastStatus(), "Create frame bitmap");
Gdiplus::Graphics graphics(&frameBitmap);
ThrowIfGdiplusError(graphics.GetLastStatus(), "Create graphics");
graphics.SetCompositingMode(Gdiplus::CompositingModeSourceOver);
graphics.SetCompositingQuality(Gdiplus::CompositingQualityHighQuality);
graphics.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic);
graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit);
const Gdiplus::REAL margin = std::max<Gdiplus::REAL>(16.0f, static_cast<Gdiplus::REAL>(height) * kMarginRatio);
const Gdiplus::REAL maxImageW = static_cast<Gdiplus::REAL>(width) * kImageMaxWidthRatio;
const Gdiplus::REAL maxImageH = static_cast<Gdiplus::REAL>(height) * kImageMaxHeightRatio;
const Gdiplus::REAL srcW = static_cast<Gdiplus::REAL>(overlayImage.GetWidth());
const Gdiplus::REAL srcH = static_cast<Gdiplus::REAL>(overlayImage.GetHeight());
if (srcW <= 0.0f || srcH <= 0.0f)
{
throw std::runtime_error("Overlay image has invalid size.");
}
const Gdiplus::REAL imageScale =
std::min<Gdiplus::REAL>(1.0f, std::min(maxImageW / srcW, maxImageH / srcH));
const Gdiplus::REAL drawW = srcW * imageScale;
const Gdiplus::REAL drawH = srcH * imageScale;
Gdiplus::RectF imageRect(margin, margin, drawW, drawH);
Gdiplus::SolidBrush imagePlate(Gdiplus::Color(96, 0, 0, 0));
graphics.FillRectangle(
&imagePlate,
imageRect.X - 8.0f,
imageRect.Y - 8.0f,
imageRect.Width + 16.0f,
imageRect.Height + 16.0f);
graphics.DrawImage(&overlayImage, imageRect);
const Gdiplus::REAL fontPx =
std::max<Gdiplus::REAL>(kMinFontPx, static_cast<Gdiplus::REAL>(height) * 0.06f);
Gdiplus::Font font(L"Segoe UI", fontPx, Gdiplus::FontStyleBold, Gdiplus::UnitPixel);
ThrowIfGdiplusError(font.GetLastStatus(), "Create font");
Gdiplus::StringFormat stringFormat;
stringFormat.SetAlignment(Gdiplus::StringAlignmentNear);
stringFormat.SetLineAlignment(Gdiplus::StringAlignmentNear);
Gdiplus::RectF measureLayout(
margin,
static_cast<Gdiplus::REAL>(height) - margin - fontPx * 2.0f,
static_cast<Gdiplus::REAL>(width) - margin * 2.0f,
fontPx * 2.0f);
Gdiplus::RectF measured;
graphics.MeasureString(kOverlayText, -1, &font, measureLayout, &stringFormat, &measured);
Gdiplus::RectF textBg(
measured.X - 12.0f,
measured.Y - 8.0f,
measured.Width + 24.0f,
measured.Height + 16.0f);
Gdiplus::SolidBrush textPlate(Gdiplus::Color(128, 0, 0, 0));
graphics.FillRectangle(&textPlate, textBg);
Gdiplus::SolidBrush shadowBrush(Gdiplus::Color(220, 0, 0, 0));
Gdiplus::RectF shadowLayout = measureLayout;
shadowLayout.X += 2.0f;
shadowLayout.Y += 2.0f;
graphics.DrawString(kOverlayText, -1, &font, shadowLayout, &stringFormat, &shadowBrush);
Gdiplus::SolidBrush textBrush(Gdiplus::Color(235, 255, 255, 255));
graphics.DrawString(kOverlayText, -1, &font, measureLayout, &stringFormat, &textBrush);
}
void BgraToNv12(const BYTE* bgra, UINT32 width, UINT32 height, BYTE* nv12)
{
const bool useBt709 = (width > 1024 || height > 576);
const int yR = useBt709 ? 47 : 66;
const int yG = useBt709 ? 157 : 129;
const int yB = useBt709 ? 16 : 25;
const int uR = useBt709 ? -26 : -38;
const int uG = useBt709 ? -87 : -74;
const int uB = 112;
const int vR = 112;
const int vG = useBt709 ? -102 : -94;
const int vB = useBt709 ? -10 : -18;
BYTE* yPlane = nv12;
BYTE* uvPlane = nv12 + static_cast<size_t>(width) * height;
const size_t srcStride = static_cast<size_t>(width) * 4;
for (UINT32 y = 0; y < height; ++y)
{
const BYTE* srcRow = bgra + static_cast<size_t>(y) * srcStride;
BYTE* dstY = yPlane + static_cast<size_t>(y) * width;
for (UINT32 x = 0; x < width; ++x)
{
const BYTE b = srcRow[x * 4 + 0];
const BYTE g = srcRow[x * 4 + 1];
const BYTE r = srcRow[x * 4 + 2];
const int Y = ((yR * r + yG * g + yB * b + 128) >> 8) + 16;
dstY[x] = ClampToByte(Y);
}
}
for (UINT32 y = 0; y < height; y += 2)
{
const BYTE* row0 = bgra + static_cast<size_t>(y) * srcStride;
const BYTE* row1 = bgra + static_cast<size_t>(y + 1) * srcStride;
BYTE* dstUV = uvPlane + static_cast<size_t>(y / 2) * width;
for (UINT32 x = 0; x < width; x += 2)
{
int b = 0;
int g = 0;
int r = 0;
for (UINT32 dy = 0; dy < 2; ++dy)
{
const BYTE* row = (dy == 0) ? row0 : row1;
for (UINT32 dx = 0; dx < 2; ++dx)
{
const UINT32 ix = x + dx;
b += row[ix * 4 + 0];
g += row[ix * 4 + 1];
r += row[ix * 4 + 2];
}
}
b = (b + 2) / 4;
g = (g + 2) / 4;
r = (r + 2) / 4;
const int U = ((uR * r + uG * g + uB * b + 128) >> 8) + 128;
const int V = ((vR * r + vG * g + vB * b + 128) >> 8) + 128;
dstUV[x + 0] = ClampToByte(U);
dstUV[x + 1] = ClampToByte(V);
}
}
}
ComPtr<IMFSample> CreateNv12Sample(
const std::vector<BYTE>& bgra,
const VideoFormatInfo& videoInfo,
LONGLONG sampleTime,
LONGLONG sampleDuration)
{
const DWORD bufferSize =
static_cast<DWORD>(videoInfo.width * videoInfo.height * 3 / 2);
ComPtr<IMFMediaBuffer> buffer;
ThrowIfFailed(MFCreateMemoryBuffer(bufferSize, &buffer), "MFCreateMemoryBuffer");
BYTE* dst = nullptr;
DWORD maxLength = 0;
DWORD currentLength = 0;
ThrowIfFailed(buffer->Lock(&dst, &maxLength, ¤tLength), "Lock(NV12 buffer)");
try
{
BgraToNv12(bgra.data(), videoInfo.width, videoInfo.height, dst);
}
catch (...)
{
buffer->Unlock();
throw;
}
ThrowIfFailed(buffer->Unlock(), "Unlock(NV12 buffer)");
ThrowIfFailed(buffer->SetCurrentLength(bufferSize), "SetCurrentLength(NV12 buffer)");
ComPtr<IMFSample> sample;
ThrowIfFailed(MFCreateSample(&sample), "MFCreateSample");
ThrowIfFailed(sample->AddBuffer(buffer.Get()), "AddBuffer(output sample)");
ThrowIfFailed(sample->SetSampleTime(sampleTime), "SetSampleTime");
ThrowIfFailed(sample->SetSampleDuration(sampleDuration), "SetSampleDuration");
return sample;
}
}
int wmain(int argc, wchar_t* argv[])
{
if (argc != 4)
{
std::wcerr << L"Usage: OverlayMp4.exe <input.mp4> <overlayImage.png> <output.mp4>" << std::endl;
return 1;
}
const std::wstring inputPath = argv[1];
const std::wstring imagePath = argv[2];
const std::wstring outputPath = argv[3];
try
{
if (_wcsicmp(inputPath.c_str(), outputPath.c_str()) == 0)
{
throw std::runtime_error("Input and output paths must be different.");
}
ScopedMf mf;
ScopedGdiplus gdiplus;
ComPtr<IMFAttributes> readerAttributes;
ThrowIfFailed(MFCreateAttributes(&readerAttributes, 1), "MFCreateAttributes(reader)");
ThrowIfFailed(
readerAttributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE),
"SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING)");
ComPtr<IMFSourceReader> reader;
ThrowIfFailed(
MFCreateSourceReaderFromURL(inputPath.c_str(), readerAttributes.Get(), &reader),
"MFCreateSourceReaderFromURL");
VideoFormatInfo videoInfo = ConfigureSourceReader(reader.Get());
if ((videoInfo.width % 2) != 0 || (videoInfo.height % 2) != 0)
{
throw std::runtime_error(
"This sample requires even video width and height because NV12 is 4:2:0.");
}
Gdiplus::Image overlayImage(imagePath.c_str());
ThrowIfGdiplusError(overlayImage.GetLastStatus(), "Load overlay image");
DWORD videoStreamIndex = 0;
ComPtr<IMFSinkWriter> writer =
CreateSinkWriter(outputPath, videoInfo, &videoStreamIndex);
std::vector<BYTE> bgra;
LONGLONG firstTimestamp = -1;
unsigned long long frameCount = 0;
while (true)
{
DWORD flags = 0;
LONGLONG timestamp = 0;
ComPtr<IMFSample> inputSample;
ThrowIfFailed(
reader->ReadSample(
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
0,
nullptr,
&flags,
×tamp,
&inputSample),
"ReadSample(video)");
if ((flags & MF_SOURCE_READERF_CURRENTMEDIATYPECHANGED) != 0)
{
throw std::runtime_error("Dynamic video format change is not supported in this sample.");
}
if ((flags & MF_SOURCE_READERF_NATIVEMEDIATYPECHANGED) != 0)
{
throw std::runtime_error("Native video format change is not supported in this sample.");
}
if ((flags & MF_SOURCE_READERF_STREAMTICK) != 0)
{
if (firstTimestamp < 0)
{
firstTimestamp = timestamp;
}
ThrowIfFailed(
writer->SendStreamTick(videoStreamIndex, timestamp - firstTimestamp),
"SendStreamTick");
}
if (inputSample)
{
if (firstTimestamp < 0)
{
firstTimestamp = timestamp;
}
LONGLONG duration = 0;
if (FAILED(inputSample->GetSampleDuration(&duration)) || duration <= 0)
{
duration = videoInfo.defaultFrameDuration;
}
CopySampleToTopDownBgra(inputSample.Get(), videoInfo, bgra);
DrawOverlay(bgra, videoInfo.width, videoInfo.height, overlayImage);
ComPtr<IMFSample> outputSample =
CreateNv12Sample(bgra, videoInfo, timestamp - firstTimestamp, duration);
ThrowIfFailed(
writer->WriteSample(videoStreamIndex, outputSample.Get()),
"WriteSample(video)");
++frameCount;
}
if ((flags & MF_SOURCE_READERF_ENDOFSTREAM) != 0)
{
break;
}
}
ThrowIfFailed(writer->Finalize(), "Finalize");
std::wcout
<< L"Done. frames=" << frameCount
<< L", output=" << outputPath
<< std::endl;
return 0;
}
catch (const std::exception& ex)
{
std::cerr << ex.what() << std::endl;
return 1;
}
}
7. 阅读这份实现时需要把握的要点
7.1 便于绘制的格式和编码器容易接受的格式是两回事
这份示例中,
Source Reader输出:RGB32- 绘制:
GDI+ Sink Writer输入:NV12
采用的是这样的流程。
理由很简单:要叠加文字或 PNG,RGB 系列更好处理;要交给 H.264 编码,NV12 更好处理。
阅读实现时,把这里拆成“绘制阶段”和“编码前整理阶段”来看,会更容易跟上思路。
7.2 先吸收 stride 与上下方向,再进行绘制
视频帧在内存中的排列方式,未必和看上去的样子一致。
- stride 有时不等于
width * 4 - 上下方向有时是反过来的
IMF2DBuffer和IMFMediaBuffer的处理方式略有不同
因此,这份代码会先归一化为 top-down 的 BGRA 缓冲区,然后再绘制。 先把这一步统一好,绘制侧的代码就能写得相当直白。
flowchart TB
accTitle: 绘制前先归一化的理由
accDescr: stride与宽度的4倍不一致、上下方向相反、IMF2DBuffer与IMFMediaBuffer处理方式不同等差异,先通过归一化为top-down的BGRA缓冲区吸收掉,然后再绘制的图。
ir1["stride不一致"] --> nr1["归一化为top-down的BGRA"]
ir2["上下方向有时相反"] --> nr1
ir3["缓冲区种类不同处理也不同"] --> nr1
nr1 --> nr2["绘制代码变得直白"]
图12:把内存布局的差异集中在一处吸收掉,绘制一侧就不必知道这些。
7.3 ReadSample 不能只看 HRESULT,还要看 flags 和 sample
即使 ReadSample 返回 S_OK,sample 也可能是 nullptr。
典型情况是:
MF_SOURCE_READERF_STREAMTICKMF_SOURCE_READERF_ENDOFSTREAM- 以及其他流事件
等等。
因此,循环中需要把 HRESULT、flags、inputSample 这三者放在一起检查。
尤其是漏看 STREAMTICK 和 ENDOFSTREAM 时,后续的时间线处理很容易出问题。
flowchart TB
accTitle: ReadSample要看的3点
accDescr: ReadSample即使返回S_OK,sample也可能是nullptr,典型情况是STREAMTICK和ENDOFSTREAM等流事件,因此循环中要把HRESULT、flags、sample这三者放在一起确认的图。
rs1["ReadSample返回"] --> rs2["确认HRESULT"]
rs2 --> rs3["确认flags"]
rs3 --> rs4["确认sample是否存在"]
rs4 -.-> rs5["即使S_OK也可能是nullptr"]
图13:不要只凭一个返回值判断,用三者一组来处理一帧。
7.4 timestamp 和 duration 沿用输入更安全
时间戳的单位是 100ns。
另外,duration 需要单独从 IMFSample 中取。
与其按固定 fps 每次强行累加,尽量沿用输入 sample 的 timestamp / duration 更不容易出问题。
这份示例也是只在取不到 duration 时,才回退到根据 fps 计算出的默认值。
flowchart TB
accTitle: timestamp和duration的处理
accDescr: 不按固定fps强行累加,而是尽量沿用输入sample的timestamp和duration,只在取不到duration时才回退到根据fps计算的默认值的图。
ts1["从输入sample中取出"] --> ts2{"duration取到了吗"}
ts2 -->|"取到了"| ts3["直接沿用"]
ts2 -->|"取不到"| ts4["使用来自fps的默认值"]
ts3 -.-> ts5["比强行累加更不容易出错"]
图14:时间不要自己造,从输入沿用下来才是基本做法。
7.5 GDI+ 引入成本低,但长时长或高分辨率场景还有下一阶段
GDI+ 相当适合做单文件即可运行的示例,但在长时长视频或大批量处理 4K 的场景下,D3D11 + Direct2D + DirectWrite 有时更有优势。
- 先用
GDI+把整体流程跑通 - 之后需要时再替换成
Direct2D / DirectWrite - 色彩转换交给
Video Processor MFT或 GPU 侧
采用这种分阶段推进的方式,就能在不破坏原有设计的前提下更容易扩展。
flowchart TB
accTitle: 绘制API的分阶段推进
accDescr: 先用GDI+把整体流程跑通,需要时再替换成Direct2D和DirectWrite,色彩转换交给Video Processor MFT或GPU侧,这种不破坏设计的分阶段推进方式的图。
gd1["先用GDI+跑通整体"] --> gd2["必要时替换成Direct2D"]
gd2 --> gd3["把色彩转换交给MFT或GPU"]
gd1 -.-> gd4["长时长和4K大批量处理是下一阶段"]
图15:从引入成本低的方案起步,再从需要性能的地方依次替换。
7.6 这份示例只聚焦于视频
如果把音频也一起塞进同一篇文章,论述的主轴容易变得分散。 因此这份示例把焦点对准向视频帧烧录图片和文字,输出是只有视频的 MP4。
在实务中,下一阶段可以扩展为
- 只有视频走
Source Reader -> 合成 -> Sink Writer - 音频保持压缩状态直接 remux
这样的结构会比较好处理。
8. 如果“拿到的视频数据”不是文件而是内存中的 MP4 字节序列
这次的代码使用的是 MFCreateSourceReaderFromURL,所以输入是文件路径。
不过,如果需求是“想对从 API 拿到的 mp4 字节序列做同样的事”,思路并不会变。 要改的只有入口。
- 准备一个
IStream或自定义的流 - 把它作为
IMFByteStream交给Source Reader - 之后同样是
RGB32 -> 绘制 -> NV12 -> Sink Writer
也就是说,本质不在于视频数据怎么持有,而在于解码后如何向每一帧写入内容。
flowchart TB
accTitle: 输入是字节序列时也只改入口
accDescr: 输入是文件路径时用MFCreateSourceReaderFromURL,是内存中的MP4字节序列时准备IMFByteStream交给Source Reader,之后从RGB32到绘制、NV12、Sink Writer的流程完全相同的图。
in1["文件路径"] --> sr1["Source Reader"]
in2["内存中的字节序列"] --> bs1["做成IMFByteStream"]
bs1 --> sr1
sr1 --> same1["之后的流程相同"]
图16:数据的持有方式变了,改动的也只有入口这一段。
9. 若要在生产环境中扩展
9.1 添加音频 remux
作为最初的扩展方向,最贴近实务的是把音频原样保留。 只对视频重新编码,音频保持压缩状态以相同格式写回,这样既能满足需求,又不会大幅增加实现量。
Sink Writer 明确支持把压缩的输入以相同格式写到输出这种组合,用于不重新编码的 remux。需要添加的有下面 3 处。
- 让音频流保持压缩状态被接收。 用
reader->SetStreamSelection(MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE)启用它,再把GetNativeMediaType取得的类型原样传给SetCurrentMediaType。不想让它解码时就指定 native 的类型,这是 Source Reader 侧的用法 - 在 Sink Writer 上把该类型同时设为输入和输出。 向
writer->AddStream(audioType.Get(), &audioStreamIndex)和writer->SetInputMediaType(audioStreamIndex, audioType.Get(), nullptr)传入同一个媒体类型 - 让 timestamp 的基准与视频一致。 6.2 的代码在视频侧使用的
firstTimestamp,也要以同样的方式从音频样本中减去。如果这里用了各自不同的基准,声音和画面就会错位
ReadSample 的调用也要从现在只指定视频的写法,改成使用 MF_SOURCE_READER_ANY_STREAM、再按返回的 stream index 分派的写法。
另外,除非编码器提供,否则 Sink Writer 不会做音频重采样,也不会做视频的尺寸调整和帧率转换。如果输入的音频格式是 MP4 的接收器无法接受的,那就不是 remux,而必须重新编码。
flowchart TB
accTitle: 音频remux要添加的3处
accDescr: 要把音频原样保留,需要添加让音频流保持压缩状态被接收、在Sink Writer上把同一类型设为输入和输出、让timestamp的基准与视频一致这3处的图。
ar1["让音频保持压缩状态被接收"] --> ar2["把同一类型设为输入和输出"]
ar2 --> ar3["timestamp基准与视频一致"]
ar3 -.-> ar4["基准不同会导致声音错位"]
图17:remux 只需要加 3 处,但别忘了对齐时间基准。
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。
因此,如果想在多个应用或 pipeline 中复用同一套叠加处理,custom MFT 是一个干净的选择。
不过,作为第一版实现,
- 需要满足
IMFTransform契约 - 输入输出媒体类型的管理成本增加
- 注册和调试的难度上升
所以在实务中,先用 Source Reader + 合成 + Sink Writer 正确跑通,等真正需要时再拆分成 MFT,往往更容易推进。
flowchart TB
accTitle: 考虑custom MFT的时机
accDescr: 先用Source Reader、合成和Sink Writer正确跑通,等到想在多个应用或管线中复用同一套叠加处理时再拆分成custom MFT的判断流程图。
mf1["先用当前结构正确跑通"] --> mf2{"是否想复用了"}
mf2 -->|"想在多个应用中使用"| mf3["拆分成custom MFT"]
mf2 -->|"一个程序就够用"| mf4["维持当前结构"]
mf3 -.-> mf5["实现、注册、调试难度上升"]
图18:做成组件很干净,但等到真正需要时再做也不迟。
9.5 把文字串参数化,使其能绘制日文
示例中的文字串固定在 kOverlayText 里,值是 HelloWorld。要烧录设备编号或操作员姓名时,首先就会想把这里改成参数。要绘制日文时,字体也需要一起改。
改动共 4 处。
1. 在匿名 namespace 中添加要使用的字体名。 已有的 kOverlayText 作为默认值保留。
const wchar_t* kOverlayText = L"HelloWorld"; // 已有。省略参数时作为默认值使用
const wchar_t* kFontFamilyName = L"Yu Gothic UI"; // 能显示日文的字体
const wchar_t* kFallbackFontFamilyName = L"Segoe UI"; // 供没有上面那个字体的环境使用
2. 让 DrawOverlay 能接收要绘制的文字串。
void DrawOverlay(
std::vector<BYTE>& bgra,
UINT32 width,
UINT32 height,
Gdiplus::Image& overlayImage,
const std::wstring& overlayText) // 新增
3. 在 DrawOverlay 内部替换字体创建和对文字串的引用。 把原来的 Gdiplus::Font font(L"Segoe UI", ...) 那一行换成下面的内容。
// 如果指定的字体没有安装,就退回到默认字体
Gdiplus::FontFamily preferred(kFontFamilyName);
Gdiplus::FontFamily fallback(kFallbackFontFamilyName);
const Gdiplus::FontFamily& family = preferred.IsAvailable() ? preferred : fallback;
if (!family.IsAvailable())
{
throw std::runtime_error("Neither the preferred nor the fallback font family is installed.");
}
Gdiplus::Font font(&family, fontPx, Gdiplus::FontStyleBold, Gdiplus::UnitPixel);
ThrowIfGdiplusError(font.GetLastStatus(), "Create font");
在此基础上,把传给 MeasureString 和两次 DrawString 的 kOverlayText 全部替换成 overlayText.c_str()。这 3 处如果不全改,阴影里就会留下旧的文字。
4. 在 wmain 中接收参数并传给 DrawOverlay。
if (argc < 4 || argc > 5)
{
std::wcerr
<< L"Usage: OverlayMp4.exe <input.mp4> <overlayImage.png> <output.mp4> [text]"
<< std::endl;
return 1;
}
const std::wstring inputPath = argv[1];
const std::wstring imagePath = argv[2];
const std::wstring outputPath = argv[3];
const std::wstring overlayText = (argc == 5) ? std::wstring(argv[4]) : std::wstring(kOverlayText);
然后把循环中的调用改成下面这样。
DrawOverlay(bgra, videoInfo.width, videoInfo.height, overlayImage, overlayText);
处理日文时有两点需要注意。
- 如果要在
.cpp中以字面量写日文,就把源文件保存为带 BOM 的 UTF-8,或者给 MSVC 加上/utf-8再构建。 漏掉这一步就会出现乱码。通过命令行参数传入时,wmain以 UTF-16 接收,因此不会出现这个问题 - 字体未必已经安装在目标环境中。 要像上面的代码那样,一定准备好回退方案。如果在没有该字体的环境中悄悄换成了别的字体,之后很难查出布局错位的原因
flowchart TB
accTitle: 绘制日文所需的改动点
accDescr: 要把文字串参数化并绘制日文,需要改动添加字体名常量、给DrawOverlay增加参数、替换成带回退的字体创建、在wmain中接收参数这4处,并注意乱码和字体缺失的图。
jp1["添加字体名常量"] --> jp2["给DrawOverlay增加参数"]
jp2 --> jp3["带回退地创建字体"]
jp3 --> jp4["在wmain中接收参数并传入"]
jp3 -.-> jp5["没有的字体退回默认字体"]
图19:改动共 4 处,只有字体准备和字符编码这两点最容易翻车。
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 管理会一股脑地涌上来。 先分阶段把流程跑通,之后只在真正需要的地方加强,无论设计还是调试都会轻松很多。
flowchart TB
accTitle: 生产环境中的扩展顺序
accDescr: 先添加音频remux,把GDI+替换成Direct2D和DirectWrite,把NV12转换交给Video Processor MFT或GPU侧,面向长时长和高分辨率转向基于D3D11 surface的方案,需要复用性时再拆分成custom MFT,按这个顺序扩展不容易出问题的图。
ex1["添加音频remux"] --> ex2["把绘制替换成Direct2D"]
ex2 --> ex3["把转换交给MFT或GPU"]
ex3 --> ex4["转向基于D3D11 surface的方案"]
ex4 --> ex5["必要时拆分成custom MFT"]
图20:避免一次全都塞进去,按这个顺序一段一段地增强。
11. 相关文章
12. 参考资料
- 本文的完整示例代码(单文件
.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 自动转换与 NV12/YUY2 手动转换、stride、色彩空间几个角度,梳理在 Media Foundation 中把 YUV 帧转换为 RGB 的方法。
用 Media Foundation 从 MP4 的指定时刻提取静止图像的方法
本文整理用 Source Reader 从 MP4 中取出最接近指定时刻的帧,处理好 stride 与 RGB32 的第 4 个字节(alpha)之后保存为 PNG 的完整实现步骤。
Time Travel Debugging ── 把长期运行中不复现的缺陷“录下来”再倒回去
一个月才出一次的缺陷,崩溃转储只拍得到结果。本文讲解如何用 WinDbg 的 Time Travel Debugging(TTD) 录制执行并倒回,涵盖 TTD.exe 的录制设计、环形缓冲区、TTD.Calls 查询,以及与转储的分工。
参数为什么会坏掉 ── Windows 命令行参数的规则
在 Windows 上并不存在参数的数组,传给 CreateProcess 的是一条字符串,切分由接收方完成。本文讲解 CommandLineToArgvW・CRT・.NET 的切分规则,以及 .NET 的 ArgumentList 与 C++ 中正确的组装方法。
父进程消失之后还剩下什么 —— 用 Job Object 圈养子进程
为什么强制结束 UI 之后,SDK 的辅助进程仍然残留,一直占着摄像头或 COM 端口?本文从测量应用的视角,讲解如何用 Job Object 把进程树变成一个单位,并借助 KillOnJobClose 与完成端口来设计子进程的寿命。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
这个主题与横跨 Media Foundation、GDI+、Direct2D / DirectWrite、色彩转换和视频输出的 Windows 应用实现直接相关。
技术咨询 & 设计评审
也适合用来梳理设计:如何把单文件实现扩展成面向生产环境的结构,以及在哪里划分音频 remux 与 GPU 化的边界。
常见问题
汇总了咨询这一主题时常见的问题。
- 用 Media Foundation 向 MP4 每一帧烧录图片和文字的基本流程是什么?
- 基本形式是“用 Source Reader 解码 → 合成到未压缩帧 → 必要时进行色彩转换 → 用 Sink Writer 重新编码”。叠加图片或文字这件事本身并不是 Media Foundation 的工作,而是 GDI+、Direct2D/DirectWrite、WIC 等绘制 API 的工作。如果只是想先跑通第一版,采用 Source Reader → RGB32 → 用 GDI+ 绘制 → NV12 → Sink Writer 的结构比较容易理解;如果优先考虑速度和可扩展性,转向 D3D11/DXGI surface 配合 Direct2D/DirectWrite 的结构会有更大的提升空间。
- 可以把 RGB32 的帧直接交给 H.264 编码吗?
- 不一定可以。微软的 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,这样更不容易出错。