Как извлечь кадр из 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 выходит странно прозрачным.
flowchart TB
accTitle: Что ломается, если идти в лоб
accDescr: Если после seek прочитать один sample и сразу сохранить, в игру вступают key frame, timestamp, stride, ориентация и четвёртый байт RGB32 — время съезжает, картинка переворачивается, PNG становится прозрачным.
rough1["seek, одно чтение, сохранение"] --> tz1["Время немного съезжает"]
rough1 --> ud1["Изображение переворачивается"]
rough1 --> tp1["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, результат становится заметно стабильнее.
flowchart TB
accTitle: Грубая схема и стабильная схема
accDescr: Схема «seek, одно чтение, сохранение» грубая; стабильнее после seek сравнивать кадры по timestamp вокруг target, копировать с учётом stride и только потом сохранять PNG.
sk1["seek"] --> cmp1["Сравнение по timestamp вокруг target"]
cmp1 --> cp1["Копирование с учётом stride"]
cp1 --> sv1["Сохранение PNG"]
sk1 -.->|"одно чтение и сохранение — грубо"| ng1["Причина сдвига и порчи"]
Рис. 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.
flowchart LR
accTitle: Карта знаний: извлечение кадра из MP4 (Media Foundation)
accDescr: Схема показывает, что seek через SetCurrentPosition у IMFSourceReader смещается к ключевому кадру; связь длины GOP с сравнением до и после через ReadSample; обработку, которая через Lock2D снимает stride и ориентацию top-down/bottom-up; и путь до сохранения PNG через WIC с фиксацией четвёртого байта RGB32 как alpha.
video_frame_extraction["извлечение кадра из видео (на заданный момент)"]
imfsourcereader["IMFSourceReader (Source Reader)"]
media_foundation["Media Foundation"]
source_reader_seek["seek через SetCurrentPosition"]
readsample["IMFSourceReader::ReadSample"]
group_of_pictures["GOP (Group of Pictures)"]
key_frame["ключевой кадр (key frame)"]
video_stride["stride (байты на строку изображения)"]
imf2dbuffer_lock2d["IMF2DBuffer::Lock2D"]
top_down_bottom_up_orientation["ориентация top-down / bottom-up"]
mfvideoformat_rgb32["MFVideoFormat_RGB32"]
unintended_transparent_png["непреднамеренно прозрачный PNG"]
alpha_channel_fixup["фиксация alpha как 0xFF"]
windows_imaging_component["WIC (Windows Imaging Component)"]
null_sample_result["null-сэмпл ReadSample"]
video_frame_extraction -->|"использует"| imfsourcereader
imfsourcereader -->|"требует"| media_foundation
imfsourcereader -->|"использует"| source_reader_seek
source_reader_seek -->|"требует"| readsample
group_of_pictures -.->|"может вызвать"| source_reader_seek
source_reader_seek -->|"использует"| key_frame
video_frame_extraction -->|"требует"| readsample
video_frame_extraction -->|"требует"| video_stride
video_stride -->|"использует"| imf2dbuffer_lock2d
video_stride -->|"требует"| top_down_bottom_up_orientation
mfvideoformat_rgb32 -.->|"может вызвать"| unintended_transparent_png
alpha_channel_fixup -->|"предотвращает"| unintended_transparent_png
video_frame_extraction -->|"использует"| windows_imaging_component
video_frame_extraction -->|"использует"| mfvideoformat_rgb32
readsample -->|"может вызвать"| null_sample_result
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 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, архитектура будет другой. Для задачи «получить один кадр» этот путь достаточно понятен.
flowchart TB
accTitle: Контур предпосылок этой статьи
accDescr: Локальный MP4 на входе, синхронный IMFSourceReader берёт кадр, ближайший к заданному времени, WIC сохраняет PNG — без сторонних библиотек.
mp1["Локальный MP4"] --> rd1["Синхронный IMFSourceReader"]
rd1 --> nf1["Кадр, ближайший к заданному времени"]
nf1 --> png1["PNG через WIC"]
rd1 -.-> std1["Только стандартные 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.
flowchart TB
accTitle: Правило выбора ближайшего кадра
accDescr: После seek читаем ReadSample, держим последний sample до target, а когда приходит первый sample с timestamp не меньше target, сравниваем расстояние обоих до target и берём более близкий.
adv1["После seek читаем ReadSample"] --> bf1["Держим последний sample до target"]
bf1 --> af1["Приходит первый sample при timestamp >= target"]
af1 --> df1["Сравниваем расстояние обоих до target"]
df1 --> pk1["Берём более близкий"]
Рис. 4: Сравниваем двух соседей и только потом выбираем — не «первый кадр после target», а ближайший.
3.3. Картина обработки
От начала до конца путь примерно такой: input.mp4 -> создать Source Reader -> запросить RGB32 -> выполнить seek -> повторять ReadSample -> сравнить кадры вокруг target -> перепаковать в top-down BGRA -> сохранить PNG через WIC.
На вид всё просто, но в точности seek, в null у sample, в stride и в четвёртом байте спрятаны свои маленькие ловушки. Если на них не наступить, сама реализация укладывается прямо.
flowchart TB
accTitle: Четыре ловушки в обработке
accDescr: На вид обработка простая, но есть четыре ловушки — точность seek, null у sample, stride и четвёртый байт RGB32; если их учесть, реализация остаётся прямой.
lk1["На вид простая обработка"] --> t1["Точность seek"]
lk1 --> t2["sample равен null"]
lk1 --> t3["stride и ориентация"]
t3 -.-> t4["Обращение с 4-м байтом"]
t1 --> okf["Если не наступить — путь прямой"]
t2 --> okf
t3 --> okf
Рис. 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 секунды раньше заданного момента.
flowchart TB
accTitle: Почему одно чтение сразу после seek даёт сдвиг
accDescr: SetCurrentPosition садится чуть раньше указанной позиции, ближе к key frame, поэтому одно чтение сразу после seek на видео с длинным GOP сохраняет кадр заметно раньше заданного времени.
sp1["SetCurrentPosition (target)"] --> kf1["Посадка чуть раньше, к key frame"]
kf1 --> one1["Всего один ReadSample"]
one1 --> ng2["Сохраняется более ранний кадр"]
ng2 -.-> gp1["Чем длиннее 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.
flowchart TB
accTitle: Тройная проверка результата ReadSample
accDescr: ReadSample может вернуть S_OK при NULL у sample, поэтому смотрят HRESULT, flags и pSample вместе и обрабатывают флаги конца потока и разрыва.
rs1["Результат ReadSample"] --> h1["Смотрим HRESULT"]
rs1 --> f1["Смотрим flags"]
rs1 --> s1["Смотрим pSample"]
f1 -.-> gap1["Возможны флаги конца и разрыва"]
s1 -.-> nl1["При 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. Если выровнять это заранее, сторона сохранения становится намного проще.
flowchart TB
accTitle: Как поглотить stride и ориентацию
accDescr: Lock2D у IMF2DBuffer даёт указатель на начало scan line 0 и фактический stride, в том числе отрицательный для bottom-up; данные перепаковывают в непрерывный top-down буфер BGRA и только потом отдают в PNG.
l2d["Получаем через Lock2D"] --> sl0["Указатель на начало scan line 0"]
l2d --> ast1["Фактический stride"]
ast1 -.-> neg1["В bottom-up бывает отрицательным"]
sl0 --> pack1["Перепаковываем в непрерывный top-down BGRA"]
ast1 --> pack1
pack1 --> sim1["Сторона сохранения упрощается"]
Рис. 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 и сделать изображение полностью непрозрачным.
flowchart TB
accTitle: Как обращаться с четвёртым байтом RGB32
accDescr: У MFVideoFormat_RGB32 байты 0–2 это B, G, R, а четвёртый не обязан быть alpha; запись как есть иногда даёт прозрачный PNG, поэтому перед сохранением alpha заполняют 0xFF.
rgb1["Кадр MFVideoFormat_RGB32"] --> b4["4-й байт не обязан быть alpha"]
b4 -->|"сохранить как есть"| tp2["PNG может выйти прозрачным"]
b4 -->|"заполнить 0xFF и сохранить"| op1["Полностью непрозрачный 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».
flowchart TB
accTitle: Четыре шага при создании Reader
accDescr: Включаем обработку видео, выключаем все stream, включаем только первый видеопоток и задаём выходной type RGB32 — в этом порядке при создании Reader.
c1["Включаем обработку видео"] --> c2["Выключаем все stream"]
c2 --> c3["Включаем только первый видеопоток"]
c3 --> c4["Выходной type — RGB32"]
c4 -.-> rdy1["Дальше можно писать в расчёте на RGB32"]
Рис. 10: Если четыре шага при создании Reader сделать по порядку, дальше остаётся принимать кадры RGB32.
5.2. После seek подтягиваемся по timestamp
После SetCurrentPosition кадр сразу не сохраняем. Читая sample через ReadSample, сравниваем последний кадр до target с первым кадром, который target пересёк.
Этот шаг заметно сглаживает грубость seek.
sequenceDiagram
accTitle: Сравнение соседей после seek
accDescr: Приложение делает seek через SetCurrentPosition, затем повторяет ReadSample, смотрит timestamp и в момент пересечения target сравнивает соседние sample и берёт более близкий.
participant A as Приложение
participant R as Source Reader
A->>R: SetCurrentPosition (target)
loop пока не пересечём target
A->>R: ReadSample
R-->>A: sample и timestamp
end
A->>A: Сравниваем соседние 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-изображением».
flowchart TB
accTitle: Шаги преобразования sample в BGRA
accDescr: ConvertToContiguousBuffer собирает один буфер, BufferLock даёт scan line 0 и фактический stride, строки копируют в top-down буфер и ставят alpha в 0xFF.
cv1["ConvertToContiguousBuffer"] --> bl1["BufferLock: scan line 0 и stride"]
bl1 --> rc1["Построчное копирование в top-down"]
rc1 --> al1["alpha = 0xFF"]
al1 --> out1["Обычное 32bpp BGRA-изображение"]
Рис. 12: После четырёх шагов преобразования сторона сохранения видит обычное 32bpp BGRA-изображение.
5.4. Сохранение PNG отдаём WIC
Для сохранения используем IWICBitmapEncoder / IWICBitmapFrameEncode из WIC. Разделение такое: Media Foundation забирает кадр, WIC превращает его в файл изображения. Всё это закрывается стандартными API Windows.
flowchart LR
accTitle: Разделение ролей Media Foundation и WIC
accDescr: Media Foundation забирает кадр, WIC кодирует изображение и пишет PNG.
mf1["Media Foundation"] -->|"забирает кадр"| fr1["Кадр BGRA"]
fr1 -->|"WIC кодирует изображение"| pg1["Файл 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.
flowchart TB
accTitle: Как расширить схему с одного кадра на несколько
accDescr: Для одного кадра оставляем текущую схему; если из того же ролика нужно несколько кадров, не пересоздаём Source Reader, а повторяем seek и ReadSample.
q3["Сколько кадров извлекать"] -->|"только один"| as1["Оставляем текущую схему"]
q3 -->|"несколько"| rp1["Не пересоздавая Reader, повторяем seek и чтение"]
rp1 -.-> cost1["Учитываем и стоимость копирований"]
Рис. 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 не сработало.
flowchart TB
accTitle: Как читать разницу Requested и Actual
accDescr: Если разница Requested и Actual укладывается в половину интервала между кадрами, сравнение соседей работает; если это сотни миллисекунд и больше, сравнение после seek, скорее всего, не сработало.
dfc["Смотрим разницу Requested и Actual"] -->|"половина интервала между кадрами"| okd["Сравнение соседей работает"]
dfc -->|"сотни ms и больше"| ngd["Подозрение, что сравнение не сработало"]
ngd -.-> ck2["Проверяем, не читали ли после seek только раз"]
Рис. 15: Если разница заметно больше половины интервала между кадрами, стоит проверить реализацию сравнения соседей.
Цель проверки — три пункта.
- Код выхода 0, и в строке
Saved:указан заданный путь вывода - Открытый PNG показывает сцену этого момента в правильной ориентации (не перевёрнут)
- Ширина и высота PNG совпадают с разрешением исходного ролика, фон не просвечивает (сработало заполнение alpha из 4.4)
При ошибке в стандартный поток ошибок выходит Failed. HRESULT = 0x......... Значение читают как в 2.1. Если заданные секунды больше или равны длительности ролика, будет 0x80070057 (E_INVALIDARG).
flowchart TB
accTitle: Как проверять результат запуска
accDescr: При успехе смотрят три строки вывода и ориентацию, разрешение и непрозрачность PNG; при ошибке читают HRESULT в stderr; если секунды не меньше длительности ролика, будет E_INVALIDARG.
run1["Запускаем"] -->|"успех"| ok3["Проверяем 3 строки вывода и PNG"]
run1 -->|"ошибка"| er2["Читаем HRESULT в stderr"]
er2 -.-> iv1["Если секунды >= длительности — E_INVALIDARG"]
Рис. 16: При успехе проверяют вывод и три свойства PNG; при ошибке причину читают по HRESULT.
8. Итог
Чтобы извлечь кадр из MP4 по заданному времени в Media Foundation, одного взгляда на SetCurrentPosition и ReadSample мало. На практике нужно учесть следующее:
- seek не exact
- кадры лучше сравнивать по timestamp вокруг нужного момента
- успешный
ReadSampleне гарантирует наличие sample - перед сохранением нужно поглотить
strideи ориентацию изображения - четвёртый байт
RGB32не считают alpha по умолчанию
Если держать это в голове, сбоев становится заметно меньше.
flowchart TB
accTitle: Пять пунктов, которые стоит держать
accDescr: Seek не exact, сравнение timestamp вокруг target, проверка отсутствия sample при успехе, поглощение stride и ориентации, отказ считать четвёртый байт RGB32 за alpha — вместе это сильно снижает число сбоев.
k1["seek не exact"] --> k2["Сравниваем timestamp вокруг target"]
k2 --> k3["Проверяем отсутствие sample при успехе"]
k3 --> k4["Поглощаем stride и ориентацию"]
k4 --> k5["Не считаем 4-й байт alpha"]
k5 --> safe2["Реализация, в которой меньше сбоев"]
Рис. 17: Когда собраны пять опорных пунктов, извлечение кадра на заданный момент времени ломается гораздо реже.
Пример в статье — минимальная схема, заточенная на то, чтобы корректно вынуть один кадр. Её можно переносить как есть на генерацию миниатюр, сохранение характерного кадра из записи видеонаблюдения и вывод кадра-доказательства в журнал проверки.
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. Код самодостаточен и умещается в одном файле, поэтому его удобно вставить в проект.
Блок длинный, поэтому сначала — таблица, какая функция соответствует какому месту в тексте. И читать, и разбирать сбой удобнее начинать с неё.
| Функция или класс в коде | Раздел в тексте | Роль и ловушка, которую закрывает |
|---|---|---|
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,
¤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 и собрать новый файл: роли Source Reader, отрисовки, прео...
Как преобразовать YUV в RGB с помощью Media Foundation
Как в Media Foundation преобразовать кадр YUV в RGB: автоматическое преобразование Source Reader, самостоятельное преобразование NV12/YUY...
Введение в Media Foundation: как понять API через COM
Разбираем, что такое Media Foundation, вместе с базовой терминологией Windows-медиа-API — COM, HRESULT, IMFSourceReader, MFT — в том поря...
Разделяемая память: подводные камни и практические рекомендации
Подводные камни разделяемой памяти на практике и проектирование, которое снижает частоту сбоев: синхронизация, видимость, время жизни, AB...
Пул потоков Win32 — параллелизм через CreateThreadpoolWork без своих потоков
Не плодите ли вы CreateThread по всему нативному коду? Разбираем API пула потоков Win32, переработанный в Vista: четыре объекта work, tim...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы 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 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.