用 Media Foundation 從 MP4 影片指定時間點抽出靜止畫面的方法 - 可直接貼進 .cpp 的單檔完整版

· 更新日期: · · Media Foundation, C++, Windows 開發, WIC

「想從 MP4 抽出第 12.3 秒那一張」的需求其實相當常見。像是做縮圖、檢驗紀錄、監視影像的代表畫面、裝置紀錄的留證等等。

只不過 Media Foundation 在這件事上並沒有那麼直覺。看起來好像 SetCurrentPosition 之後呼叫一次 ReadSample 就完事了,但實際上會牽扯 key frame、timestamp、stride、影像上下方向、RGB32 的第 4 byte 等細節。做得草率的話,就會發生時間點偏掉一點、畫面上下顛倒、PNG 莫名變透明等,這種低調但讓人煩躁的小狀況。

Media Foundation 的整體觀念,之前寫過的 Media Foundation 是什麼 - 為什麼看得到 COM 與 Windows 媒體 API 的影子 可以一起參考。本文則再往下一層,只聚焦在 從 MP4 抓一張畫面

本文會用 IMFSourceReader,把「從 MP4 抓出最靠近指定時間點的靜止畫面一張,並存成 PNG」這件事,連同實務上常踩的坑一起整理。最後還附了一份 可直接貼進 Visual Studio C++ 主控台應用程式專案 .cpp 的單檔完整程式碼,過程中不會有切開貼散的程式碼,只要把最後那一整塊拿走就能跑。

1. 先說結論

先把結論濃縮一下:

  • 從 MP4 取一張圖的話,這次用 Source Reader 當入口比 Media Session 自然
  • IMFSourceReader::SetCurrentPosition 並不保證 exact seek。通常會落在 target 之前,尤其偏向 key frame,所以後面得用 ReadSample 一邊往前走一邊比對目標時間的前後
  • ReadSample 就算成功,pSample == nullptr 也是有可能的。不能只看 HRESULT,還要看 flagspSample
  • 把輸出 media type 設成 MFVideoFormat_RGB32 會比較好存
  • 不過 RGB32 的第 4 byte 不一定是 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 為前提

如果要做到播放、音訊同步、seek bar、UI 連動,那是另一個設計題目;但只要「抓一幀」這種用途,這樣做就很清楚。

3. 先看整理表

3.1. 處理流程

要做的事 使用的 API 角色
開啟 MP4 MFCreateSourceReaderFromURL 從檔案建立 media source
只選影像 SetStreamSelection 避免讀音訊
轉成 RGB32 SetCurrentMediaType + MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING 拿到方便存檔的 uncompressed frame
移到指定時間 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 byte 的處理,各自都有小陷阱。只要這些不踩,用起來其實滿自然的。

4. 先抓住的陷阱

4.1. SetCurrentPosition 不是 exact 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,遇到 stream 空白就會回 MF_SOURCE_READERF_STREAMTICK 等 flag。

所以只看 HRESULT 就直接碰 pSample 是危險的。要把 HRESULTflagspSample 三件事一起看 才安全。

4.3. 隨便處理 stride 和上下方向,畫面就會壞

影像 buffer 並不一定是 width * bytesPerPixel 一直線排著的。行尾可能有 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 buffer 再丟給 PNG。先把這裡整理好,存檔那段就會變得相當單純。

4.4. 不要把 MFVideoFormat_RGB32 的第 4 byte 當成 alpha

MFVideoFormat_RGB32 聽起來一副「能直接丟給 PNG 的漂亮 RGBA」,但其實不是。Windows 的 32bit RGB 是 byte 0、1、2 為 B、G、R,而 byte 3 可能是 alpha,也可能只是 ignore。這不是 ARGB32,這一點很重要。

如果在這裡把它當成 GUID_WICPixelFormat32bppBGRA 直接存,第 4 byte 可能還是 0,結果畫面就變得很透明。本文採用 存檔前把 alpha 填 0xFF 讓整張完全不透明 的做法。

5. 實作流程

5.1. 用同步模式建立 Source Reader

這次只需要一張畫面,所以不用非同步 callback,改用同步 ReadSample。同步模式下 ReadSample 會 block 到下一個 sample,但對單次擷取靜止畫面來說,實作非常直覺。

建立 Reader 時要做的事:

  • MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING = TRUE
  • 先關掉所有 stream
  • 只開 MF_SOURCE_READER_FIRST_VIDEO_STREAM
  • 把輸出 type 設成 MFMediaType_Video / MFVideoFormat_RGB32

這樣後面的處理就可以以「接 RGB32 畫面」為前提來寫。

5.2. seek 之後看 timestamp 往下跑

SetCurrentPosition 之後不要馬上存檔。邊用 ReadSample 讀,邊把 target 之前的最後一張和跨過 target 的第一張做比較。

這個多走一步,就能把 seek 的粗糙吸收掉大部分。

5.3. 把 sample 整成 top-down BGRA

抽出來的 sample 不要直接丟 PNG,先整成 top-down 的 BGRA buffer:

  • 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 後的 1 次 存出比指定時間早很多的 frame
sample 為 NULL HRESULTflagspSample 都看 在尾端或 stream tick 時 null dereference
stride 吸收 actual stride 和上下方向 畫面壞掉、上下顛倒
RGB32 第 4 byte 把 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 不是 exact
  • frame 要看 timestamp 做前後比較
  • ReadSample 成功也可能沒 sample
  • 存檔前要吸收 stride 與影像方向
  • 不要把 RGB32 的第 4 byte 當成 alpha

把這些抓住,出狀況的機率會大大降低。

這份範例以「好好抽出一張」為目標、做得相當精簡,用起來應該很順手。縮圖產生、監視影像代表 frame 保存、檢驗紀錄留證類用途,可以很自然地直接搬進去。

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 byte 不一定是 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 抽出指定時間的靜止畫面?
以 IMFSourceReader 為入口,流程是用 MFCreateSourceReaderFromURL 開啟 MP4、只選影像串流、把輸出 media type 設成 MFVideoFormat_RGB32、以 SetCurrentPosition 移到指定時間,再反覆 ReadSample 比對目標時間的前後,最後把畫格整成 top-down BGRA 交給 WIC 存成 PNG。只做「抓一幀」的話用同步模式的 Source Reader 比 Media Session 自然,且全程只需 Windows 標準 API。
為什麼 SetCurrentPosition 之後只 ReadSample 一次會抓錯畫面?
因為 SetCurrentPosition 不保證 exact seek,影片通常會落在指定位置稍前,特別是偏向 key frame,之後預期你用 ReadSample 往前走到目標位置。在 GOP 長的影片裡「seek 後讀一次就存」很容易偏掉。應該邊讀邊保留 target 之前的最後一個 sample,遇到跨過 target 的第一個 sample 時比較兩者與 target 的差,選比較近的那張。
ReadSample 回傳成功時可以直接使用 sample 嗎?
不行。ReadSample 即使回 S_OK,ppSample 也可能是 NULL。到達尾端會回 MF_SOURCE_READERF_ENDOFSTREAM,遇到 stream 空白會回 MF_SOURCE_READERF_STREAMTICK 等 flag。只看 HRESULT 就直接碰 pSample 是危險的,要把 HRESULT、flags、pSample 三件事一起確認才安全。
存出來的 PNG 為什麼會變透明或上下顛倒?
透明是因為 MFVideoFormat_RGB32 的第 4 byte 不一定是 alpha,可能只是被忽略的值,直接當 32bppBGRA 存檔時 alpha 可能是 0,所以存檔前要把 alpha 填成 0xFF 讓整張不透明。上下顛倒或畫面壞掉則是 stride 處理不當:影像每列可能有 padding,RGB 系也可能是 bottom-up(stride 為負),應以 IMF2DBuffer::Lock2D 取得實際 stride,逐列複製成 top-down 的連續 BGRA 再存檔。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽