Как извлечь статичное изображение из MP4 по заданному времени с помощью Media Foundation

· · Media Foundation, C++, Разработка Windows, WIC

Требование «взять из MP4 один кадр в районе 12,3 секунды» встречается довольно часто: генерация миниатюр, лог осмотра, представительный кадр из видеонаблюдения, доказательный снимок для журнала оборудования и так далее.

Но в Media Foundation это оказывается чуть менее прямолинейно, чем кажется. Выглядит так, будто достаточно один раз вызвать ReadSample после SetCurrentPosition — и дело сделано, но на практике в игру вступают key frame, timestamp, stride, ориентация изображения и четвёртый байт RGB32. Если пройти по этому пути небрежно, случаются неприметные, но неприятные аварии: время немного съезжает, изображение переворачивается вверх ногами, а PNG становится странно прозрачным.

Общую картину Media Foundation можно посмотреть в нашей более ранней статье «Что такое Media Foundation — почему в ней проступает лицо COM и Windows-медиа-API». В этот раз мы спускаемся на один уровень ниже и сосредотачиваемся только на том, как извлечь из MP4 один кадр.

В этой статье мы разбираем, как с помощью IMFSourceReader извлечь из MP4 статичное изображение, ближайшее к заданному времени, и сохранить его как PNG, вместе с подводными камнями, на которые легко наткнуться на практике. А в конце статьи приведён единый самодостаточный листинг кода, который можно сразу вставить в .cpp консольного C++-проекта Visual Studio. Никаких разрозненных фрагментов по ходу статьи — достаточно взять один последний блок кода, и всё заработает.

Кроме того, весь код из этой статьи опубликован на GitHub в виде полного набора образцов (самодостаточное консольное C++-приложение из одного файла).

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
  • Если привести выходной media type к MFVideoFormat_RGB32, сохранение упрощается
  • Однако четвёртый байт RGB32 не обязательно является alpha, поэтому запись как есть в PNG иногда даёт прозрачное изображение. Безопаснее перед сохранением записать в него 0xFF, сделав изображение непрозрачным
  • Небрежное обращение с построчным stride и ориентацией top-down / bottom-up ломает изображение, поэтому проще один раз привести извлечённый sample к непрерывному top-down BGRA и только потом передавать его в PNG

Иначе говоря, схема seek -> прочитать один раз -> сохранить слишком грубая; если довести её до seek -> сравнение по timestamp вокруг target -> копирование с учётом stride -> сохранение в PNG, результат становится вполне стабильным.

2. Предпосылки этой статьи

Вот исходные условия для этой статьи:

  • Входные данные — локальный файл MP4
  • Нужен один статичный кадр
  • Возвращаем не «точно указанное время», а «кадр, ближайший к указанному времени»
  • Реализация — IMFSourceReader в синхронном режиме
  • Формат сохранения — PNG через WIC
  • Без сторонних библиотек — только стандартные API Windows
  • Предполагается обычный MP4, разрешение которого не меняется по ходу видео

Если нужны воспроизведение, синхронизация со звуком, полоса перемотки и интеграция с UI, тут уместна другая архитектура, но для задачи «получить один кадр» этот подход достаточно понятен.

3. Сводные таблицы для начала

3.1. Общий ход обработки

Что делаем Используемый API Роль
Открыть MP4 MFCreateSourceReaderFromURL Создать медиа-источник из файла
Выбрать только видео SetStreamSelection Не читать звуковую дорожку
Преобразовать в RGB32 SetCurrentMediaType + MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING Получить несжатый (uncompressed) кадр, удобный для сохранения
Перейти к заданному времени SetCurrentPosition Выполнить seek с точностью 100 нс
Прочитать кадр ReadSample Получать декодированные sample по одному
Сравнить кадры до / после sample timestamp Определить кадр, ближайший к заданному времени
Сохранить в PNG WIC Записать файл изображения

3.2. Правило выбора кадра в этой статье

Хотя мы говорим «статичное изображение в заданный момент времени», видео состоит из дискретных кадров, а не из непрерывной величины, поэтому в реализации проще заранее решить, по какому правилу выбирать один кадр.

В этой статье используется следующее правило:

  • После seek продвигаемся вызовами ReadSample
  • Сохраняем последний sample, у которого timestamp < target
  • Когда приходит первый sample с timestamp >= target, сравниваем разницу между предыдущим и текущим sample
  • Выбираем тот, что ближе к target

Так проще получить не «первый кадр после target», а кадр, максимально близкий к target.

3.3. Общая картина обработки

От начала до конца обработка выглядит примерно так: input.mp4 -> создать Source Reader -> запросить RGB32 -> выполнить seek -> повторять ReadSample -> сравнить кадры вокруг target -> перепаковать в top-down BGRA -> сохранить в PNG через WIC.

На вид всё просто, но в точности seek, в null-значении sample, в stride и в обращении с четвёртым байтом скрыты свои маленькие ловушки. Если не наступить на них, использовать этот подход довольно легко.

4. Подводные камни, которые стоит знать заранее

4.1. SetCurrentPosition — это не точный (exact) seek

Как указано и в документации Microsoft Learn для IMFSourceReader::SetCurrentPosition, этот метод не гарантирует точный (exact) seek. Для видео позиция обычно оказывается немного раньше указанной, со смещением в сторону key frame. Причём предполагается, что дальше вы продвигаетесь до нужной позиции вызовами ReadSample.

Поэтому вот такая реализация довольно ненадёжна:

  • SetCurrentPosition(target)
  • Один вызов ReadSample
  • Сохранение этого frame

На видео с длинным GOP это обычно и совершенно закономерно даёт расхождение по времени. Это и есть та неприметная трясина.

4.2. ReadSample может завершиться успешно, но при этом pSample == nullptr

ReadSample может вернуть S_OK, притом что ppSample равен NULL. В конце потока возвращается флаг MF_SOURCE_READERF_ENDOFSTREAM, при разрыве потока — MF_SOURCE_READERF_STREAMTICK и так далее.

Проверять только HRESULT и сразу же разыменовывать pSample опасно. Безопаснее всегда смотреть на все три значения сразу: 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 может быть отрицательным

В этой статье используется подход helper-функций из Microsoft Learn: данные в итоге перепаковываются в непрерывный top-down буфер BGRA, и только потом передаются в PNG. Если навести здесь порядок заранее, сторона сохранения становится намного проще.

4.4. Не считайте, что четвёртый байт MFVideoFormat_RGB32 всегда является alpha

Вопреки тому, что подсказывает название, MFVideoFormat_RGB32 — это не «чистый RGBA», который можно напрямую передать в PNG. В 32-битном RGB Windows байты 0, 1, 2 — это B, G, R, а байт 3 может быть alpha, а может игнорироваться. Важно, что это не ARGB32.

Если считать этот формат GUID_WICPixelFormat32bppBGRA и сохранять как есть, в четвёртом байте иногда оказывается 0, из-за чего изображение получается странно прозрачным. В этой статье используется подход: перед сохранением заполнить alpha значением 0xFF, сделав изображение полностью непрозрачным.

5. Ход реализации

5.1. Создание Source Reader в синхронном режиме

Поскольку нам нужен всего один кадр, используется синхронный ReadSample, а не асинхронные callback. В синхронном режиме ReadSample блокируется до появления следующего sample, но для разового извлечения статичного изображения реализация остаётся довольно простой.

При создании reader выполняются четыре действия:

  • MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING = TRUE
  • Сначала выключаем все потоки
  • Включаем только MF_SOURCE_READER_FIRST_VIDEO_STREAM
  • Задаём выходной type как MFMediaType_Video / MFVideoFormat_RGB32

После этого дальнейший код проще писать в предположении «мы получаем кадры в RGB32».

5.2. После seek — сближение с целью по timestamp

После SetCurrentPosition мы не сохраняем кадр сразу. Читая sample через ReadSample, мы сравниваем последний кадр перед target с первым кадром, который его пересёк.

Этот дополнительный шаг заметно сглаживает грубость seek.

5.3. Приведение sample к top-down BGRA

Извлечённый sample не записывается в PNG напрямую — сначала он перепаковывается в буфер top-down BGRA.

  • Объединяем в один buffer через ConvertToContiguousBuffer
  • Получаем scan line 0 и actual stride через helper BufferLock
  • Копируем построчно в top-down buffer
  • Устанавливаем alpha в 0xFF

После этого сторона сохранения может обращаться с данными просто как с «обычным 32-битным изображением BGRA».

5.4. Сохранение в PNG доверяем WIC

Для сохранения используются IWICBitmapEncoder / IWICBitmapFrameEncode из WIC. Разделение ролей такое: Media Foundation получает кадр, а WIC превращает его в файл изображения. Всё это укладывается в стандартные API Windows.

6. Практический чек-лист

Пункт Что проверять Что обычно происходит, если это упустить
Точность seek Не принимать решение по одному чтению сразу после SetCurrentPosition Сохраняется frame значительно раньше заданного времени
NULL у sample Проверять HRESULT, flags и pSample — все сразу Разыменование null в конце потока или на stream tick
stride Учитывать actual stride и вертикальную ориентацию Изображение искажается или переворачивается вверх ногами
4-й байт RGB32 Устанавливать alpha в 0xFF Получается прозрачный PNG
Диапазон времени Соблюдать 0 <= target < duration Непредвиденное поведение рядом с концом потока
Многократное извлечение Повторять seek, не пересоздавая reader Неоправданно медленно
Количество копирований При массовой обработке учитывать стоимость ConvertToContiguousBuffer Лишний расход CPU и пропускной способности памяти
Изменение формата Для нестандартных видео, где разрешение меняется по ходу, использовать отдельную архитектуру Ломаются предположения о ширине и высоте

7. Заметки о сборке и запуске

Код в конце статьи оформлен так, чтобы его было легко добавить как единый .cpp в консольное C++-приложение Visual Studio.

Вот несколько моментов, которые полезно иметь в виду:

  • В код добавлены директивы #pragma comment(lib, ...), поэтому дополнительная настройка компоновщика в целом не требуется
  • Используется wmain, поэтому аргументы командной строки обрабатываются как есть, в Unicode
  • Чтобы код было легко вставить даже в стандартный шаблон Console App с pch.h или stdafx.h, в начале файла используется __has_include, который подхватывает нужный заголовок
  • Если проект всё же принудительно требует собственный precompiled header, достаточно для этого одного .cpp включить опцию «не использовать precompiled header» — и всё соберётся
  • Рекомендуемая конфигурация запуска — x64

Использование: ExtractFrameFromMp4.exe <input.mp4> <seconds> <output.png>. Например, запуск выглядит так: ExtractFrameFromMp4.exe C:\work\input.mp4 12.345 C:\work\frame.png.

8. Итог

При извлечении статичного изображения из MP4 по заданному времени в Media Foundation одного взгляда на SetCurrentPosition и ReadSample немного не хватает. На практике нужно учитывать следующее:

  • seek не является точным
  • кадры лучше сравнивать по timestamp вокруг нужного момента
  • успешный ReadSample не гарантирует наличие sample
  • перед сохранением нужно учесть stride и ориентацию изображения
  • не считать, что четвёртый байт RGB32 — это всегда alpha

Если учесть всё это, вероятность неприятных сюрпризов заметно снижается.

Пример из этой статьи — минимальная конструкция, сосредоточенная на том, чтобы корректно извлечь один кадр, и должна быть вполне удобной в использовании. Такую архитектуру легко напрямую применить для генерации миниатюр, сохранения представительного кадра из видеонаблюдения или вывода доказательных снимков для журнала осмотра.

9. Источники

10. Полный код, готовый для вставки в .cpp

Блок кода ниже рассчитан на то, чтобы перенести его прямо в проект консольного C++-приложения Visual Studio. Аргументы командной строки идут в порядке input.mp4, seconds, output.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 не обязательно является 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;
}

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Технические консультации и ревью дизайна

Если нужно до начала реализации разобраться с точностью seek, форматом буфера, stride и ориентацией изображения, можно начать с определения направления в формате технической консультации и ревью архитектуры.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Что использовать, чтобы извлечь из MP4 статичное изображение в заданный момент времени?
Если нужен всего один кадр, `IMFSourceReader` — более прямой вход, чем `Media Session`. Порядок действий такой: открыть файл через `MFCreateSourceReaderFromURL`, выбрать только видеопоток через `SetStreamSelection`, запросить формат `MFVideoFormat_RGB32`, выполнить seek через `SetCurrentPosition`, получить кадр через `ReadSample` и сохранить его в PNG с помощью WIC. Всё это укладывается в стандартные API Windows, без сторонних библиотек.
Можно ли с помощью SetCurrentPosition попасть точно в заданное время?
Нет. `IMFSourceReader::SetCurrentPosition` не гарантирует точный (exact) seek — для видео позиция обычно оказывается немного раньше указанной, со смещением в сторону key frame. После seek нужно продолжать вызывать `ReadSample`, следить за timestamp, сравнивать последний sample перед target и первый sample после target и выбирать тот, что ближе. Реализация, которая после seek читает всего один sample и сразу его сохраняет, на видео с длинным GOP обычно даёт заметное расхождение по времени.
Почему сохранённый PNG получается прозрачным?
Потому что четвёртый байт MFVideoFormat_RGB32 не обязательно является alpha-каналом. В 32-битном RGB в Windows байты 0, 1, 2 — это B, G, R, а байт 3 может быть alpha, а может игнорироваться (это не ARGB32). Если записать буфер в PNG как есть, изображение иногда получается неожиданно прозрачным, поэтому перед сохранением безопаснее принудительно записать в четвёртый байт значение 0xFF, сделав изображение непрозрачным.
Из-за чего изображение искажается или переворачивается вверх ногами?
Из-за неправильной обработки stride и вертикальной ориентации. Буфер изображения не обязательно упакован сплошной строкой width×bytesPerPixel — в конце строки может быть padding, а форматы семейства RGB иногда хранятся снизу вверх (bottom-up, с отрицательным stride). Если получить через IMF2DBuffer::Lock2D указатель на начало scan line 0 и фактический stride, один раз перепаковать данные в непрерывный top-down буфер BGRA и уже потом передавать его в PNG, сохранение становится простым, а искажений не возникает.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог