Как извлечь статичное изображение из 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. Источники
- Полный набор образцов кода к этой статье: 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
Блок кода ниже рассчитан на то, чтобы перенести его прямо в проект консольного 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,
¤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 не обязательно является 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 Foundation
В статье разбирается, как средствами Media Foundation встроить изображение и текст в каждый кадр MP4-видео и получить новый MP4: распреде...
Как преобразовать YUV в RGB с помощью Media Foundation
Разбираем способы преобразования YUV-кадров в RGB в Media Foundation: автоматическое преобразование через Source Reader и самостоятельное...
Введение в Media Foundation — понимание API через призму COM
Разбираем, что такое Media Foundation, вместе с базовой терминологией Windows-медиа-API — COM, HRESULT, IMFSourceReader, MFT — в том поря...
Разделяемая память: подводные камни и практические рекомендации
Разбираем подводные камни практического использования разделяемой памяти и проектирование, снижающее частоту сбоев: синхронизация, видимо...
Как вызвать C# Native AOT DLL из C/C++
Разбираем, как публиковать библиотеку классов C# в виде нативной DLL через Native AOT и вызывать точки входа UnmanagedCallersOnly из C/C+...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Речь идёт об извлечении статичных изображений из видео с помощью Media Foundation, Source Reader и WIC, а это близкая к разработке Windows-приложений тема реализации.
Технические консультации и ревью дизайна
Если нужно до начала реализации разобраться с точностью 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.
Публичные ссылки