用 Media Foundation 从 MP4 视频中提取指定时间点静止图像的方法

· · Media Foundation, C++, Windows 开发, WIC

从 MP4 中取出”第 12.3 秒那一帧”这样的需求相当常见。比如生成缩略图、检验日志、监控视频的代表帧、设备日志的留证等场景都会用到。

不过,在 Media Foundation 中,这件事并没有想象中那么直接。看起来只要在 SetCurrentPosition 之后调用一次 ReadSample 就结束了,但实际上会牵涉到 key frame、timestamp、stride、图像的上下方向,以及 RGB32 的第 4 个字节等问题。处理得草率的话,就会出现时间点稍有偏差、图像上下颠倒、PNG 莫名其妙变成透明这类让人不太舒服的小问题。

关于 Media Foundation 的整体概念,之前写过的 Media Foundation 是什么 - 为什么能看到 COM 与 Windows 媒体 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 ReaderMedia Session 作为入口更直接
  • IMFSourceReader::SetCurrentPosition 并不保证 exact seek。通常会落在 target 稍靠前、尤其偏向 key frame 的位置,因此之后需要推进 ReadSample,比较目标时间前后的帧
  • ReadSample 即便成功,也可能出现 pSample == nullptr 的情况。不能只看 HRESULT,还要同时确认 flagspSample
  • 把输出媒体类型设为 MFVideoFormat_RGB32 会更方便保存
  • 但是 RGB32 的第 4 个字节不一定是 alpha,直接写入 PNG 有时会得到透明图像。比较安全的做法是在保存前填入 0xFF,使其变为不透明
  • 如果对逐行的 stride 以及 top-down / bottom-up 处理得草率,图像会崩坏,因此比较省事的做法是把取出的 sample 先整理成top-down 的连续 BGRA,再交给 PNG

总而言之,seek -> 读取一次 -> 保存 这样的流程稍显粗糙,如果能做到 seek -> 边看 timestamp 边比较前后 -> 注意 stride 进行拷贝 -> 保存 PNG 这种程度,就会相当稳定。

2. 本文的前提

这次的前提如下。

  • 输入是本地的 MP4 文件
  • 想要的是一张静止图像
  • 目标不是”恰好等于指定时间”,而是”返回最接近指定时间的帧”
  • 实现采用同步模式的 IMFSourceReader
  • 保存格式使用基于 WIC 的 PNG
  • 不使用外部库,只用 Windows 标准 API 完成
  • 前提是分辨率在过程中不会发生变化的一般 MP4 文件

如果要实现播放、音频同步、进度条、UI 联动等功能,还有其他设计方式,但如果只是想取出一帧,这种做法会相当清晰易懂。

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 相对 target 的差值
  • 采用更接近 target 的那一个

这样一来,取到的就不是”target 之后的第一帧”,而更容易做到最接近 target 的一帧

3.3. 处理流程概览

从头到尾大致是这样的流程:input.mp4 -> 创建 Source Reader -> 请求 RGB32 -> seek -> 反复调用 ReadSample -> 比较 target 前后的帧 -> 重新整理为 top-down BGRA -> 用 WIC 保存 PNG

看起来很单纯,但在 seek 的精度、sample 的 null、stride、第 4 个字节的处理上,各自都藏着小陷阱。只要不踩到这些坑,用起来会相当顺畅。

4. 先掌握这些陷阱

4.1. SetCurrentPosition 并不是精确的 seek

正如 Microsoft Learn 的 IMFSourceReader::SetCurrentPosition 文档中所写,它并不保证 exact seeking。对于视频,通常会落在指定位置稍靠前、尤其偏向 key frame 的位置。而且,前提是之后还要推进 ReadSample 前进到目标位置。

正因为如此,下面这种实现相当危险。

  • SetCurrentPosition(target)
  • 只调用一次 ReadSample
  • 保存那一帧

在 GOP 较长的视频中,这样做会出现相当明显的偏差。这是一个不起眼却容易踩坑的地方。

4.2. ReadSample 即便成功,也可能出现 pSample == nullptr

即便 ReadSample 返回 S_OK,ppSample 也可能是 NULL。到达末尾时会返回 MF_SOURCE_READERF_ENDOFSTREAM,出现流间隙时会返回 MF_SOURCE_READERF_STREAMTICK 等标志。

只看 HRESULT 就直接引用 pSample 是很危险的。比较安全的做法是HRESULTflagspSample 这三者放在一起确认

4.3. 对 stride 和上下方向处理得草率,图像会崩坏

图像缓冲区并不一定是按 宽度 × 每像素字节数 紧密排列的。行末有时会存在 padding,RGB 系有时也会是 bottom-up 的。Microsoft Learn 的 Image StrideUncompressed Video Buffers 中,对这一点也写得相当明确。

尤其重要的是以下两点。

  • IMF2DBuffer::Lock2D 返回的是 scan line 0 的起始指针实际的 stride
  • 对于 bottom-up 图像,stride 可能为负值

本文借鉴 Microsoft Learn 中 helper 的思路,最终整理成 top-down 的连续 BGRA 缓冲区后再交给 PNG。提前做好这一步,保存端会变得相当简单。

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,使其完全不透明

5. 实现流程

5.1. 以同步模式创建 Source Reader

这次只需要取一帧就够了,因此不使用异步回调,而是采用同步的 ReadSample。在同步模式下,ReadSample 会阻塞直到取得下一个 sample,但对于单次静止图像提取来说,实现会相当直接。

创建 Reader 时要做的事情有以下 4 项。

  • MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING = TRUE
  • 先把所有 stream 都关闭
  • 只打开 MF_SOURCE_READER_FIRST_VIDEO_STREAM
  • 把输出类型设置为 MFMediaType_Video / MFVideoFormat_RGB32

这样一来,后续部分就可以在”接收到的是 RGB32 帧”这一前提下编写代码,会更容易。

5.2. seek 之后边看 timestamp 边整理

SetCurrentPosition 之后,不会立即保存。而是通过 ReadSample 逐个读取 sample,比较 target 之前的最后一帧与跨过 target 的第一帧。

多做这一步,就能相当程度地吸收 seek 的粗糙误差。

5.3. 把 sample 整理成 top-down BGRA

取出的 sample 不会直接写入 PNG,而是先重新整理成 top-down 的 BGRA 缓冲区。

  • ConvertToContiguousBuffer 整合成一段连续 buffer
  • BufferLock helper 获取 scan line 0 与实际 stride
  • 逐行拷贝到 top-down buffer 中
  • 把 alpha 设为 0xFF

这样一来,保存端就可以把它当作”普通的 32bpp BGRA 图像”来处理。

5.4. PNG 保存交给 WIC 处理

保存使用 WIC 的 IWICBitmapEncoder / IWICBitmapFrameEncode。分工是:Media Foundation 负责取帧,WIC 负责把它图像化。这一步完全依靠 Windows 标准 API 即可完成。

6. 实际工作中的检查清单

项目 要检查的内容 忽略后容易发生的问题
seek 精度 不要只靠 SetCurrentPosition 之后的一次读取来决定 保存到比指定时间早得多的帧
sample 的 NULL HRESULTflagspSample 全部确认一遍 在末尾或 stream tick 处发生 null 引用
stride 吸收实际 stride 与上下方向的差异 图像崩坏、上下颠倒
RGB32 的第 4 个字节 把 alpha 设为 0xFF 变成透明 PNG
时间范围 保证 0 <= target < duration 在末尾附近出现意料之外的行为
连续提取 不重新创建 Reader,反复 seek 无谓地变慢
拷贝次数 大批量处理时要注意 ConvertToContiguousBuffer 的开销 多占用 CPU 与内存带宽
格式变化 对于中途分辨率会变化的特殊视频,另行设计 宽高的前提假设被打破

7. 构建与运行说明

本文最后的代码,做成了便于以单个 .cpp 文件添加到 Visual Studio C++ 控制台应用程序中的形式

列出几个可以提前留意的要点。

  • 由于已经加入了 #pragma comment(lib, ...),基本不需要额外的链接器设置
  • 由于使用了 wmain,命令行参数可以保持 Unicode 形式处理
  • 为了在默认的 Console App 模板中存在 pch.hstdafx.h 时也方便粘贴,代码开头使用了 __has_include 来做兼容处理
  • 即便如此,如果项目仍强制使用自己的预编译头,只需把这一个 .cpp 文件设置为”不使用预编译头”即可解决
  • 建议使用 x64 运行配置

使用方式是 ExtractFrameFromMp4.exe <input.mp4> <seconds> <output.png>。例如可以像这样执行:ExtractFrameFromMp4.exe C:\work\input.mp4 12.345 C:\work\frame.png

8. 总结

在用 Media Foundation 从 MP4 中提取指定时间点的静止图像时,只关注 SetCurrentPositionReadSample 会有些不够。实际上还需要注意:

  • seek 并不是精确的
  • 帧最好通过 timestamp 进行前后比较
  • ReadSample 即便成功,也可能没有 sample
  • 保存前要先吸收 stride 与图像方向的差异
  • 不要断定 RGB32 的第 4 个字节就是 alpha

把这些要点掌握住,就能相当程度地降低出问题的概率。

这次的示例,作为专注于准确取出一帧的最小化实现,应该相当好用。对于生成缩略图、保存监控视频的代表帧、输出检验日志的留证等场景,都可以直接拿来使用这套结构。

9. 参考资料

10. 可直接粘贴到 .cpp 中的完整代码

以下这一整块代码,是可以直接拿到 Visual Studio C++ 控制台应用程序项目中使用的代码。命令行参数依次是 input.mp4secondsoutput.png。由于做成了单文件完整的结构,因此便于粘贴到项目中。

#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,
            &currentTimestamp,
            &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;
}

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

要从 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,保存端就会变得简单,也能避免图像崩坏。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表