用 Media Foundation 从 MP4 的指定时刻提取静止图像的方法
· 更新日期: · 小村 豪 · Media Foundation, C++, Windows 开发, WIC
引用本文(DOI: 10.5281/zenodo.21615460)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《用 Media Foundation 从 MP4 的指定时刻提取静止图像的方法》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615460 https://comcomponent.com/zh-CN/blog/2026/03/15/000-media-foundation-extract-still-image-from-mp4-at-specific-time/
- DOI(最新版本)
- 10.5281/zenodo.21615460
- DOI(此版本)
- 10.5281/zenodo.22281977
从 MP4 中取出“第 12.3 秒的那一帧”,这类需求相当常见。比如生成缩略图、检验日志、监控视频的代表帧、设备日志的留证等等。
不过在 Media Foundation 里,这件事并没有那么顺理成章。看上去只要在 SetCurrentPosition 之后调用一次 ReadSample 就结束了,但实际上还会牵扯到 key frame、timestamp、stride、图像的上下方向,以及 RGB32 的第 4 个字节等等。处理得草率,就会碰到时刻略有偏差、图像上下颠倒、PNG 莫名其妙变透明这类不起眼却很讨厌的问题。
flowchart TB
accTitle: 处理草率时会出现的问题
accDescr: 表示只做一次 seek、读一次、保存的实现会牵扯到 key frame、timestamp、stride、上下方向和 RGB32 的第 4 个字节,从而引发时刻偏差、上下颠倒、透明 PNG 三类问题的图。
rough1["seek 之后读一次就保存"] --> tz1["时刻略有偏差"]
rough1 --> ud1["图像上下颠倒"]
rough1 --> tp1["PNG 莫名其妙变透明"]
图1:看上去到此为止就完事的实现,会引出时刻偏差、上下颠倒、透明 PNG 这些不起眼的问题。
关于 Media Foundation 的整体轮廓,之前写过的 Media Foundation 入门:用 COM 视角理解 API 也可以参考。本文则从那里再下沉一层,只聚焦在从 MP4 中取出一帧这件事上。
本文用 IMFSourceReader,把从 MP4 中取出最接近指定时刻的一张静止图像并保存为 PNG 这条路径,连同实际工作中容易踩的坑一起整理清楚。而且文章最后还放了一份可以直接粘贴到 Visual Studio 的 C++ 控制台应用项目 .cpp 里的单文件完整代码。文中不会出现零碎拆分的代码片段,只要把最后那一整块拿走就能运行。
另外,本文中出现的代码,已经作为一整套示例代码(单文件完整的 C++ 控制台应用)在 GitHub 上公开。
media-foundation-extract-still-image-from-mp4-at-specific-time - komurasoft-blog-samples (GitHub)
1. 先说结论
先只把结论摆出来,是这样的。
- 如果只是从 MP4 中取出一帧,这次
Source Reader比Media Session作为入口更直接 IMFSourceReader::SetCurrentPosition并不保证 exact seek。通常会落在 target 稍靠前、尤其偏向 key frame 的位置,因此之后需要推进ReadSample,比较目标时刻前后的帧ReadSample即使成功,也可能出现pSample == nullptr的情况。不能只看HRESULT,还要一起看flags和pSample- 把输出媒体类型统一到
MFVideoFormat_RGB32,保存起来会更省事 - 但是
RGB32的第 4 个字节不一定是 alpha,直接写入 PNG 有时会得到透明图像。比较安全的做法是在保存前填入0xFF,使其变为不透明 - 如果对逐行的
stride以及 top-down / bottom-up 处理得草率,图像就会错乱,所以取出的 sample 要先整理成 top-down 的连续 BGRA,再交给 PNG
一句话说,seek -> 读一次 -> 保存 稍嫌草率,做到 seek -> 一边看 timestamp 一边比较前后 -> 注意 stride 进行拷贝 -> 保存 PNG 这个程度,就会相当稳定。
flowchart TB
accTitle: 草率的流程与稳定的流程
accDescr: 表示 seek 之后读一次就保存的流程比较草率,而 seek 之后一边看 timestamp 一边比较前后、注意 stride 进行拷贝再保存 PNG 的流程会相当稳定的图。
sk1["seek"] --> cmp1["一边看 timestamp 一边比较前后"]
cmp1 --> cp1["注意 stride 进行拷贝"]
cp1 --> sv1["保存 PNG"]
sk1 -.->|"读一次就保存太草率"| ng1["偏差与错乱的根源"]
图2:不要在 seek 之后立刻保存,中间插入前后比较和注意 stride 的拷贝,结果就会稳定。
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 15 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
2. 本文的前提
2.1. 目标读者与预备知识
- 面向用 C++ 调用过 Windows COM API 的读者。只要理解用
CoInitializeEx初始化、用Release释放接口指针这条流程就够了 - 返回值
HRESULT用SUCCEEDED/FAILED宏判断。失败时直接读十六进制的值。以0x8007开头的值来自 Win32 错误,低 16 位就是 Win32 的错误码(0x80070057与E_INVALIDARG是同一个值)。以MF_E_开头的 Media Foundation 专用错误定义在mferror.h中 - 即使是第一次接触 Media Foundation 本身也能读下去。从 COM 视角看到的整体轮廓,整理在 Media Foundation 入门:用 COM 视角理解 API 中
2.2. 开发环境
| 项目 | 本文的前提 |
|---|---|
| 操作系统 | Windows 10 / Windows 11 |
| IDE | Visual Studio 2022(工作负载“使用 C++ 的桌面开发”) |
| SDK | Visual Studio 附带的 Windows SDK(包含 Media Foundation 与 WIC 的头文件和库) |
| 项目 | C++ 的“控制台应用”模板 |
| 平台 | x64 |
| 附加库 | 无。mfplat.lib 等链接指定由代码中的 #pragma comment(lib, ...) 完成 |
粘贴方式以及预编译头相关的细节注意事项,整理在“7. 构建与运行的备忘”中。
2.3. 输入与输出的前提
本文的前提如下。
- 输入是本地的 MP4 文件
- 想要的是一张静止图像
- 返回的不是“恰好等于指定时刻”,而是“最接近指定时刻的帧”
- 实现采用同步模式的
IMFSourceReader - 保存格式是用 WIC 生成的 PNG
- 不使用外部库,只用 Windows 标准 API 完成
- 以中途分辨率不发生变化的一般 MP4 为前提
如果要做到播放、音频同步、进度条、与 UI 联动,那还有别的设计方式;但如果用途就是取出一帧,这种做法相当好懂。
flowchart TB
accTitle: 本文前提的构成
accDescr: 表示以本地 MP4 为输入,用同步模式的 IMFSourceReader 取得最接近指定时刻的帧,再用 WIC 保存为 PNG,且不依赖外部库的前提构成图。
mp1["本地的 MP4"] --> rd1["同步模式的 IMFSourceReader"]
rd1 --> nf1["最接近指定时刻的帧"]
nf1 --> png1["用 WIC 保存 PNG"]
rd1 -.-> std1["只用 Windows 标准 API 完成"]
图3:从输入到保存,全部走不依赖外部库的同步 Source Reader 构成。
3. 先看整理表
3.1. 处理流程
| 要做的事 | 使用的 API | 作用 |
|---|---|---|
| 打开 MP4 | MFCreateSourceReaderFromURL |
从文件创建媒体源 |
| 只选择视频 | SetStreamSelection |
不去读音频 |
| 转换为 RGB32 | SetCurrentMediaType + MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING |
得到便于保存的未压缩帧 |
| 移动到指定时刻 | SetCurrentPosition |
以 100ns 为单位进行 seek |
| 读取帧 | ReadSample |
逐个取得已解码的 sample |
| 比较前一帧 / 后一帧 | sample timestamp | 确定最接近指定时刻的一帧 |
| 保存为 PNG | WIC | 写出成图像文件 |
3.2. 本文采用的判定规则
虽说是“指定时刻的静止图像”,但视频不是连续量而是离散的帧,所以在实现里先定好按什么规则选出一帧会更省事。
本文采用下面这套规则。
- seek 之后推进
ReadSample - 保留
timestamp < target的最后一个 sample - 一旦出现
timestamp >= target的第一个 sample,就比较前一个 sample 与当前 sample 相对 target 的差值 - 采用更接近 target 的那一个
这样取到的就不是“target 之后的第一帧”,而更容易拿到最接近 target 的那一帧。
flowchart TB
accTitle: 选出最接近的一帧的判定规则
accDescr: 表示 seek 之后推进 ReadSample,保留 target 之前的最后一个 sample,当 target 之后的第一个 sample 到来时比较两者的差值并采用更接近 target 的那一个的判定规则图。
adv1["seek 之后推进 ReadSample"] --> bf1["保留 target 之前的最后一个 sample"]
bf1 --> af1["target 之后的第一个 sample 到来"]
af1 --> df1["比较两者与 target 的差值"]
df1 --> pk1["采用更接近的那一个"]
图4:先比较前后两个候选帧再决定采用哪个,取到的就不是“target 之后的第一帧”,而是最接近的一帧。
3.3. 处理的整体印象
从头到尾大致是 input.mp4 -> 创建 Source Reader -> 请求 RGB32 -> seek -> 反复调用 ReadSample -> 比较 target 前后的帧 -> 重新整理成 top-down BGRA -> 用 WIC 保存 PNG。
看上去很简单,但 seek 的精度、sample 为 null、stride、第 4 个字节的处理这几处各自都藏着小陷阱。只要不踩这 4 个坑,实现本身会很顺。
flowchart TB
accTitle: 处理中潜藏的 4 个陷阱
accDescr: 表示处理看上去简单,但存在 seek 精度、sample 为 null、stride、RGB32 第 4 个字节这 4 个小陷阱,只要不踩到实现就能顺利收束的图。
lk1["看上去简单的处理"] --> t1["seek 的精度"]
lk1 --> t2["sample 为 null"]
lk1 --> t3["stride 与上下方向"]
t3 -.-> t4["第 4 个字节的处理"]
t1 --> okf["不踩坑就能顺利收束"]
t2 --> okf
t3 --> okf
图5:看似简单的流程各处都有 4 个陷阱,只要留意这几点,实现就能顺利收束。
4. 先要掌握的陷阱
先把本章出现的术语简短地整理一下。
| 术语 | 含义 |
|---|---|
| key frame(关键帧) | 不参照前后帧,仅凭自身就能解码的帧。在 H.264 中 IDR 图像等就属于这一类 |
| GOP(Group of Pictures) | 从某个 key frame 到下一个 key frame 之前的一组帧。GOP 越长,key frame 的间隔就越大,seek 之后落到的位置也越容易远离指定时刻 |
| stride | 图像缓冲区中一行所占的字节数。它不一定等于 宽度 × 每像素字节数,行末可能会有 padding |
| top-down / bottom-up | 图像缓冲区的开头是上边一行还是下边一行的区别。bottom-up 用负的 stride 值来表示 |
MF_SOURCE_READERF_STREAMTICK |
ReadSample 返回的 flag 之一,表示流中存在间隙(数据中断)。这个 flag 置位的那次调用取不到帧,需要重新读取 |
4.1. SetCurrentPosition 不是 exact seek
正如 Microsoft Learn 的 IMFSourceReader::SetCurrentPosition 所写,它并不保证 exact seeking。对视频而言,通常会落在指定位置稍靠前、尤其偏向 key frame 的位置。而且,前提是之后还要靠 ReadSample 继续前进到目标位置。
因此,下面这种实现相当危险。
SetCurrentPosition(target)- 调用一次
ReadSample - 保存那一帧
在 GOP 较长的视频里,这样做会偏。如果视频的 key frame 间隔是 2 秒,那就可能保存下比指定时刻最多早将近 2 秒的帧。
flowchart TB
accTitle: seek 之后只读一次为什么会偏
accDescr: 表示 SetCurrentPosition 会落在指定位置稍靠前、尤其偏向 key frame 的位置,因此紧接着只读一次就保存,在 GOP 较长的视频中会保存下比指定时刻早很多的帧的图。
sp1["SetCurrentPosition(target)"] --> kf1["落在 key frame 一侧稍靠前的位置"]
kf1 --> one1["只调用一次 ReadSample"]
one1 --> ng2["保存下靠前的那一帧"]
ng2 -.-> gp1["GOP 越长偏差越大"]
图6:seek 会偏向 key frame 一侧,只读一次就可能保存下早了整整一个 GOP 的画面。
4.2. ReadSample 即使成功也可能出现 pSample == nullptr
ReadSample 即使返回 S_OK,ppSample 也可能是 NULL。到达末尾时会返回 MF_SOURCE_READERF_ENDOFSTREAM,出现流间隙时会返回 MF_SOURCE_READERF_STREAMTICK 等 flag。
只看 HRESULT 就立刻引用 pSample 是危险的。把 HRESULT、flags、pSample 作为三件套一起看,才算稳妥。
flowchart TB
accTitle: ReadSample 结果的三点确认
accDescr: 表示 ReadSample 即使返回 S_OK 也可能取不到 sample,因此需要把 HRESULT、flags、pSample 三者一起确认,并处理末尾和流间隙 flag 的图。
rs1["ReadSample 的结果"] --> h1["看 HRESULT"]
rs1 --> f1["看 flags"]
rs1 --> s1["看 pSample"]
f1 -.-> gap1["可能出现末尾或间隙的 flag"]
s1 -.-> nl1["即使 S_OK 也可能是 NULL"]
图7:不要只凭成功码就去引用,要把 HRESULT、flags、pSample 作为三件套一起确认。
4.3. 对 stride 和上下方向处理草率,图像就会错乱
图像缓冲区不一定按 width * bytesPerPixel 紧密排列。行末可能会有 padding,RGB 系也可能是 bottom-up 的。Microsoft Learn 的 Image Stride 与 Uncompressed Video Buffers 也把这一点写得相当明确。
特别重要的是下面两点。
IMF2DBuffer::Lock2D返回的是 scan line 0 的起始指针 与 实际的 stride- 对 bottom-up 图像,stride 可能是负值
本文借用 Microsoft Learn 中 helper 的思路,最终重新整理成 top-down 的连续 BGRA 缓冲区再交给 PNG。先把这一步理顺,保存端就会相当简单。
flowchart TB
accTitle: 吸收 stride 与上下方向
accDescr: 表示用 IMF2DBuffer 的 Lock2D 取得 scan line 0 的起始指针和实际的 stride,把包括 bottom-up 时 stride 为负在内的情况都重新整理成 top-down 的连续 BGRA 缓冲区,再交给 PNG 的流程图。
l2d["用 Lock2D 取得"] --> sl0["scan line 0 的起始指针"]
l2d --> ast1["实际的 stride"]
ast1 -.-> neg1["bottom-up 时可能为负"]
sl0 --> pack1["重新整理成 top-down 的连续 BGRA"]
ast1 --> pack1
pack1 --> sim1["保存端变简单"]
图8:用 Lock2D 吸收实际的 stride 与上下方向,整理成 top-down 的连续 BGRA 之后再交给保存。
4.4. 不要断定 MFVideoFormat_RGB32 的第 4 个字节就是 alpha
MFVideoFormat_RGB32 这个名字容易让人误解,它并不是可以直接交给 PNG 的“干净的 RGBA”。Windows 的 32bit RGB 中,字节 0、1、2 是 B、G、R,而字节 3 可能是 alpha,也可能是被忽略的值。关键在于它并不是 ARGB32。
如果把它当成 GUID_WICPixelFormat32bppBGRA 直接保存,第 4 个字节里可能是 0,图像就会莫名其妙地变透明。本文采取的方针是在保存前把 alpha 填成 0xFF,使其完全不透明。
flowchart TB
accTitle: RGB32 第 4 个字节的处理
accDescr: 表示 MFVideoFormat_RGB32 的字节 0 到 2 是 B、G、R,而第 4 个字节不一定是 alpha,直接写入 PNG 有时会变透明,因此保存前把 alpha 填成 0xFF 使其不透明的方针图。
rgb1["MFVideoFormat_RGB32 的帧"] --> b4["第 4 个字节不一定是 alpha"]
b4 -->|"原样保存"| tp2["有时会变成透明 PNG"]
b4 -->|"填成 0xFF 再保存"| op1["完全不透明的 PNG"]
图9:不要对第 4 个字节想当然,保存前用 0xFF 填满,把不透明固定下来。
5. 实现流程
5.1. 以同步模式创建 Source Reader
这次只要能取到一帧就够了,所以不用异步 callback,而用同步的 ReadSample。同步模式下 ReadSample 会阻塞到下一个 sample 到来,但对于单次静止图像提取来说,实现相当直接。
创建 Reader 时要做的就是下面 4 件事。
MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING = TRUE- 先把所有 stream 都关掉
- 只打开
MF_SOURCE_READER_FIRST_VIDEO_STREAM - 把输出 type 设为
MFMediaType_Video/MFVideoFormat_RGB32
这样一来,后半段就可以按“接收 RGB32 的帧”这个前提去写了。
flowchart TB
accTitle: 创建 Reader 时的 4 个步骤
accDescr: 表示启用视频处理、先关掉所有 stream 再只打开第一个视频 stream、把输出 type 设为 RGB32 这 4 个创建 Reader 步骤及其顺序的图。
c1["启用视频处理"] --> c2["关掉所有 stream"]
c2 --> c3["只打开第一个视频 stream"]
c3 --> c4["把输出 type 设为 RGB32"]
c4 -.-> rdy1["后半段可按 RGB32 前提编写"]
图10:把创建 Reader 的 4 个步骤依次做完,后半段就只剩下接收 RGB32 的帧了。
5.2. seek 之后一边看 timestamp 一边推进
SetCurrentPosition 之后不要马上保存。要一边用 ReadSample 读 sample,一边比较 target 之前的最后一帧与跨过 target 的第一帧。
多花这一道功夫,就能相当程度地吸收 seek 的粗糙。
sequenceDiagram
accTitle: seek 之后比较前后帧的交互
accDescr: 表示应用用 SetCurrentPosition 进行 seek 之后,反复调用 ReadSample 确认 timestamp,在跨过 target 的时点比较前后两个 sample 并选出更接近者的流程图。
participant A as 应用
participant R as Source Reader
A->>R: SetCurrentPosition(target)
loop 直到跨过 target
A->>R: ReadSample
R-->>A: sample 与 timestamp
end
A->>A: 比较前后两个 sample,采用更接近的那一个
图11:不要在 seek 之后立刻保存,先读到跨过 target 为止,再比较前后两帧。
5.3. 把 sample 整理成 top-down BGRA
取出的 sample 不直接写入 PNG,而是先重新整理成 top-down 的 BGRA 缓冲区。
- 用
ConvertToContiguousBuffer合并成一段 buffer - 用
BufferLockhelper 取得 scan line 0 与实际的 stride - 逐行拷贝到 top-down buffer 中
- 把 alpha 设为
0xFF
这样保存端就可以把它当作“普通的 32bpp BGRA 图像”来处理。
flowchart TB
accTitle: 从 sample 到 BGRA 的整理步骤
accDescr: 表示用 ConvertToContiguousBuffer 合并成一段缓冲区,用 BufferLock 取得 scan line 0 与实际的 stride,逐行拷贝到 top-down 缓冲区并把 alpha 设为 0xFF 的整理步骤图。
cv1["ConvertToContiguousBuffer"] --> bl1["用 BufferLock 取得 scan line 0 与 stride"]
bl1 --> rc1["逐行拷贝到 top-down"]
rc1 --> al1["把 alpha 设为 0xFF"]
al1 --> out1["就是普通的 32bpp BGRA 图像"]
图12:走完这 4 步整理,保存端就可以只当成普通的 32bpp BGRA 图像来处理。
5.4. PNG 保存交给 WIC
保存使用 WIC 的 IWICBitmapEncoder / IWICBitmapFrameEncode。分工是:Media Foundation 取帧,WIC 做成图像。这一段完全只靠 Windows 标准 API 就能完成。
flowchart LR
accTitle: Media Foundation 与 WIC 的分工
accDescr: 表示 Media Foundation 负责取得帧,WIC 负责做成图像并写出 PNG 的分工图。
mf1["Media Foundation"] -->|"取得帧"| fr1["BGRA 帧"]
fr1 -->|"WIC 做成图像"| pg1["PNG 文件"]
图13:取帧由 Media Foundation 负责,成图由 WIC 负责,只用标准 API 就能走完。
6. 实际工作中的检查清单
| 项目 | 要看什么 | 漏看之后容易发生什么 |
|---|---|---|
| seek 精度 | 不要只凭 SetCurrentPosition 之后的一次读取就定下来 |
保存下比指定时刻早很多的 frame |
| sample 为 NULL | HRESULT、flags、pSample 全都要看 |
在末尾或 stream tick 处发生 null 解引用 |
| stride | 吸收实际的 stride 与上下方向 | 图像错乱、上下颠倒 |
| RGB32 的第 4 个字节 | 把 alpha 设为 0xFF |
变成透明 PNG |
| 时刻范围 | 遵守 0 <= target < duration |
在末尾附近出现意料之外的行为 |
| 连续提取 | 不重新创建 Reader,反复 seek | 白白变慢 |
| 拷贝次数 | 大批量处理时要留意 ConvertToContiguousBuffer 的开销 |
多占用 CPU 与内存带宽 |
| 格式变化 | 中途分辨率会变的特殊视频要另行设计 | 宽高的前提被打破 |
其中“连续提取”和“拷贝次数”这两行,说的不是取一帧本身,而是当你把它扩展成从同一个视频里抽几十帧时才会显现出来的项目。本文的示例是取一帧就结束的结构,所以要抽多帧时,请改成不重新创建 Source Reader,而是反复执行 SetCurrentPosition 与 ReadSample。包含 Source Reader 之外的选项(如 Media Session)在内的 Media Foundation 全景图,可以看 Media Foundation 入门:用 COM 视角理解 API。
flowchart TB
accTitle: 从取一帧扩展到连续提取的方式
accDescr: 表示只取一帧时保持本文结构即可,而要从同一个视频抽多帧时应改成不重新创建 Source Reader、反复执行 seek 与 ReadSample 的扩展方式图。
q3["要抽几帧"] -->|"只有一帧"| as1["保持本文的结构"]
q3 -->|"多帧"| rp1["不重新创建 Reader,反复 seek 与读取"]
rp1 -.-> cost1["也要留意拷贝次数的开销"]
图14:扩展成抽多帧时,要改成不重新创建 Reader,而是反复 seek 与读取。
7. 构建与运行的备忘
本文最后的代码,做成了便于以单个 .cpp 文件添加到 Visual Studio 的 C++ 控制台应用中的形式。环境本身的前提整理在 2.2 中。
7.1. 构建备忘
列几个先掌握会更省事的点。
- 代码里已经加了
#pragma comment(lib, ...),基本不需要额外的链接器设置 - 由于使用
wmain,命令行参数可以保持 Unicode 形式处理 - 为了在默认 Console App 模板存在
pch.h或stdafx.h时也便于粘贴,代码开头用__has_include做了兼容处理 - 即便如此,如果项目那边仍强制使用自定义的预编译头,只要把这一个
.cpp设为“不使用预编译头”就能通过 - 运行配置建议用 x64
7.2. 运行方式,以及成功时会输出什么
用法是 ExtractFrameFromMp4.exe <input.mp4> <seconds> <output.png>。例如可以这样执行:ExtractFrameFromMp4.exe C:\work\input.mp4 12.345 C:\work\frame.png。
成功时,wmain 的最后会向标准输出打印下面 3 行。
Saved: C:\work\frame.png
Requested: 12.345 sec
Actual: (采用的那一帧的显示时刻) sec
Requested 就是传给参数的秒数本身,Actual 是实际采用的那一帧的 timestamp。由于按 3.2 的规则在前后两帧中取更接近的一个,两者之差会落在帧间隔的大约一半以内(29.97fps 时上限大致是 17ms 左右)。如果这里相差几百毫秒以上,就要怀疑是不是 seek 之后只读了一次,也就是 4.1 讲的前后比较没有生效。
flowchart TB
accTitle: 如何看 Requested 与 Actual 之差
accDescr: 表示 Requested 与 Actual 之差落在帧间隔一半以内即为正常,若相差几百毫秒以上则应怀疑 seek 之后的前后比较没有生效的诊断流程图。
dfc["看 Requested 与 Actual 之差"] -->|"在帧间隔的一半以内"| okd["前后比较生效了"]
dfc -->|"几百 ms 以上"| ngd["怀疑前后比较没有生效"]
ngd -.-> ck2["确认是不是 seek 之后只读了一次等情况"]
图15:如果差值远超帧间隔的一半,就要怀疑前后比较的实现。
动作确认的目标是这 3 点。
- 退出码为 0,并且
Saved:那一行显示的是指定的输出路径 - 打开输出的 PNG,能看到该时刻的画面,且方向正确(没有上下颠倒)
- PNG 的宽高与原视频的分辨率一致,背景没有透出来(4.4 的 alpha 填充生效了)
失败时会向标准错误输出打印 Failed. HRESULT = 0x........。这个值按 2.1 的要领去读。当指定的秒数大于等于视频长度时,值会是 0x80070057(E_INVALIDARG)。
flowchart TB
accTitle: 确认运行结果的流程
accDescr: 表示运行成功时确认 3 行输出以及 PNG 的方向、分辨率与不透明,失败时读取标准错误输出的 HRESULT,指定秒数大于等于视频长度时为 E_INVALIDARG 的确认流程图。
run1["执行"] -->|"成功"| ok3["确认 3 行输出与 PNG"]
run1 -->|"失败"| er2["读标准错误的 HRESULT"]
er2 -.-> iv1["秒数大于等于视频长度时为 E_INVALIDARG"]
图16:成功就确认输出与 PNG 的这 3 点,失败就从 HRESULT 的值读出原因。
8. 总结
用 Media Foundation 从 MP4 中取出指定时刻的静止图像时,只盯着 SetCurrentPosition 和 ReadSample 还差一点。实际上,
- seek 不是 exact 的
- frame 最好看着 timestamp 做前后比较
ReadSample即使成功也可能没有 sample- 先吸收
stride与图像方向再保存 - 不要断定
RGB32的第 4 个字节就是 alpha
把这些都掌握住,就会相当不容易出问题。
flowchart TB
accTitle: 要掌握的 5 点
accDescr: 表示 seek 不是 exact 的、按 timestamp 做前后比较、确认成功时也可能没有 sample、吸收 stride 与方向、不要断定 RGB32 第 4 个字节这 5 点掌握后就不容易出问题的图。
k1["seek 不是 exact 的"] --> k2["按 timestamp 做前后比较"]
k2 --> k3["确认成功时也可能没有 sample"]
k3 --> k4["吸收 stride 与方向"]
k4 --> k5["不要断定第 4 个字节"]
k5 --> safe2["不容易出问题的实现"]
图17:这 5 个要点齐了,指定时刻的静止图像提取就相当不容易出问题。
本文的示例,是聚焦在把一帧取干净这件事上的最小构成。用在生成缩略图、保存监控视频的代表 frame、输出检验日志的留证这类场景,可以直接照搬。
9. 参考资料
- 本文的示例代码一整套:media-foundation-extract-still-image-from-mp4-at-specific-time - komurasoft-blog-samples (GitHub)
- Microsoft Learn:Using the Source Reader to Process Media Data
- Microsoft Learn:
IMFSourceReader::SetCurrentPosition - Microsoft Learn:
IMFSourceReader::ReadSample - Microsoft Learn:
IMFSourceReader::SetCurrentMediaType - Microsoft Learn:
IMF2DBuffer - Microsoft Learn:
IMF2DBuffer::Lock2D - Microsoft Learn:Uncompressed Video Buffers
- Microsoft Learn:Image Stride
- Microsoft Learn:MF_MT_FRAME_SIZE attribute
- Microsoft Learn:MF_MT_DEFAULT_STRIDE attribute
- Microsoft Learn:Native pixel formats overview (WIC)
- Microsoft Learn:Uncompressed RGB Video Subtypes
10. 可直接粘贴到 .cpp 的完整代码
下面这一整块,就是可以直接拿到 Visual Studio 的 C++ 控制台应用项目里的代码。命令行参数依次是 input.mp4、seconds、output.png。做成了单文件完整的结构,便于粘贴到项目中。
因为是很长的一整块,先把哪个函数对应正文的哪一节放在这里。无论是阅读还是在跑不起来时排查原因,从这张对照表入手都更快。
| 代码中的函数 / 类 | 对应的正文 | 作用,以及所应对的陷阱 |
|---|---|---|
MediaFoundationScope |
5.1 | 把 CoInitializeEx 与 MFStartup 的初始化和收尾归拢到一起 |
CreateConfiguredSourceReader |
5.1 | 创建 Reader,只选择视频流,并请求 MFVideoFormat_RGB32 |
GetPresentationDuration |
6.(时刻范围) | 取得视频长度,确认是否满足 0 <= target < duration |
SeekSourceReader |
4.1 / 5.2 | 用 SetCurrentPosition 进行 seek。光靠这里还做不到 exact |
ReadNearestVideoSample |
3.2 / 4.1 / 4.2 / 5.2 | 比较 target 前后并选出一帧。flags 与 pSample 为 null 的情况也在这里处理 |
GetDefaultStride |
4.3 | 在没有 MF_MT_DEFAULT_STRIDE 时计算并补上 stride |
BufferLock |
4.3 / 5.3 | 用 IMF2DBuffer::Lock2D 取得 scan line 0 与实际的 stride |
CopyContiguousBufferToTopDownBgra |
4.3 / 4.4 / 5.3 | 逐行重新装入 top-down 缓冲区,并把第 4 个字节填成 0xFF |
CopySampleToTopDownBgra |
5.3 | 取出 frame size 与 stride,再调用上面的拷贝 |
SaveBgraToPng |
5.4 | 用 WIC 把 32bpp BGRA 写出成 PNG |
ExtractFrameFromMp4ToPng |
5. 整体 | 依次调用上面这些函数的入口 |
TryParseSeconds / wmain |
7.2 | 参数解析,以及 Requested / Actual 的显示 |
#define NOMINMAX
#if defined(_MSC_VER)
# if __has_include("pch.h")
# include "pch.h"
# elif __has_include("stdafx.h")
# include "stdafx.h"
# endif
#endif
#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mferror.h>
#include <mfobjects.h>
#include <propvarutil.h>
#include <wincodec.h>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cwchar>
#include <cmath>
#include <cstring>
#include <limits>
#include <vector>
#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfreadwrite.lib")
#pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "ole32.lib")
#pragma comment(lib, "propsys.lib")
#pragma comment(lib, "windowscodecs.lib")
template <class T>
void SafeRelease(T** pp)
{
if (pp != nullptr && *pp != nullptr)
{
(*pp)->Release();
*pp = nullptr;
}
}
class MediaFoundationScope
{
public:
MediaFoundationScope() : m_comInitialized(false), m_mfStarted(false)
{
}
HRESULT Initialize()
{
HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
if (hr == RPC_E_CHANGED_MODE)
{
return hr;
}
if (SUCCEEDED(hr))
{
m_comInitialized = true;
}
hr = MFStartup(MF_VERSION);
if (FAILED(hr))
{
if (m_comInitialized)
{
CoUninitialize();
m_comInitialized = false;
}
return hr;
}
m_mfStarted = true;
return S_OK;
}
~MediaFoundationScope()
{
if (m_mfStarted)
{
MFShutdown();
}
if (m_comInitialized)
{
CoUninitialize();
}
}
private:
bool m_comInitialized;
bool m_mfStarted;
};
HRESULT GetPresentationDuration(IMFSourceReader* pReader, LONGLONG* phnsDuration)
{
if (pReader == nullptr || phnsDuration == nullptr)
{
return E_POINTER;
}
PROPVARIANT var;
PropVariantInit(&var);
HRESULT hr = pReader->GetPresentationAttribute(
MF_SOURCE_READER_MEDIASOURCE,
MF_PD_DURATION,
&var);
if (SUCCEEDED(hr))
{
hr = PropVariantToInt64(var, phnsDuration);
}
PropVariantClear(&var);
return hr;
}
HRESULT GetDefaultStride(IMFMediaType* pType, LONG* plStride)
{
if (pType == nullptr || plStride == nullptr)
{
return E_POINTER;
}
LONG lStride = 0;
HRESULT hr = pType->GetUINT32(
MF_MT_DEFAULT_STRIDE,
reinterpret_cast<UINT32*>(&lStride));
if (FAILED(hr))
{
GUID subtype = GUID_NULL;
UINT32 width = 0;
UINT32 height = 0;
hr = pType->GetGUID(MF_MT_SUBTYPE, &subtype);
if (FAILED(hr))
{
return hr;
}
hr = MFGetAttributeSize(pType, MF_MT_FRAME_SIZE, &width, &height);
if (FAILED(hr))
{
return hr;
}
hr = MFGetStrideForBitmapInfoHeader(subtype.Data1, width, &lStride);
if (FAILED(hr))
{
return hr;
}
(void)pType->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(lStride));
}
*plStride = lStride;
return S_OK;
}
class BufferLock
{
public:
explicit BufferLock(IMFMediaBuffer* pBuffer)
: m_pBuffer(pBuffer),
m_p2DBuffer(nullptr),
m_locked(false)
{
if (m_pBuffer != nullptr)
{
m_pBuffer->AddRef();
(void)m_pBuffer->QueryInterface(IID_PPV_ARGS(&m_p2DBuffer));
}
}
~BufferLock()
{
UnlockBuffer();
SafeRelease(&m_p2DBuffer);
SafeRelease(&m_pBuffer);
}
HRESULT LockBuffer(
LONG defaultStride,
DWORD heightInPixels,
BYTE** ppScanLine0,
LONG* plStride)
{
if (ppScanLine0 == nullptr || plStride == nullptr)
{
return E_POINTER;
}
*ppScanLine0 = nullptr;
*plStride = 0;
HRESULT hr = S_OK;
if (m_p2DBuffer != nullptr)
{
hr = m_p2DBuffer->Lock2D(ppScanLine0, plStride);
}
else
{
BYTE* pData = nullptr;
hr = m_pBuffer->Lock(&pData, nullptr, nullptr);
if (SUCCEEDED(hr))
{
*plStride = defaultStride;
if (defaultStride < 0)
{
const size_t strideAbs = static_cast<size_t>(-defaultStride);
*ppScanLine0 = pData + strideAbs * (heightInPixels - 1);
}
else
{
*ppScanLine0 = pData;
}
}
}
m_locked = SUCCEEDED(hr);
return hr;
}
void UnlockBuffer()
{
if (!m_locked)
{
return;
}
if (m_p2DBuffer != nullptr)
{
(void)m_p2DBuffer->Unlock2D();
}
else if (m_pBuffer != nullptr)
{
(void)m_pBuffer->Unlock();
}
m_locked = false;
}
private:
IMFMediaBuffer* m_pBuffer;
IMF2DBuffer* m_p2DBuffer;
bool m_locked;
};
HRESULT CreateConfiguredSourceReader(PCWSTR inputPath, IMFSourceReader** ppReader)
{
if (inputPath == nullptr || ppReader == nullptr)
{
return E_POINTER;
}
*ppReader = nullptr;
IMFAttributes* pAttributes = nullptr;
IMFSourceReader* pReader = nullptr;
IMFMediaType* pRequestedType = nullptr;
HRESULT hr = MFCreateAttributes(&pAttributes, 1);
if (FAILED(hr))
{
goto done;
}
hr = pAttributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE);
if (FAILED(hr))
{
goto done;
}
hr = MFCreateSourceReaderFromURL(inputPath, pAttributes, &pReader);
if (FAILED(hr))
{
goto done;
}
hr = pReader->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
if (FAILED(hr))
{
goto done;
}
hr = pReader->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
if (FAILED(hr))
{
goto done;
}
hr = MFCreateMediaType(&pRequestedType);
if (FAILED(hr))
{
goto done;
}
hr = pRequestedType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
if (FAILED(hr))
{
goto done;
}
hr = pRequestedType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
if (FAILED(hr))
{
goto done;
}
hr = pReader->SetCurrentMediaType(
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
nullptr,
pRequestedType);
if (FAILED(hr))
{
goto done;
}
*ppReader = pReader;
pReader = nullptr;
done:
SafeRelease(&pRequestedType);
SafeRelease(&pReader);
SafeRelease(&pAttributes);
return hr;
}
HRESULT SeekSourceReader(IMFSourceReader* pReader, LONGLONG targetHns)
{
if (pReader == nullptr)
{
return E_POINTER;
}
PROPVARIANT var;
PropVariantInit(&var);
HRESULT hr = InitPropVariantFromInt64(targetHns, &var);
if (SUCCEEDED(hr))
{
hr = pReader->SetCurrentPosition(GUID_NULL, var);
}
PropVariantClear(&var);
return hr;
}
HRESULT ReadNearestVideoSample(
IMFSourceReader* pReader,
LONGLONG targetHns,
IMFSample** ppSample,
LONGLONG* pChosenTimestampHns)
{
if (pReader == nullptr || ppSample == nullptr)
{
return E_POINTER;
}
*ppSample = nullptr;
if (pChosenTimestampHns != nullptr)
{
*pChosenTimestampHns = 0;
}
IMFSample* pBefore = nullptr;
LONGLONG beforeTimestamp = 0;
bool hasBefore = false;
HRESULT hr = S_OK;
for (;;)
{
IMFSample* pCurrent = nullptr;
DWORD flags = 0;
LONGLONG currentTimestamp = 0;
LONGLONG diffBefore = 0;
LONGLONG diffCurrent = 0;
hr = pReader->ReadSample(
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
0,
nullptr,
&flags,
¤tTimestamp,
&pCurrent);
if (FAILED(hr))
{
SafeRelease(&pCurrent);
break;
}
if ((flags & MF_SOURCE_READERF_ENDOFSTREAM) != 0)
{
SafeRelease(&pCurrent);
if (hasBefore)
{
*ppSample = pBefore;
pBefore = nullptr;
if (pChosenTimestampHns != nullptr)
{
*pChosenTimestampHns = beforeTimestamp;
}
hr = S_OK;
}
else
{
hr = MF_E_END_OF_STREAM;
}
break;
}
if ((flags & MF_SOURCE_READERF_STREAMTICK) != 0)
{
SafeRelease(&pCurrent);
continue;
}
if (pCurrent == nullptr)
{
continue;
}
if (currentTimestamp < targetHns)
{
SafeRelease(&pBefore);
pBefore = pCurrent;
pCurrent = nullptr;
beforeTimestamp = currentTimestamp;
hasBefore = true;
continue;
}
if (hasBefore)
{
diffBefore = targetHns - beforeTimestamp;
diffCurrent = currentTimestamp - targetHns;
if (diffBefore <= diffCurrent)
{
*ppSample = pBefore;
pBefore = nullptr;
if (pChosenTimestampHns != nullptr)
{
*pChosenTimestampHns = beforeTimestamp;
}
SafeRelease(&pCurrent);
}
else
{
*ppSample = pCurrent;
pCurrent = nullptr;
if (pChosenTimestampHns != nullptr)
{
*pChosenTimestampHns = currentTimestamp;
}
}
}
else
{
*ppSample = pCurrent;
pCurrent = nullptr;
if (pChosenTimestampHns != nullptr)
{
*pChosenTimestampHns = currentTimestamp;
}
}
hr = S_OK;
break;
}
SafeRelease(&pBefore);
return hr;
}
HRESULT CopyContiguousBufferToTopDownBgra(
IMFMediaBuffer* pBuffer,
LONG defaultStride,
UINT32 width,
UINT32 height,
std::vector<BYTE>& pixels,
UINT32* pStride)
{
if (pBuffer == nullptr || pStride == nullptr)
{
return E_POINTER;
}
BufferLock lock(pBuffer);
BYTE* pScanLine0 = nullptr;
LONG actualStride = 0;
HRESULT hr = lock.LockBuffer(defaultStride, height, &pScanLine0, &actualStride);
if (FAILED(hr))
{
return hr;
}
if (width > (std::numeric_limits<UINT32>::max() / 4))
{
return E_INVALIDARG;
}
const UINT32 destStride = width * 4;
const LONG actualStrideAbs = (actualStride < 0) ? -actualStride : actualStride;
if (actualStrideAbs < static_cast<LONG>(destStride))
{
return E_UNEXPECTED;
}
pixels.resize(static_cast<size_t>(destStride) * height);
BYTE* pDestRow = pixels.data();
BYTE* pSrcRow = pScanLine0;
for (UINT32 y = 0; y < height; ++y)
{
std::memcpy(pDestRow, pSrcRow, destStride);
// MFVideoFormat_RGB32 的第 4 个字节不一定是 alpha,
// 因此在保存 PNG 前将其固定为不透明。
for (UINT32 x = 0; x < width; ++x)
{
pDestRow[static_cast<size_t>(x) * 4 + 3] = 0xFF;
}
pDestRow += destStride;
pSrcRow += actualStride;
}
*pStride = destStride;
return S_OK;
}
HRESULT CopySampleToTopDownBgra(
IMFSample* pSample,
IMFMediaType* pCurrentType,
std::vector<BYTE>& pixels,
UINT32* pWidth,
UINT32* pHeight,
UINT32* pStride)
{
if (pSample == nullptr || pCurrentType == nullptr ||
pWidth == nullptr || pHeight == nullptr || pStride == nullptr)
{
return E_POINTER;
}
*pWidth = 0;
*pHeight = 0;
*pStride = 0;
IMFMediaBuffer* pBuffer = nullptr;
GUID subtype = GUID_NULL;
UINT32 width = 0;
UINT32 height = 0;
LONG defaultStride = 0;
HRESULT hr = pCurrentType->GetGUID(MF_MT_SUBTYPE, &subtype);
if (FAILED(hr))
{
goto done;
}
if (!IsEqualGUID(subtype, MFVideoFormat_RGB32))
{
hr = MF_E_INVALIDMEDIATYPE;
goto done;
}
hr = MFGetAttributeSize(pCurrentType, MF_MT_FRAME_SIZE, &width, &height);
if (FAILED(hr))
{
goto done;
}
if (width == 0 || height == 0)
{
hr = E_UNEXPECTED;
goto done;
}
hr = GetDefaultStride(pCurrentType, &defaultStride);
if (FAILED(hr))
{
goto done;
}
hr = pSample->ConvertToContiguousBuffer(&pBuffer);
if (FAILED(hr))
{
goto done;
}
hr = CopyContiguousBufferToTopDownBgra(
pBuffer,
defaultStride,
width,
height,
pixels,
pStride);
if (FAILED(hr))
{
goto done;
}
*pWidth = width;
*pHeight = height;
hr = S_OK;
done:
SafeRelease(&pBuffer);
return hr;
}
HRESULT SaveBgraToPng(
PCWSTR outputPath,
const BYTE* pixels,
UINT32 width,
UINT32 height,
UINT32 stride)
{
if (outputPath == nullptr || pixels == nullptr)
{
return E_POINTER;
}
if (width == 0 || height == 0 || stride < width * 4)
{
return E_INVALIDARG;
}
const size_t bufferSizeSizeT = static_cast<size_t>(stride) * height;
if (bufferSizeSizeT > static_cast<size_t>(std::numeric_limits<UINT>::max()))
{
return E_INVALIDARG;
}
const UINT bufferSize = static_cast<UINT>(bufferSizeSizeT);
IWICImagingFactory* pFactory = nullptr;
IWICStream* pStream = nullptr;
IWICBitmapEncoder* pEncoder = nullptr;
IWICBitmapFrameEncode* pFrame = nullptr;
IPropertyBag2* pProps = nullptr;
WICPixelFormatGUID pixelFormat = GUID_WICPixelFormat32bppBGRA;
HRESULT hr = CoCreateInstance(
CLSID_WICImagingFactory,
nullptr,
CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(&pFactory));
if (FAILED(hr))
{
goto done;
}
hr = pFactory->CreateStream(&pStream);
if (FAILED(hr))
{
goto done;
}
hr = pStream->InitializeFromFilename(outputPath, GENERIC_WRITE);
if (FAILED(hr))
{
goto done;
}
hr = pFactory->CreateEncoder(GUID_ContainerFormatPng, nullptr, &pEncoder);
if (FAILED(hr))
{
goto done;
}
hr = pEncoder->Initialize(pStream, WICBitmapEncoderNoCache);
if (FAILED(hr))
{
goto done;
}
hr = pEncoder->CreateNewFrame(&pFrame, &pProps);
if (FAILED(hr))
{
goto done;
}
hr = pFrame->Initialize(pProps);
if (FAILED(hr))
{
goto done;
}
hr = pFrame->SetSize(width, height);
if (FAILED(hr))
{
goto done;
}
hr = pFrame->SetPixelFormat(&pixelFormat);
if (FAILED(hr))
{
goto done;
}
if (!IsEqualGUID(pixelFormat, GUID_WICPixelFormat32bppBGRA))
{
hr = WINCODEC_ERR_UNSUPPORTEDPIXELFORMAT;
goto done;
}
hr = pFrame->WritePixels(
height,
stride,
bufferSize,
const_cast<BYTE*>(pixels));
if (FAILED(hr))
{
goto done;
}
hr = pFrame->Commit();
if (FAILED(hr))
{
goto done;
}
hr = pEncoder->Commit();
done:
SafeRelease(&pProps);
SafeRelease(&pFrame);
SafeRelease(&pEncoder);
SafeRelease(&pStream);
SafeRelease(&pFactory);
return hr;
}
HRESULT ExtractFrameFromMp4ToPng(
PCWSTR inputPath,
LONGLONG targetHns,
PCWSTR outputPath,
LONGLONG* pActualTimestampHns)
{
if (inputPath == nullptr || outputPath == nullptr)
{
return E_POINTER;
}
if (targetHns < 0)
{
return E_INVALIDARG;
}
MediaFoundationScope mf;
HRESULT hr = mf.Initialize();
if (FAILED(hr))
{
return hr;
}
IMFSourceReader* pReader = nullptr;
IMFMediaType* pCurrentType = nullptr;
IMFSample* pChosenSample = nullptr;
LONGLONG durationHns = 0;
UINT32 width = 0;
UINT32 height = 0;
UINT32 stride = 0;
std::vector<BYTE> pixels;
hr = CreateConfiguredSourceReader(inputPath, &pReader);
if (FAILED(hr))
{
goto done;
}
hr = pReader->GetCurrentMediaType(
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
&pCurrentType);
if (FAILED(hr))
{
goto done;
}
hr = GetPresentationDuration(pReader, &durationHns);
if (FAILED(hr))
{
goto done;
}
if (targetHns >= durationHns)
{
hr = E_INVALIDARG;
goto done;
}
hr = SeekSourceReader(pReader, targetHns);
if (FAILED(hr))
{
goto done;
}
hr = ReadNearestVideoSample(
pReader,
targetHns,
&pChosenSample,
pActualTimestampHns);
if (FAILED(hr))
{
goto done;
}
hr = CopySampleToTopDownBgra(
pChosenSample,
pCurrentType,
pixels,
&width,
&height,
&stride);
if (FAILED(hr))
{
goto done;
}
hr = SaveBgraToPng(outputPath, pixels.data(), width, height, stride);
done:
SafeRelease(&pChosenSample);
SafeRelease(&pCurrentType);
SafeRelease(&pReader);
return hr;
}
bool TryParseSeconds(PCWSTR text, LONGLONG* phns)
{
if (text == nullptr || phns == nullptr)
{
return false;
}
wchar_t* end = nullptr;
errno = 0;
const double seconds = std::wcstod(text, &end);
if (end == text || *end != L'\0' || errno != 0)
{
return false;
}
if (!std::isfinite(seconds) || seconds < 0.0)
{
return false;
}
const long double hns =
static_cast<long double>(seconds) * 10000000.0L;
if (hns < 0.0L ||
hns > static_cast<long double>(std::numeric_limits<LONGLONG>::max()))
{
return false;
}
*phns = static_cast<LONGLONG>(std::llround(hns));
return true;
}
double HnsToSeconds(LONGLONG hns)
{
return static_cast<double>(hns) / 10000000.0;
}
void PrintUsage()
{
std::fwprintf(stderr, L"Usage:\n");
std::fwprintf(stderr, L" ExtractFrameFromMp4.exe <input.mp4> <seconds> <output.png>\n");
std::fwprintf(stderr, L"\nExample:\n");
std::fwprintf(stderr, L" ExtractFrameFromMp4.exe input.mp4 12.345 output.png\n");
}
int wmain(int argc, wchar_t* argv[])
{
if (argc != 4)
{
PrintUsage();
return 1;
}
LONGLONG targetHns = 0;
if (!TryParseSeconds(argv[2], &targetHns))
{
std::fwprintf(stderr, L"Invalid seconds: %ls\n", argv[2]);
return 1;
}
LONGLONG actualHns = 0;
HRESULT hr = ExtractFrameFromMp4ToPng(
argv[1],
targetHns,
argv[3],
&actualHns);
if (FAILED(hr))
{
std::fwprintf(stderr, L"Failed. HRESULT = 0x%08lX\n", static_cast<unsigned long>(hr));
return 1;
}
std::wprintf(L"Saved: %ls\n", argv[3]);
std::wprintf(L"Requested: %.3f sec\n", HnsToSeconds(targetHns));
std::wprintf(L"Actual: %.3f sec\n", HnsToSeconds(actualHns));
return 0;
}
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
用 Media Foundation 把图片和文字烧录进 MP4 的方法
本文整理用 Media Foundation 向 MP4 视频的每一帧烧录图片与文字、生成新 MP4 的思路,梳理 Source Reader、绘制、色彩转换、Sink Writer 各自的职责分工,并给出单文件即可运行的 C++ 示例。
在 Media Foundation 中把 YUV 转换为 RGB 的方法
从 Source Reader 自动转换与 NV12/YUY2 手动转换、stride、色彩空间几个角度,梳理在 Media Foundation 中把 YUV 帧转换为 RGB 的方法。
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、Source Reader 和 WIC 从视频中取出静止图像,与 Windows 应用开发的实现主题很接近。
技术咨询 & 设计评审
如果希望在动手实现之前先把 seek 精度、缓冲区格式、stride、图像上下方向等问题梳理清楚,可以按技术咨询与设计评审的方式,从方案梳理开始介入。
常见问题
汇总了咨询这一主题时常见的问题。
- 要从 MP4 中取出指定时刻的静止图像,应该用什么?
- 如果只取一帧,比起 Media Session,IMFSourceReader 作为入口更直接。流程是:用 MFCreateSourceReaderFromURL 打开文件,用 SetStreamSelection 只选择视频流,请求 MFVideoFormat_RGB32,用 SetCurrentPosition 进行 seek,用 ReadSample 获取帧,再用 WIC 保存为 PNG。全程不需要任何外部库,只用 Windows 标准 API 即可完成。
- 用 SetCurrentPosition 能精确移动到指定时刻吗?
- 不能。IMFSourceReader::SetCurrentPosition 并不保证 exact seeking,对视频而言通常会落在指定位置稍靠前的地方,尤其会偏向 key frame 一侧。需要在 seek 之后继续推进 ReadSample,一边看 timestamp,一边比较 target 之前的最后一个 sample 与 target 之后的第一个 sample,采用更接近目标的那一个。只在 seek 之后读取一次就保存的实现,在 GOP 较长的视频里很容易出现偏差。
- 保存出来的 PNG 为什么会是透明的?
- 因为 MFVideoFormat_RGB32 的第 4 个字节不一定就是 alpha。Windows 的 32bit RGB 中,字节 0、1、2 是 B、G、R,而字节 3 可能是 alpha,也可能是被忽略的值(它并不是 ARGB32)。直接这样写入 PNG 有时会得到透明图像,因此比较安全的做法是在保存前把第 4 个字节填成 0xFF,使其变为不透明。
- 图像错乱或者上下颠倒的原因是什么?
- 原因在于 stride 与上下方向的处理。图像缓冲区不一定按 宽度 × 每像素字节数 紧密排列,行末可能存在 padding,而且 RGB 系有时是 bottom-up(stride 为负值)。用 IMF2DBuffer::Lock2D 取得 scan line 0 的起始指针和实际的 stride,先重新整理成 top-down 的连续 BGRA 缓冲区再交给 PNG,保存端就会变简单,也能避免图像错乱。