Ein Standbild aus einer MP4 zu einem bestimmten Zeitpunkt mit Media Foundation extrahieren

· · Media Foundation, C++, Windows-Entwicklung, WIC

„Aus einer MP4 das eine Bild an der 12,3-Sekunden-Marke holen“ ist eine ziemlich alltägliche Anforderung: Thumbnail-Erzeugung, Prüfprotokolle, repräsentative Frames aus Überwachungsaufnahmen, Belegbilder für Anlagenprotokolle und Ähnliches.

Bei Media Foundation ist das jedoch ein kleines bisschen weniger geradlinig, als es aussieht. Es wirkt, als würde ein einziger Aufruf von ReadSample nach SetCurrentPosition genügen, aber tatsächlich spielen Key Frame, Timestamp, Stride, die Bildausrichtung und das vierte Byte von RGB32 mit hinein. Geht man das nachlässig an, entstehen die unauffälligen, aber lästigen Pannen: Der Zeitpunkt weicht leicht ab, das Bild steht auf dem Kopf, oder das PNG wird merkwürdig transparent.

Für das große Ganze von Media Foundation ist auch unser früherer Artikel Media Foundation verstehen – Warum sich die API wie COM anfühlt hilfreich. Diesmal steigen wir eine Ebene tiefer und konzentrieren uns ausschließlich darauf, ein einzelnes Bild aus einer MP4 zu holen.

In diesem Artikel arbeiten wir mit IMFSourceReader, um das der angegebenen Zeit am nächsten liegende Standbild aus einer MP4 zu extrahieren und als PNG zu speichern — inklusive der Fallstricke, in die man in der Praxis leicht tritt. Und am Ende steht ein einziger, in sich geschlossener Codeblock, der sich direkt in die .cpp eines Visual-Studio-C++-Konsolenanwendungsprojekts einfügen lässt. Im Artikel selbst gibt es keine verstreuten Codefragmente — nehmen Sie einfach den letzten Block mit, und er läuft.

Der in diesem Artikel gezeigte Code ist außerdem als komplettes Beispiel (eine in sich geschlossene C++-Konsolenanwendung in einer Datei) auf GitHub veröffentlicht.

media-foundation-extract-still-image-from-mp4-at-specific-time - komurasoft-blog-samples (GitHub)

1. Das Fazit vorab

Vorab nur die Schlussfolgerungen zusammengefasst:

  • Für das Herausziehen eines einzelnen Bildes aus einer MP4 ist diesmal der Source Reader ein geradlinigerer Einstieg als die Media Session
  • IMFSourceReader::SetCurrentPosition garantiert kein exaktes Seeking. Normalerweise landet es etwas vor dem Ziel, insbesondere in Richtung Key Frame, sodass Sie anschließend mit ReadSample vorrücken und die Zeitpunkte vor und nach der Zielzeit vergleichen müssen
  • ReadSample kann erfolgreich sein und trotzdem pSample == nullptr liefern. Prüfen Sie nicht nur den HRESULT, sondern auch flags und pSample
  • Richtet man den Ausgabe-Medientyp auf MFVideoFormat_RGB32 aus, lässt sich das Ergebnis leicht speichern
  • Allerdings ist das vierte Byte von RGB32 nicht zwingend Alpha, sodass ein direktes Schreiben ins PNG ein transparentes Bild ergeben kann. Sicherer ist es, vor dem Speichern 0xFF einzutragen, um es undurchsichtig zu machen
  • Wird der zeilenweise stride sowie top-down/bottom-up nachlässig behandelt, zerfällt das Bild. Bringen Sie das extrahierte Sample deshalb zunächst in einen zusammenhängenden top-down-BGRA-Puffer, bevor Sie es an PNG übergeben

Kurz gesagt: seek -> einmal lesen -> speichern ist etwas zu grob; geht man bis seek -> unter Beobachtung der Timestamps vor und nach dem Ziel vergleichen -> unter Berücksichtigung des Stride kopieren -> als PNG speichern, wird das Ergebnis ziemlich stabil.

2. Voraussetzungen dieses Artikels

2.1. Zielgruppe und Vorkenntnisse

  • Wir setzen voraus, dass Sie in C++ bereits Windows-COM-APIs aufgerufen haben. Es genügt, den Ablauf zu kennen: mit CoInitializeEx initialisieren und Interface-Zeiger mit Release freigeben
  • Der Rückgabewert HRESULT wird mit den Makros SUCCEEDED / FAILED ausgewertet. Bei einem Fehler lesen Sie den Hexadezimalwert direkt ab. Werte, die mit 0x8007 beginnen, stammen von Win32-Fehlern, wobei die unteren 16 Bit dem Win32-Fehlercode entsprechen (0x80070057 entspricht E_INVALIDARG). Media-Foundation-spezifische Fehler, die mit MF_E_ beginnen, sind in mferror.h definiert
  • Der Artikel ist so geschrieben, dass er auch ohne Vorkenntnisse in Media Foundation lesbar ist. Das große Ganze aus COM-Perspektive ist in Media Foundation verstehen – Warum sich die API wie COM anfühlt zusammengefasst

2.2. Entwicklungsumgebung

Element Voraussetzung in diesem Artikel
OS Windows 10 / Windows 11
IDE Visual Studio 2022 (Workload „Desktopentwicklung mit C++“)
SDK Das mit Visual Studio ausgelieferte Windows SDK (enthält die Header und Bibliotheken für Media Foundation und WIC)
Projekt Die C++-Vorlage „Konsolen-App“
Plattform x64
Zusätzliche Bibliotheken Keine. Die Linker-Angaben wie mfplat.lib werden über #pragma comment(lib, ...) im Code erledigt

Details zum Einfügen und zu Precompiled Headers finden Sie in „7. Hinweise zu Build und Ausführung“.

2.3. Voraussetzungen für Eingabe und Ausgabe

Die Voraussetzungen für diesen Artikel sind:

  • Die Eingabe ist eine lokale MP4-Datei
  • Gewünscht wird ein einzelnes Standbild
  • Es wird nicht „exakt der angegebene Zeitpunkt“ zurückgegeben, sondern „die dem angegebenen Zeitpunkt am nächsten liegende Frame“
  • Die Implementierung nutzt IMFSourceReader im synchronen Modus
  • Das Speicherformat ist PNG über WIC
  • Keine externen Bibliotheken — es wird ausschließlich mit Windows-Standard-APIs gearbeitet
  • Es wird ein gewöhnliches MP4 vorausgesetzt, dessen Auflösung sich mittendrin nicht ändert

Für Wiedergabe, Audiosynchronisation, eine Suchleiste oder UI-Integration braucht es andere Entwürfe, aber für den Zweck „ich will ein Frame“ ist dieser Ansatz ziemlich verständlich.

3. Übersichtstabellen zuerst

3.1. Der Verarbeitungsablauf

Was zu tun ist Verwendete API Rolle
Die MP4 öffnen MFCreateSourceReaderFromURL Erzeugt eine Medienquelle aus der Datei
Nur das Video auswählen SetStreamSelection Verhindert das Lesen der Audiospur
In RGB32 umwandeln SetCurrentMediaType + MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING Liefert ein leicht speicherbares unkomprimiertes Frame
Zum angegebenen Zeitpunkt springen SetCurrentPosition Sucht in Einheiten von 100 ns
Ein Frame lesen ReadSample Holt dekodierte Samples einzeln
Vorheriges/nachfolgendes vergleichen Sample-Timestamp Bestimmt das dem Zeitpunkt am nächsten liegende Bild
Als PNG speichern WIC Schreibt die Bilddatei

3.2. Die in diesem Artikel verwendete Auswahlregel

Auch wenn man von „dem Standbild zum angegebenen Zeitpunkt“ spricht: Ein Video ist keine kontinuierliche Größe, sondern besteht aus diskreten Frames. Es ist daher einfacher, die Auswahlregel für das eine Bild vorab festzulegen.

In diesem Artikel gilt folgende Regel.

  • Nach dem Seek mit ReadSample vorrücken
  • Das letzte Sample mit timestamp < target behalten
  • Kommt das erste Sample mit timestamp >= target, die Differenzen des vorherigen und des aktuellen Samples vergleichen
  • Das dem Ziel näherliegende übernehmen

So erhält man leichter das dem Ziel am nächsten liegende Bild statt „das erste Bild ab dem Ziel“.

3.3. Der Ablauf im Überblick

Vom Anfang bis zum Ende verläuft die Verarbeitung ungefähr so: input.mp4 -> Source Reader erzeugen -> RGB32 anfordern -> seeken -> ReadSample wiederholen -> vor/nach dem Ziel vergleichen -> in top-down BGRA umpacken -> mit WIC als PNG speichern.

Das sieht einfach aus, aber Seek-Genauigkeit, Null-Samples, Stride und die Behandlung des vierten Bytes bergen jeweils eine kleine Falle. Tritt man nicht in diese vier, bleibt die Implementierung selbst ziemlich geradlinig.

4. Die vorab zu kennenden Fallstricke

Die in diesem Kapitel vorkommenden Begriffe fassen wir vorab kurz zusammen.

Begriff Bedeutung
key frame (Keyframe) Eine Frame, die sich ohne Bezug auf umliegende Frames eigenständig dekodieren lässt. Bei H.264 zählen dazu etwa IDR-Bilder
GOP (Group of Pictures) Die Gruppe von Frames von einem Keyframe bis unmittelbar vor den nächsten Keyframe. Je länger die GOP, desto größer der Abstand zwischen Keyframes, sodass die Position nach dem Seek tendenziell weiter vom angegebenen Zeitpunkt entfernt liegt
stride Die Anzahl der Bytes, die eine Zeile im Bildpuffer belegt. Sie muss nicht mit Breite × Bytes pro Pixel übereinstimmen, da am Zeilenende Padding eingefügt sein kann
top-down / bottom-up Der Unterschied, ob der Anfang des Bildpuffers die oberste oder die unterste Zeile ist. Bei bottom-up wird der Stride als negativer Wert dargestellt
MF_SOURCE_READERF_STREAMTICK Eines der von ReadSample zurückgegebenen Flags. Es zeigt eine Lücke (einen Unterbruch der Daten) im Stream an. Bei einem Aufruf mit gesetztem Flag wird kein Frame geliefert, also liest man erneut

4.1. SetCurrentPosition ist kein exaktes Seek

Wie auch Microsoft Learn zu IMFSourceReader::SetCurrentPosition schreibt, garantiert dies kein exaktes Seeking. Bei Video landet es normalerweise etwas vor der angegebenen Position, insbesondere in Richtung Keyframe. Zusätzlich wird vorausgesetzt, dass Sie anschließend mit ReadSample bis zur Zielposition vorrücken.

Deshalb ist folgende Implementierung ziemlich wackelig.

  • SetCurrentPosition(target)
  • einmal ReadSample
  • dieses Frame speichern

Bei Videos mit langer GOP führt das zu Abweichungen. Liegt der Abstand zwischen Keyframes bei 2 Sekunden, speichert man im schlimmsten Fall ein Frame, das fast 2 Sekunden vor dem angegebenen Zeitpunkt liegt.

4.2. ReadSample kann erfolgreich sein und trotzdem pSample == nullptr liefern

ReadSample kann S_OK liefern, während ppSample trotzdem NULL ist. Am Ende des Streams wird das Flag MF_SOURCE_READERF_ENDOFSTREAM zurückgegeben, bei einer Stream-Lücke MF_SOURCE_READERF_STREAMTICK und Ähnliches.

Nur den HRESULT zu prüfen und pSample sofort zu dereferenzieren, ist gefährlich. Sicherer ist es, HRESULT, flags und pSample als Dreiersatz zu betrachten.

4.3. Werden stride und die Ausrichtung nachlässig behandelt, zerfällt das Bild

Ein Bildpuffer ist nicht zwingend lückenlos als width * bytesPerPixel gepackt. Am Zeilenende kann Padding stehen, und RGB-Formate können bottom-up sein. Auch Microsoft Learn schreibt das in Image Stride und Uncompressed Video Buffers ziemlich deutlich.

Besonders wichtig sind diese beiden Punkte.

  • IMF2DBuffer::Lock2D liefert den Zeiger auf den Anfang von Scanline 0 und den tatsächlichen Stride
  • Bei bottom-up-Bildern kann der Stride negativ sein

Dieser Artikel übernimmt den Ansatz der Microsoft-Learn-Hilfsfunktionen und packt die Daten am Ende in einen zusammenhängenden top-down-BGRA-Puffer um, bevor sie an PNG übergeben werden. Klärt man das vorab, wird die Speicherseite deutlich einfacher.

4.4. Das vierte Byte von MFVideoFormat_RGB32 nicht pauschal als Alpha annehmen

Entgegen dem, was der Name suggeriert, ist MFVideoFormat_RGB32 kein „sauberes RGBA“, das sich direkt an PNG übergeben lässt. Bei Windows-32-Bit-RGB sind die Bytes 0, 1, 2 B, G, R, und Byte 3 kann Alpha sein oder ignoriert werden. Wichtig ist: Es ist nicht ARGB32.

Hält man es fälschlich für GUID_WICPixelFormat32bppBGRA und speichert es unverändert, kann im vierten Byte eine 0 stehen, wodurch das Bild merkwürdig transparent wird. In diesem Artikel gilt daher die Regel, vor dem Speichern Alpha mit 0xFF zu füllen und das Bild vollständig undurchsichtig zu machen.

5. Der Implementierungsablauf

5.1. Den Source Reader im synchronen Modus erzeugen

Da diesmal nur ein einzelnes Bild benötigt wird, verwenden wir statt asynchroner Callbacks synchrones ReadSample. Im synchronen Modus blockiert ReadSample bis zum nächsten Sample, aber für die einmalige Standbildextraktion bleibt die Implementierung ziemlich geradlinig.

Bei der Erzeugung des Readers erledigen wir diese vier Dinge.

  • MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING = TRUE
  • Zunächst alle Streams ausschalten
  • Nur MF_SOURCE_READER_FIRST_VIDEO_STREAM einschalten
  • Den Ausgabetyp auf MFMediaType_Video / MFVideoFormat_RGB32 setzen

Damit lässt sich der nachfolgende Code leicht unter der Annahme schreiben, „wir erhalten RGB32-Frames“.

5.2. Nach dem Seek unter Beobachtung des Timestamps annähern

Nach SetCurrentPosition wird nicht sofort gespeichert. Während mit ReadSample Samples gelesen werden, vergleicht man das letzte Bild vor dem Ziel mit dem ersten Bild, das das Ziel überschreitet.

Dieser eine zusätzliche Schritt federt die Ungenauigkeit des Seeks erheblich ab.

5.3. Das Sample in top-down BGRA umformen

Das extrahierte Sample wird nicht direkt ins PNG geschrieben, sondern zunächst in einen top-down-BGRA-Puffer umgepackt.

  • Mit ConvertToContiguousBuffer zu einem einzigen Puffer zusammenfassen
  • Mit dem BufferLock-Helper Scanline 0 und den tatsächlichen Stride ermitteln
  • Zeile für Zeile in den top-down-Puffer kopieren
  • Alpha auf 0xFF setzen

Damit lässt sich das Bild auf der Speicherseite als „einfaches 32-bpp-BGRA-Bild“ behandeln.

5.4. Das PNG-Speichern WIC überlassen

Für das Speichern werden IWICBitmapEncoder / IWICBitmapFrameEncode von WIC verwendet. Die Aufgabenteilung lautet: Media Foundation holt das Frame, WIC macht daraus ein Bild. Auch hier kommt man ausschließlich mit Windows-Standard-APIs aus.

6. Checkliste für die Praxis

Punkt Was zu prüfen ist Was bei Übersehen leicht passiert
Seek-Genauigkeit Nicht direkt nach SetCurrentPosition mit einem einzigen Aufruf entscheiden Ein Frame wird deutlich vor dem angegebenen Zeitpunkt gespeichert
NULL-Samples HRESULT, flags und pSample allesamt prüfen Null-Dereferenzierung am Streamende oder bei einem Stream-Tick
stride Den tatsächlichen Stride und die Ausrichtung berücksichtigen Das Bild zerfällt oder steht auf dem Kopf
Das 4. Byte von RGB32 Alpha auf 0xFF setzen Ein transparentes PNG entsteht
Zeitbereich 0 <= target < duration einhalten Unerwartetes Verhalten in der Nähe des Endes
Wiederholte Extraktion Seek wiederholen, ohne den Reader neu zu erzeugen Unnötig langsam
Anzahl der Kopien Bei Massenverarbeitung die Kosten von ConvertToContiguousBuffer im Blick behalten Unnötiger Verbrauch von CPU und Speicherbandbreite
Formatwechsel Für Sonderfälle mit sich ändernder Auflösung ein separates Design vorsehen Die Annahmen über Breite und Höhe brechen zusammen

Von diesen betreffen nur die beiden Zeilen „Wiederholte Extraktion“ und „Anzahl der Kopien“ nicht die einmalige Extraktion selbst, sondern werden erst relevant, wenn man das Vorgehen auf Dutzende Bilder aus demselben Video ausweitet. Da das heutige Beispiel so aufgebaut ist, dass es nach einem einzelnen Bild endet, ändern Sie es für mehrere Bilder so, dass Sie den Source Reader nicht neu erzeugen, sondern SetCurrentPosition und ReadSample wiederholen. Eine Gesamtkarte von Media Foundation, einschließlich Alternativen zum Source Reader (etwa Media Session), finden Sie in Media Foundation verstehen – Warum sich die API wie COM anfühlt.

7. Hinweise zu Build und Ausführung

Der Code am Ende dieses Artikels ist so gestaltet, dass er sich leicht als eine einzelne .cpp zu einer C++-Konsolenanwendung in Visual Studio hinzufügen lässt. Die Voraussetzungen der Umgebung selbst sind in 2.2 zusammengefasst.

7.1. Hinweise zum Build

Hier ein paar Punkte, die das Leben leichter machen.

  • Da #pragma comment(lib, ...) bereits enthalten ist, sind grundsätzlich keine zusätzlichen Linker-Einstellungen nötig
  • Da wmain verwendet wird, lassen sich Kommandozeilenargumente durchgehend als Unicode behandeln
  • Damit sich der Code auch in der Standard-Console-App-Vorlage mit pch.h oder stdafx.h leicht einfügen lässt, wird am Anfang des Codes __has_include verwendet, um diese Dateien aufzugreifen
  • Erzwingt Ihr Projekt trotzdem einen eigenen Precompiled Header, genügt es, für diese eine .cpp „Precompiled Header nicht verwenden“ einzustellen
  • Als Ausführungskonfiguration wird x64 empfohlen

7.2. Aufruf und was bei Erfolg ausgegeben wird

Der Aufruf lautet ExtractFrameFromMp4.exe <input.mp4> <seconds> <output.png>. Zum Beispiel führen Sie es so aus: ExtractFrameFromMp4.exe C:\work\input.mp4 12.345 C:\work\frame.png.

Bei Erfolg gibt wmain am Ende die folgenden drei Zeilen auf die Standardausgabe aus.

Saved: C:\work\frame.png
Requested: 12.345 sec
Actual: (Anzeigezeitpunkt der übernommenen Frame) sec

Requested ist genau die als Argument übergebene Sekundenzahl, Actual ist der Timestamp des tatsächlich übernommenen Frames. Da nach der Regel aus 3.2 das näherliegende der beiden umliegenden Frames übernommen wird, bleibt die Differenz zwischen beiden in etwa innerhalb der Hälfte des Frame-Abstands (bei 29,97 fps liegt die Obergrenze grob bei 17 ms). Liegt die Differenz mehrere hundert ms oder mehr, sollten Sie vermuten, dass der Vor-/Nach-Vergleich aus 4.1 nicht greift — etwa weil nach dem Seek nur ein einziges Mal gelesen wurde.

Die Ziele der Funktionsprüfung sind diese drei Punkte.

  • Der Exit-Code ist 0, und in der Zeile Saved: erscheint der angegebene Ausgabepfad
  • Öffnet man das erzeugte PNG, wird die Szene zu diesem Zeitpunkt in der richtigen Ausrichtung angezeigt (nicht auf dem Kopf)
  • Breite und Höhe des PNG stimmen mit der Auflösung des Ausgangsvideos überein, und der Hintergrund ist nicht durchsichtig (die Alpha-Füllung aus 4.4 greift)

Bei einem Fehlschlag erscheint auf der Standardfehlerausgabe Failed. HRESULT = 0x......... Der Wert wird nach dem Vorgehen aus 2.1 gelesen. Liegt die angegebene Sekundenzahl bei oder über der Videolänge, lautet er 0x80070057 (E_INVALIDARG).

8. Zusammenfassung

Beim Extrahieren eines Standbilds zu einem angegebenen Zeitpunkt aus einer MP4 mit Media Foundation reicht es nicht ganz, nur SetCurrentPosition und ReadSample zu betrachten. Tatsächlich gilt:

  • Das Seek ist nicht exakt
  • Frames sollten anhand des Timestamps vor und nach dem Ziel verglichen werden
  • Auch bei erfolgreichem ReadSample kann das Sample fehlen
  • stride und die Bildausrichtung berücksichtigen, bevor gespeichert wird
  • Das vierte Byte von RGB32 nicht pauschal als Alpha annehmen

Beachtet man das, werden Pannen ziemlich selten.

Das heutige Beispiel ist eine minimale Konfiguration, die sich ganz auf das saubere Extrahieren eines einzelnen Bildes konzentriert. Es lässt sich direkt für Thumbnail-Erzeugung, das Speichern repräsentativer Frames aus Überwachungsaufnahmen und die Ausgabe von Belegbildern für Prüfprotokolle übernehmen.

9. Quellen

10. Der vollständige Code zum direkten Einfügen in die .cpp

Der folgende einzelne Block ist der Code, der direkt in ein Visual-Studio-C++-Konsolenanwendungsprojekt übernommen werden soll. Die Kommandozeilenargumente sind in der Reihenfolge input.mp4, seconds, output.png. Da alles in einer Datei enthalten ist, lässt er sich leicht in ein Projekt einfügen.

Da es sich um einen langen einzelnen Block handelt, stellen wir vorab dar, welche Funktion welchem Abschnitt des Textes entspricht. Sowohl beim Lesen als auch bei der Fehlersuche, falls etwas nicht funktioniert, lohnt es sich, mit dieser Zuordnungstabelle zu beginnen.

Funktion/Klasse im Code Entsprechender Abschnitt Rolle und behandelter Fallstrick
MediaFoundationScope 5.1 Bündelt Initialisierung und Aufräumen von CoInitializeEx und MFStartup
CreateConfiguredSourceReader 5.1 Erzeugt den Reader, wählt nur den Videostream aus und fordert MFVideoFormat_RGB32 an
GetPresentationDuration 6. (Zeitbereich) Ermittelt die Videolänge und prüft, ob 0 <= target < duration erfüllt ist
SeekSourceReader 4.1 / 5.2 Führt das Seek mit SetCurrentPosition aus. Allein damit wird es nicht exakt
ReadNearestVideoSample 3.2 / 4.1 / 4.2 / 5.2 Vergleicht vor und nach dem Ziel und wählt ein Bild aus. Behandelt hier auch Null-Werte von flags und pSample
GetDefaultStride 4.3 Berechnet den Stride, wenn MF_MT_DEFAULT_STRIDE fehlt
BufferLock 4.3 / 5.3 Ermittelt über IMF2DBuffer::Lock2D Scanline 0 und den tatsächlichen Stride
CopyContiguousBufferToTopDownBgra 4.3 / 4.4 / 5.3 Packt zeilenweise in den top-down-Puffer um und füllt das vierte Byte mit 0xFF
CopySampleToTopDownBgra 5.3 Ermittelt Framegröße und Stride und ruft die obige Kopierfunktion auf
SaveBgraToPng 5.4 Schreibt 32-bpp-BGRA über WIC als PNG
ExtractFrameFromMp4ToPng 5. (gesamt) Einstiegspunkt, der die obigen Funktionen der Reihe nach aufruft
TryParseSeconds / wmain 7.2 Argumentanalyse und Anzeige von 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);

        // Das 4. Byte von MFVideoFormat_RGB32 ist nicht zwingend alpha,
        // daher wird es vor dem PNG-Speichern auf undurchsichtig fixiert.
        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;
}

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Was sollte man verwenden, um ein Standbild zu einem bestimmten Zeitpunkt aus einer MP4 zu holen?
Für ein einzelnes Bild ist IMFSourceReader ein geradlinigerer Einstieg als die Media Session. Der Ablauf: die Datei mit MFCreateSourceReaderFromURL öffnen, mit SetStreamSelection nur das Video auswählen, MFVideoFormat_RGB32 anfordern, mit SetCurrentPosition seeken, mit ReadSample das Frame holen und mit WIC als PNG speichern. Das lässt sich vollständig mit Windows-Standard-APIs umsetzen, ganz ohne externe Bibliotheken.
Kann man mit SetCurrentPosition exakt zum angegebenen Zeitpunkt springen?
Nein. IMFSourceReader::SetCurrentPosition garantiert kein exaktes Seeking; bei Video landet es normalerweise etwas vor der angegebenen Position, insbesondere in Richtung Keyframe. Man muss nach dem Seek mit ReadSample vorrücken, dabei den Timestamp beobachten, das letzte Sample direkt vor dem Ziel mit dem ersten Sample ab dem Ziel vergleichen und das näherliegende übernehmen. Eine Implementierung, die nach dem Seek nur ein einziges Mal liest und speichert, weicht bei Videos mit langer GOP ganz normal ab.
Warum wird das gespeicherte PNG transparent?
Weil das vierte Byte von MFVideoFormat_RGB32 nicht zwingend Alpha ist. Bei Windows-32-Bit-RGB sind die Bytes 0, 1, 2 B, G, R, und Byte 3 kann Alpha sein oder ignoriert werden (es ist kein ARGB32). Schreibt man das direkt ins PNG, kann ein transparentes Bild entstehen. Sicherer ist es, vor dem Speichern das vierte Byte auf 0xFF zu setzen, um es undurchsichtig zu machen.
Woran liegt es, wenn das Bild zerfällt oder auf dem Kopf steht?
An der Behandlung von Stride und Ausrichtung. Ein Bildpuffer ist nicht zwingend lückenlos als Breite × Bytes pro Pixel gepackt — am Zeilenende kann Padding stehen, und RGB-Formate können bottom-up sein (mit negativem Stride). Ermittelt man mit IMF2DBuffer::Lock2D den Zeiger auf den Anfang von Scanline 0 sowie den tatsächlichen Stride und packt die Daten einmal in einen zusammenhängenden top-down-BGRA-Puffer um, bevor man sie an PNG übergibt, wird die Speicherseite einfacher, und Bildfehler werden vermieden.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog