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

· Обновлено: · · Media Foundation, C++, Разработка Windows, WIC

История изменений (6 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Чтобы по схемам можно было проследить грубость seek и сравнение соседних кадров, тройную проверку ReadSample, выравнивание stride и alpha и путь до сохранения через WIC, добавлены 17 диаграмм Mermaid (по норме «не меньше одной схемы на 500–750 знаков основного текста»). Текст статьи не менялся.
В начало статьи добавлен раздел «Карта знаний этой статьи». Понятия из текста и связи между ними собраны в краткое изложение, схему и ссылку на страницу сведений. Утверждения статьи не менялись.
Текст обновлён по результатам внешнего ревью (1283 замечания). Содержание отдельных правок — в записях ниже.
Разведены аудитория, среда разработки и предпосылки по входу и выходу. Добавлены таблица соответствия функций кода разделам статьи, заметки по сборке и запуску и тому, что появляется при успехе, а также словарь терминов. Конкретные измеренные значения в выводе не приводятся: объясняется только то, что следует из логики статьи — разница укладывается примерно в половину интервала между кадрами.
Формулировка ссылки на связанную статью приведена в соответствие с её текущим заголовком. Текст статьи не менялся.
Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619681)

Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.

Го Комура (2026). Как извлечь кадр из MP4 по заданному времени с помощью Media Foundation. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619681 https://comcomponent.com/ru/blog/2026/03/15/000-media-foundation-extract-still-image-from-mp4-at-specific-time/

DOI (последняя версия)
10.5281/zenodo.21619681
DOI (эта версия)
10.5281/zenodo.21619682

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

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

Что ломается, если идти в лобЕсли после seek прочитать один sample и сразу сохранить, в игру вступают key frame, timestamp, stride, ориентация и четвёртый байт RGB32 — время съезжает, картинка переворачивается, PNG становится прозрачным.seek, одно чтение, сохранениеВремя немного съезжаетИзображение переворачиваетсяPNG получается прозрачным

Рис. 1: Реализация, которая «вроде бы на этом заканчивается», приводит к сдвигу времени, перевёрнутой картинке и прозрачному PNG.

Общую картину Media Foundation можно посмотреть в более ранней статье Введение в Media Foundation: как понять API через COM. Здесь мы спускаемся на один уровень ниже и сосредотачиваемся только на том, как извлечь из 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, результат становится заметно стабильнее.

Грубая схема и стабильная схемаСхема «seek, одно чтение, сохранение» грубая; стабильнее после seek сравнивать кадры по timestamp вокруг target, копировать с учётом stride и только потом сохранять PNG.одно чтение и сохранение — грубоseekСравнение по timestamp вокруг targetКопирование с учётом strideСохранение PNGПричина сдвига и порчи

Рис. 2: Не сохранять сразу после seek: сравнение соседних кадров и копирование с учётом stride делают результат стабильнее.

Карта знаний этой статьи

Статья систематизирует способ извлечь из MP4 один кадр, ближайший к заданному моменту, через IMFSourceReader в Media Foundation. Seek через SetCurrentPosition не exact и обычно смещается к ключевому кадру, поэтому чем длиннее GOP, тем больше ошибка, и после seek нужно повторять ReadSample и сравнивать timestamp до и после. ReadSample может завершиться успешно при sample равном null, поэтому нужна проверка. Полученный кадр MFVideoFormat_RGB32 через IMF2DBuffer::Lock2D снимает stride и вертикальную ориентацию, приводится к top-down BGRA, четвёртый байт фиксируют как alpha в 0xFF и сохраняют PNG через WIC — так предотвращают непреднамеренно прозрачный PNG.

Карта знаний: извлечение кадра из MP4 (Media Foundation)Схема показывает, что seek через SetCurrentPosition у IMFSourceReader смещается к ключевому кадру; связь длины GOP с сравнением до и после через ReadSample; обработку, которая через Lock2D снимает stride и ориентацию top-down/bottom-up; и путь до сохранения PNG через WIC с фиксацией четвёртого байта RGB32 как alpha.используеттребуетиспользуеттребуетможет вызватьиспользуеттребуеттребуетиспользуеттребуетможет вызватьпредотвращаетиспользуетиспользуетможет вызватьизвлечение кадра из видео (на заданный момент)IMFSourceReader (Source Reader)Media Foundationseek через SetCurrentPositionIMFSourceReader::ReadSampleGOP (Group of Pictures)ключевой кадр (key frame)stride (байты на строку изображения)IMF2DBuffer::Lock2Dориентация top-down / bottom-upMFVideoFormat_RGB32непреднамеренно прозрачный PNGфиксация alpha как 0xFFWIC (Windows Imaging Component)null-сэмпл ReadSample

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 15, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

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

2.1. Кому статья и что предполагается

  • Рассчитана на тех, кто уже вызывал COM API Windows из C++. Достаточно понимать цепочку: инициализация через CoInitializeEx и освобождение указателя на интерфейс через Release
  • HRESULT проверяют макросами SUCCEEDED / FAILED. При ошибке значение читают как есть, в шестнадцатеричном виде. Коды, которые начинаются с 0x8007, приходят из ошибок Win32, младшие 16 бит — это код ошибки Win32 (0x80070057 совпадает с E_INVALIDARG). Специфичные для Media Foundation ошибки с префиксом MF_E_ определены в mferror.h
  • Сам Media Foundation можно читать и с нуля. Общая картина через COM собрана в статье Введение в Media Foundation: как понять API через COM

2.2. Среда разработки

Пункт Что предполагается здесь
OS Windows 10 / Windows 11
IDE Visual Studio 2022 (нагрузка «Разработка классических приложений на C++»)
SDK Windows SDK, который поставляется вместе с Visual Studio (заголовки и библиотеки Media Foundation и WIC)
Проект шаблон C++ «Консольное приложение»
Платформа x64
Дополнительные библиотеки нет. Линковку mfplat.lib и остальных задаём в коде через #pragma comment(lib, ...)

Как вставлять код и на что смотреть вокруг предварительно скомпилированных заголовков — в «7. Заметки о сборке и запуске».

2.3. Вход и выход

Предпосылки такие:

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

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

Контур предпосылок этой статьиЛокальный MP4 на входе, синхронный IMFSourceReader берёт кадр, ближайший к заданному времени, WIC сохраняет PNG — без сторонних библиотек.Локальный MP4Синхронный IMFSourceReaderКадр, ближайший к заданному времениPNG через WICТолько стандартные API Windows

Рис. 3: От входа до сохранения — синхронный Source Reader и стандартные API Windows, без сторонних библиотек.

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

3.1. Ход обработки

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

3.2. Правило выбора кадра

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

Здесь правило такое:

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

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

Правило выбора ближайшего кадраПосле seek читаем ReadSample, держим последний sample до target, а когда приходит первый sample с timestamp не меньше target, сравниваем расстояние обоих до target и берём более близкий.После seek читаем ReadSampleДержим последний sample до targetПриходит первый sample при timestamp >= targetСравниваем расстояние обоих до targetБерём более близкий

Рис. 4: Сравниваем двух соседей и только потом выбираем — не «первый кадр после target», а ближайший.

3.3. Картина обработки

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

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

Четыре ловушки в обработкеНа вид обработка простая, но есть четыре ловушки — точность seek, null у sample, stride и четвёртый байт RGB32; если их учесть, реализация остаётся прямой.На вид простая обработкаТочность seeksample равен nullstride и ориентацияОбращение с 4-м байтомЕсли не наступить — путь прямой

Рис. 5: В простом на вид потоке четыре ловушки; если помнить именно их, реализация остаётся прямой.

4. Ловушки, которые стоит знать заранее

Сначала коротко соберём термины, которые появятся в этой главе.

Термин Смысл
key frame (ключевой кадр) Кадр, который декодируется сам по себе, без ссылок на соседние. В H.264 это, например, IDR-картинка
GOP (Group of Pictures) Пачка кадров от одного key frame до кадра перед следующим key frame. Чем длиннее GOP, тем реже key frame, и тем дальше от заданного времени может оказаться позиция после seek
stride Сколько байт занимает одна строка в буфере изображения. Это не обязательно ширина × байт на пиксель: в конце строки бывает padding
top-down / bottom-up Начинается ли буфер с верхней строки или с нижней. Для bottom-up stride записывают отрицательным числом
MF_SOURCE_READERF_STREAMTICK Один из flag, которые возвращает ReadSample: в потоке есть разрыв (пропуск данных). В таком вызове кадра нет, поэтому читают снова

4.1. SetCurrentPosition — это не exact seek

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

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

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

На видео с длинным GOP время съедет. Если интервал между key frame около 2 секунд, вы можете сохранить кадр почти на 2 секунды раньше заданного момента.

Почему одно чтение сразу после seek даёт сдвигSetCurrentPosition садится чуть раньше указанной позиции, ближе к key frame, поэтому одно чтение сразу после seek на видео с длинным GOP сохраняет кадр заметно раньше заданного времени.SetCurrentPosition (target)Посадка чуть раньше, к key frameВсего один ReadSampleСохраняется более ранний кадрЧем длиннее GOP, тем больше сдвиг

Рис. 6: Seek садится ближе к key frame, поэтому одно чтение может сохранить картинку на длину GOP раньше нужного момента.

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

ReadSample может вернуть S_OK, а ppSample при этом будет NULL. В конце потока приходит MF_SOURCE_READERF_ENDOFSTREAM, при разрыве — MF_SOURCE_READERF_STREAMTICK и другие flag.

Смотреть только HRESULT и сразу разыменовывать pSample опасно. Безопаснее смотреть тройку: HRESULT, flags и pSample.

Тройная проверка результата ReadSampleReadSample может вернуть S_OK при NULL у sample, поэтому смотрят HRESULT, flags и pSample вместе и обрабатывают флаги конца потока и разрыва.Результат ReadSampleСмотрим HRESULTСмотрим flagsСмотрим pSampleВозможны флаги конца и разрываПри S_OK тоже бывает NULL

Рис. 7: Не разыменовывать по одному коду успеха: проверяют HRESULT, flags и pSample вместе.

4.3. Небрежное обращение со stride и ориентацией ломает изображение

Буфер изображения не обязан быть плотной строкой width * bytesPerPixel. В конце строки бывает padding, а форматы семейства RGB иногда хранятся снизу вверх (bottom-up). В Microsoft Learn это сказано прямо: Image Stride и Uncompressed Video Buffers.

Особенно важны два момента:

  • IMF2DBuffer::Lock2D возвращает указатель на начало scan line 0 и фактический stride
  • У bottom-up изображения stride может быть отрицательным

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

Как поглотить stride и ориентациюLock2D у IMF2DBuffer даёт указатель на начало scan line 0 и фактический stride, в том числе отрицательный для bottom-up; данные перепаковывают в непрерывный top-down буфер BGRA и только потом отдают в PNG.Получаем через Lock2DУказатель на начало scan line 0Фактический strideВ bottom-up бывает отрицательнымПерепаковываем в непрерывный top-down BGRAСторона сохранения упрощается

Рис. 8: Lock2D забирает фактический stride и ориентацию; в сохранение уходит уже непрерывный top-down BGRA.

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 и сделать изображение полностью непрозрачным.

Как обращаться с четвёртым байтом RGB32У MFVideoFormat_RGB32 байты 0–2 это B, G, R, а четвёртый не обязан быть alpha; запись как есть иногда даёт прозрачный PNG, поэтому перед сохранением alpha заполняют 0xFF.сохранить как естьзаполнить 0xFF и сохранитьКадр MFVideoFormat_RGB324-й байт не обязан быть alphaPNG может выйти прозрачнымПолностью непрозрачный PNG

Рис. 9: Четвёртый байт не считают alpha по умолчанию: перед сохранением его заполняют 0xFF и фиксируют непрозрачность.

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

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

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

При создании Reader делают четыре вещи:

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

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

Четыре шага при создании ReaderВключаем обработку видео, выключаем все stream, включаем только первый видеопоток и задаём выходной type RGB32 — в этом порядке при создании Reader.Включаем обработку видеоВыключаем все streamВключаем только первый видеопотокВыходной type — RGB32Дальше можно писать в расчёте на RGB32

Рис. 10: Если четыре шага при создании Reader сделать по порядку, дальше остаётся принимать кадры RGB32.

5.2. После seek подтягиваемся по timestamp

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

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

Сравнение соседей после seekПриложение делает seek через SetCurrentPosition, затем повторяет ReadSample, смотрит timestamp и в момент пересечения target сравнивает соседние sample и берёт более близкий.Source ReaderПриложениеSource ReaderПриложениеloop[пока не пересечём target]SetCurrentPosition (target)ReadSamplesample и timestampСравниваем соседние sample и берём более близкий

Рис. 11: Сразу после seek не сохраняем: читаем, пока не пересечём target, и только потом сравниваем соседей.

5.3. Из sample собираем top-down BGRA

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

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

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

Шаги преобразования sample в BGRAConvertToContiguousBuffer собирает один буфер, BufferLock даёт scan line 0 и фактический stride, строки копируют в top-down буфер и ставят alpha в 0xFF.ConvertToContiguousBufferBufferLock: scan line 0 и strideПострочное копирование в top-downalpha = 0xFFОбычное 32bpp BGRA-изображение

Рис. 12: После четырёх шагов преобразования сторона сохранения видит обычное 32bpp BGRA-изображение.

5.4. Сохранение PNG отдаём WIC

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

Разделение ролей Media Foundation и WICMedia Foundation забирает кадр, WIC кодирует изображение и пишет PNG.забирает кадрWIC кодирует изображениеMedia FoundationКадр BGRAФайл PNG

Рис. 13: Кадр берёт Media Foundation, в изображение его превращает WIC — только стандартные API.

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 и пропускной способности памяти
Смена формата Для роликов, где разрешение меняется по ходу, нужна отдельная архитектура Ломаются предпосылки о ширине и высоте

Из этой таблицы строки «многократное извлечение» и «число копирований» относятся не к извлечению одного кадра, а к расширению: когда из одного ролика вынимают десятки кадров. Пример в статье заканчивается после одного кадра, поэтому для нескольких кадров не пересоздавайте Source Reader, а повторяйте SetCurrentPosition и ReadSample. Карта Media Foundation в целом, включая варианты помимо Source Reader (Media Session и другие), есть в статье Введение в Media Foundation: как понять API через COM.

Как расширить схему с одного кадра на несколькоДля одного кадра оставляем текущую схему; если из того же ролика нужно несколько кадров, не пересоздаём Source Reader, а повторяем seek и ReadSample.только одиннесколькоСколько кадров извлекатьОставляем текущую схемуНе пересоздавая Reader, повторяем seek и чтениеУчитываем и стоимость копирований

Рис. 14: Если вынимать несколько кадров, Reader не пересоздают — повторяют seek и чтение.

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

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

7.1. Заметки о сборке

Имеет смысл заранее учесть следующее.

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

7.2. Как запускать и что появляется при успехе

Запуск: ExtractFrameFromMp4.exe <input.mp4> <seconds> <output.png>. Например: ExtractFrameFromMp4.exe C:\work\input.mp4 12.345 C:\work\frame.png.

При успехе wmain в конце печатает в стандартный вывод три строки:

Saved: C:\work\frame.png
Requested: 12.345 sec
Actual: (время показа выбранного кадра) sec

Requested — это секунды из аргумента, Actual — timestamp кадра, который реально взяли. По правилу из 3.2 берётся более близкий из соседних кадров, поэтому разница укладывается примерно в половину интервала между кадрами (для 29.97fps верхняя оценка — около 17 ms). Если расхождение составляет сотни миллисекунд и больше, стоит подозревать, что после seek прочитали только один раз и сравнение из 4.1 не сработало.

Как читать разницу Requested и ActualЕсли разница Requested и Actual укладывается в половину интервала между кадрами, сравнение соседей работает; если это сотни миллисекунд и больше, сравнение после seek, скорее всего, не сработало.половина интервала между кадрамисотни ms и большеСмотрим разницу Requested и ActualСравнение соседей работаетПодозрение, что сравнение не сработалоПроверяем, не читали ли после seek только раз

Рис. 15: Если разница заметно больше половины интервала между кадрами, стоит проверить реализацию сравнения соседей.

Цель проверки — три пункта.

  • Код выхода 0, и в строке Saved: указан заданный путь вывода
  • Открытый PNG показывает сцену этого момента в правильной ориентации (не перевёрнут)
  • Ширина и высота PNG совпадают с разрешением исходного ролика, фон не просвечивает (сработало заполнение alpha из 4.4)

При ошибке в стандартный поток ошибок выходит Failed. HRESULT = 0x......... Значение читают как в 2.1. Если заданные секунды больше или равны длительности ролика, будет 0x80070057 (E_INVALIDARG).

Как проверять результат запускаПри успехе смотрят три строки вывода и ориентацию, разрешение и непрозрачность PNG; при ошибке читают HRESULT в stderr; если секунды не меньше длительности ролика, будет E_INVALIDARG.успехошибкаЗапускаемПроверяем 3 строки вывода и PNGЧитаем HRESULT в stderrЕсли секунды >= длительности — E_INVALIDARG

Рис. 16: При успехе проверяют вывод и три свойства PNG; при ошибке причину читают по HRESULT.

8. Итог

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

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

Если держать это в голове, сбоев становится заметно меньше.

Пять пунктов, которые стоит держатьSeek не exact, сравнение timestamp вокруг target, проверка отсутствия sample при успехе, поглощение stride и ориентации, отказ считать четвёртый байт RGB32 за alpha — вместе это сильно снижает число сбоев.seek не exactСравниваем timestamp вокруг targetПроверяем отсутствие sample при успехеПоглощаем stride и ориентациюНе считаем 4-й байт alphaРеализация, в которой меньше сбоев

Рис. 17: Когда собраны пять опорных пунктов, извлечение кадра на заданный момент времени ломается гораздо реже.

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

9. Справочные материалы

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

Блок ниже рассчитан на то, чтобы перенести его прямо в проект консольного C++-приложения Visual Studio. Аргументы командной строки идут в порядке input.mp4, seconds, output.png. Код самодостаточен и умещается в одном файле, поэтому его удобно вставить в проект.

Блок длинный, поэтому сначала — таблица, какая функция соответствует какому месту в тексте. И читать, и разбирать сбой удобнее начинать с неё.

Функция или класс в коде Раздел в тексте Роль и ловушка, которую закрывает
MediaFoundationScope 5.1 Собирает инициализацию и завершение CoInitializeEx и MFStartup
CreateConfiguredSourceReader 5.1 Создаёт Reader, выбирает только видеопоток и запрашивает MFVideoFormat_RGB32
GetPresentationDuration 6. (диапазон времени) Берёт длительность и проверяет 0 <= target < duration
SeekSourceReader 4.1 / 5.2 Выполняет seek через SetCurrentPosition. Одного этого вызова для exact не хватает
ReadNearestVideoSample 3.2 / 4.1 / 4.2 / 5.2 Сравнивает кадры вокруг target и выбирает один. Здесь же обрабатываются flags и null у pSample
GetDefaultStride 4.3 Если нет MF_MT_DEFAULT_STRIDE, вычисляет stride и подставляет
BufferLock 4.3 / 5.3 Через IMF2DBuffer::Lock2D получает scan line 0 и фактический stride
CopyContiguousBufferToTopDownBgra 4.3 / 4.4 / 5.3 Построчно перепаковывает в top-down буфер и заполняет 4-й байт значением 0xFF
CopySampleToTopDownBgra 5.3 Достаёт frame size и stride и вызывает копирование выше
SaveBgraToPng 5.4 Пишет 32bpp BGRA в PNG через WIC
ExtractFrameFromMp4ToPng 5. в целом Точка входа, которая вызывает функции выше по порядку
TryParseSeconds / wmain 7.2 Разбор аргументов и вывод Requested / Actual
#define NOMINMAX
#if defined(_MSC_VER)
#  if __has_include("pch.h")
#    include "pch.h"
#  elif __has_include("stdafx.h")
#    include "stdafx.h"
#  endif
#endif
#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mferror.h>
#include <mfobjects.h>
#include <propvarutil.h>
#include <wincodec.h>

#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cwchar>
#include <cmath>
#include <cstring>
#include <limits>
#include <vector>

#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfreadwrite.lib")
#pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "ole32.lib")
#pragma comment(lib, "propsys.lib")
#pragma comment(lib, "windowscodecs.lib")

template <class T>
void SafeRelease(T** pp)
{
    if (pp != nullptr && *pp != nullptr)
    {
        (*pp)->Release();
        *pp = nullptr;
    }
}

class MediaFoundationScope
{
public:
    MediaFoundationScope() : m_comInitialized(false), m_mfStarted(false)
    {
    }

    HRESULT Initialize()
    {
        HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
        if (hr == RPC_E_CHANGED_MODE)
        {
            return hr;
        }

        if (SUCCEEDED(hr))
        {
            m_comInitialized = true;
        }

        hr = MFStartup(MF_VERSION);
        if (FAILED(hr))
        {
            if (m_comInitialized)
            {
                CoUninitialize();
                m_comInitialized = false;
            }
            return hr;
        }

        m_mfStarted = true;
        return S_OK;
    }

    ~MediaFoundationScope()
    {
        if (m_mfStarted)
        {
            MFShutdown();
        }

        if (m_comInitialized)
        {
            CoUninitialize();
        }
    }

private:
    bool m_comInitialized;
    bool m_mfStarted;
};

HRESULT GetPresentationDuration(IMFSourceReader* pReader, LONGLONG* phnsDuration)
{
    if (pReader == nullptr || phnsDuration == nullptr)
    {
        return E_POINTER;
    }

    PROPVARIANT var;
    PropVariantInit(&var);

    HRESULT hr = pReader->GetPresentationAttribute(
        MF_SOURCE_READER_MEDIASOURCE,
        MF_PD_DURATION,
        &var);

    if (SUCCEEDED(hr))
    {
        hr = PropVariantToInt64(var, phnsDuration);
    }

    PropVariantClear(&var);
    return hr;
}

HRESULT GetDefaultStride(IMFMediaType* pType, LONG* plStride)
{
    if (pType == nullptr || plStride == nullptr)
    {
        return E_POINTER;
    }

    LONG lStride = 0;
    HRESULT hr = pType->GetUINT32(
        MF_MT_DEFAULT_STRIDE,
        reinterpret_cast<UINT32*>(&lStride));

    if (FAILED(hr))
    {
        GUID subtype = GUID_NULL;
        UINT32 width = 0;
        UINT32 height = 0;

        hr = pType->GetGUID(MF_MT_SUBTYPE, &subtype);
        if (FAILED(hr))
        {
            return hr;
        }

        hr = MFGetAttributeSize(pType, MF_MT_FRAME_SIZE, &width, &height);
        if (FAILED(hr))
        {
            return hr;
        }

        hr = MFGetStrideForBitmapInfoHeader(subtype.Data1, width, &lStride);
        if (FAILED(hr))
        {
            return hr;
        }

        (void)pType->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(lStride));
    }

    *plStride = lStride;
    return S_OK;
}

class BufferLock
{
public:
    explicit BufferLock(IMFMediaBuffer* pBuffer)
        : m_pBuffer(pBuffer),
          m_p2DBuffer(nullptr),
          m_locked(false)
    {
        if (m_pBuffer != nullptr)
        {
            m_pBuffer->AddRef();
            (void)m_pBuffer->QueryInterface(IID_PPV_ARGS(&m_p2DBuffer));
        }
    }

    ~BufferLock()
    {
        UnlockBuffer();
        SafeRelease(&m_p2DBuffer);
        SafeRelease(&m_pBuffer);
    }

    HRESULT LockBuffer(
        LONG defaultStride,
        DWORD heightInPixels,
        BYTE** ppScanLine0,
        LONG* plStride)
    {
        if (ppScanLine0 == nullptr || plStride == nullptr)
        {
            return E_POINTER;
        }

        *ppScanLine0 = nullptr;
        *plStride = 0;

        HRESULT hr = S_OK;

        if (m_p2DBuffer != nullptr)
        {
            hr = m_p2DBuffer->Lock2D(ppScanLine0, plStride);
        }
        else
        {
            BYTE* pData = nullptr;
            hr = m_pBuffer->Lock(&pData, nullptr, nullptr);
            if (SUCCEEDED(hr))
            {
                *plStride = defaultStride;

                if (defaultStride < 0)
                {
                    const size_t strideAbs = static_cast<size_t>(-defaultStride);
                    *ppScanLine0 = pData + strideAbs * (heightInPixels - 1);
                }
                else
                {
                    *ppScanLine0 = pData;
                }
            }
        }

        m_locked = SUCCEEDED(hr);
        return hr;
    }

    void UnlockBuffer()
    {
        if (!m_locked)
        {
            return;
        }

        if (m_p2DBuffer != nullptr)
        {
            (void)m_p2DBuffer->Unlock2D();
        }
        else if (m_pBuffer != nullptr)
        {
            (void)m_pBuffer->Unlock();
        }

        m_locked = false;
    }

private:
    IMFMediaBuffer* m_pBuffer;
    IMF2DBuffer* m_p2DBuffer;
    bool m_locked;
};

HRESULT CreateConfiguredSourceReader(PCWSTR inputPath, IMFSourceReader** ppReader)
{
    if (inputPath == nullptr || ppReader == nullptr)
    {
        return E_POINTER;
    }

    *ppReader = nullptr;

    IMFAttributes* pAttributes = nullptr;
    IMFSourceReader* pReader = nullptr;
    IMFMediaType* pRequestedType = nullptr;

    HRESULT hr = MFCreateAttributes(&pAttributes, 1);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pAttributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = MFCreateSourceReaderFromURL(inputPath, pAttributes, &pReader);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pReader->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pReader->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = MFCreateMediaType(&pRequestedType);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pRequestedType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pRequestedType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
    if (FAILED(hr))
    {
        goto done;
    }

    hr = pReader->SetCurrentMediaType(
        MF_SOURCE_READER_FIRST_VIDEO_STREAM,
        nullptr,
        pRequestedType);
    if (FAILED(hr))
    {
        goto done;
    }

    *ppReader = pReader;
    pReader = nullptr;

done:
    SafeRelease(&pRequestedType);
    SafeRelease(&pReader);
    SafeRelease(&pAttributes);
    return hr;
}

HRESULT SeekSourceReader(IMFSourceReader* pReader, LONGLONG targetHns)
{
    if (pReader == nullptr)
    {
        return E_POINTER;
    }

    PROPVARIANT var;
    PropVariantInit(&var);

    HRESULT hr = InitPropVariantFromInt64(targetHns, &var);
    if (SUCCEEDED(hr))
    {
        hr = pReader->SetCurrentPosition(GUID_NULL, var);
    }

    PropVariantClear(&var);
    return hr;
}

HRESULT ReadNearestVideoSample(
    IMFSourceReader* pReader,
    LONGLONG targetHns,
    IMFSample** ppSample,
    LONGLONG* pChosenTimestampHns)
{
    if (pReader == nullptr || ppSample == nullptr)
    {
        return E_POINTER;
    }

    *ppSample = nullptr;
    if (pChosenTimestampHns != nullptr)
    {
        *pChosenTimestampHns = 0;
    }

    IMFSample* pBefore = nullptr;
    LONGLONG beforeTimestamp = 0;
    bool hasBefore = false;

    HRESULT hr = S_OK;

    for (;;)
    {
        IMFSample* pCurrent = nullptr;
        DWORD flags = 0;
        LONGLONG currentTimestamp = 0;
        LONGLONG diffBefore = 0;
        LONGLONG diffCurrent = 0;

        hr = pReader->ReadSample(
            MF_SOURCE_READER_FIRST_VIDEO_STREAM,
            0,
            nullptr,
            &flags,
            &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;
}

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

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

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

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

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

Что использовать, чтобы извлечь из MP4 кадр на заданный момент времени?
Если нужен один кадр, IMFSourceReader — более прямая точка входа, чем Media Session. Порядок такой: открыть файл через MFCreateSourceReaderFromURL, выбрать только видео через SetStreamSelection, запросить MFVideoFormat_RGB32, выполнить seek через SetCurrentPosition, получить кадр через ReadSample и сохранить PNG через WIC. Всё это закрывается стандартными API Windows, без сторонних библиотек.
Можно ли с помощью SetCurrentPosition попасть точно в заданное время?
Нет. IMFSourceReader::SetCurrentPosition не гарантирует exact seeking: для видео позиция обычно оказывается чуть раньше указанной, ближе к key frame. После seek нужно продолжать ReadSample, смотреть timestamp, сравнивать последний sample до target и первый sample с timestamp >= 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

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

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