MP4-Frames mit Media Foundation Bilder und Text einbrennen

· · Media Foundation, C++, Windows-Entwicklung, GDI+, Direct2D, DirectWrite, H.264

Logo-Wasserzeichen, Prüfergebnisse, Gerätenummern, Namen von Mitarbeitenden, Zeitstempel. Die Anforderung, solche Informationen in jeden Frame eines MP4-Videos einzubrennen und daraus ein neues MP4 zu erzeugen, kommt bei Überwachung, Inspektion, Nachweisführung und Analyse-UIs recht häufig vor.

Sobald man aber anfängt, mit Media Foundation zu arbeiten, stehen plötzlich IMFSourceReader, IMFSample, IMFMediaBuffer, IMFTransform und IMFSinkWriter nebeneinander, und es wird auf einmal schwer zu erkennen, wo genau man eigentlich Text oder ein PNG einblenden soll.

Dieser Artikel ordnet zunächst das Gesamtbild — Source Reader -> Zeichnen -> Farbkonvertierung -> Sink Writer — und liefert danach ein einzeldateibasiertes Beispiel, das sich direkt in eine C++-Konsolenanwendung in Visual Studio einfügen lässt. Das Beispiel liest ein angegebenes MP4, zeichnet ein angegebenes Bild sowie den Text HelloWorld in jeden Frame und erzeugt daraus ein Ausgabe-MP4.

Dieses Beispiel priorisiert, dass man es einfach einfügen und sofort ausführen kann, und verwendet daher einen Aufbau, der nur das Videobild neu kodiert. Audio-Remuxing ließe sich ebenfalls in dasselbe Programm packen, aber da das Thema dieses Artikels „Bild und Text in jeden Frame einbrennen“ ist, konzentrieren wir uns zunächst darauf.

Der in diesem Artikel gezeigte Code ist als vollständiges Beispielpaket (eine einzelne .cpp-Datei plus CMake-Build-Konfiguration) auf GitHub veröffentlicht.

media-foundation-overlay-image-text-on-mp4-frames - komurasoft-blog-samples (GitHub)

Zielgruppe und benötigte Umgebung

Dieser Artikel richtet sich an Entwickler mit mittlerem Kenntnisstand, die in C++ mit Videoverarbeitung unter Windows beginnen. Vorausgesetzt werden Grundkenntnisse zu COM (ComPtr, HRESULT, Referenzzählung); Media Foundation selbst darf noch neu sein.

Für das Beispiel wird folgende Umgebung benötigt. Details und Anforderungen an die Eingabedaten sind in Kapitel 5 zusammengefasst.

Punkt Voraussetzung
Betriebssystem Windows 10 / 11
Entwicklungsumgebung C++-Konsolenanwendung in Visual Studio 2022
Build-Konfiguration x64
Vorkompilierte Header Für diese .cpp nicht verwenden
Eingabevideo Gewöhnliches MP4 mit gerader Breite und Höhe (da NV12 4:2:0 ist)
Ausgabe MP4 nur mit Videobild, ohne Ton

Vorab: die verwendeten Fachbegriffe

In der Tabelle aus Kapitel 3 tauchen englische Begriffe ohne Erklärung auf. Hier vorab je eine Zeile dazu.

Begriff Bedeutung
remux Der Inhalt der komprimierten Daten bleibt unverändert, nur der Container wird neu erstellt. Da nicht neu kodiert wird, gehen weder Bild- noch Tonqualität verloren, und die Verarbeitung bleibt leichtgewichtig
topology Der Graph, mit dem Media Foundation darstellt, „von welchem Bauteil zu welchem Bauteil die Daten fließen“. Entspricht einem Aufbauschema aus Quelle, Transformation und Senke
custom MFT Ein selbst geschriebener Media Foundation Transform. Implementiert man IMFTransform, lässt sich ein Effekt als Bauteil in die Media-Foundation-Pipeline einfügen
stride Die Anzahl der Bytes, die eine Bildzeile im Speicher belegt. Stimmt nicht zwangsläufig mit Breite × 4 überein; am Zeilenende kann Füllbytes-Padding stehen

1. Zuerst das Fazit

  • Das Grundmuster, um Bild oder Text in jeden Frame eines MP4 einzufügen, lautet: mit dem Source Reader dekodieren -> auf unkomprimierten Frames zusammensetzen -> bei Bedarf die Farbe konvertieren -> mit dem Sink Writer neu kodieren.
  • Das Platzieren von Bild oder Text selbst ist nicht die Aufgabe von Media Foundation. Hierfür denkt man eher in Zeichen-APIs wie GDI+, Direct2D, DirectWrite und WIC.
  • Beim Zurückschreiben nach MP4 (H.264) wird häufig eine Konvertierungsstufe benötigt, die zwischen dem gut zu zeichnenden RGB32 / ARGB32 und dem vom Encoder bevorzugten NV12 / I420 / YUY2 vermittelt.
  • Wenn man die erste Version zum Laufen bringen will, ist der Aufbau Source Reader -> RGB32 -> Zeichnen mit GDI+ -> NV12 -> Sink Writer gut nachvollziehbar.
  • Wenn Geschwindigkeit und Erweiterbarkeit im Vordergrund stehen, lohnt sich der Wechsel zu D3D11 / DXGI-Surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer.

2. Warum dieses Problem etwas verzwickt ist

„Text in ein Video einfügen“ vermischt in Wirklichkeit vier verschiedene Themen.

  1. Container versus Codec Ein mp4 ist ein Container, nicht die Frames selbst. Der Inhalt sind meist komprimierte Daten wie H.264 oder H.265.

  2. Dekodieren / Kodieren Solange die Daten noch komprimiert sind, kann man Text oder ein PNG nicht einfach mit einer gewöhnlichen 2D-Zeichen-API auflegen. Zuerst muss man zu unkomprimierten Frames zurückkehren.

  3. Zeichnen Text, Logos, alphaüberblendete PNGs und kantengeglättete Textdarstellung sind nicht die Aufgabe von Media Foundation selbst. Das ist die Aufgabe von GDI+ oder Direct2D / DirectWrite / WIC.

  4. Farbraum und Pixelformat Das Format, das sich gut zeichnen lässt, und das Format, das der Encoder bevorzugt, sind nicht dasselbe. Genau hier bleibt man leicht unbemerkt hängen.

Grob in einem Satz zusammengefasst: Statt „mit Media Foundation Text einfügen“ ist es klarer, sich vorzustellen, „mit Media Foundation Frames durchreichen, mit einer Zeichen-API etwas auflegen und danach die nötige Farbkonvertierung vor dem Kodieren einfügen“.

3. Die Übersichtstabelle zuerst

Ansatz Aufbau Geeignet für Worauf zu achten ist
Erst zum Laufen bringen Source Reader -> RGB32 -> Zusammensetzen -> NV12 -> Sink Writer Batch-Verarbeitung, interne Tools, erste Implementierung CPU-seitige Kopien und Konvertierungen häufen sich leicht an
Geschwindigkeit steigern D3D11 / DXGI-Surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer Lange Videos, hohe Auflösung, Massenverarbeitung Mehr Verwaltungsaufwand für D3D11 und DXGI
Als wiederverwendbares Bauteil umsetzen Als custom MFT implementieren und in eine topology einfügen Effekte, die in mehreren Anwendungen verwendet werden, Einbindung in eine MF-Pipeline Implementierung, Registrierung und Debugging werden aufwendiger

Das Beispiel in diesem Artikel beschränkt sich auf die oberste Zeile, den Aufbau „erst zum Laufen bringen“.

3.1 Verarbeitungsübersicht

input.mp4IMFSourceReaderUnkomprimierter FrameRGB32Bild + HelloWorld mit GDI+ zeichnenBGRA -> NV12-KonvertierungIMFSinkWriteroutput.mp4Audio-SamplesUnverändert kopierenoder neu kodieren

Wichtig ist hier: Das Zeichnen selbst ist nicht die Aufgabe von Media Foundation. Media Foundation ist dafür zuständig, Frames herein- und herauszureichen; das Platzieren von Bild und Text überlässt man einer Zeichen-API.

4. Wie man die Pipeline sinnvoll aufteilt

4.1 Eingabe mit IMFSourceReader entgegennehmen

Ist die Eingabe ein Dateipfad, bietet sich MFCreateSourceReaderFromURL an; liegen die Videodaten im Speicher vor, ist es klarer, ein IMFByteStream zu erzeugen und MFCreateSourceReaderFromByteStream zu verwenden.

Die erste Entscheidung, die hier ansteht, ist, ob man in einem gut zeichenbaren Format oder in einem für den Encoder geeigneten Format empfängt.

  • Für eine einfache Implementierung eignet sich RGB32 oder ARGB32
  • Für Kodiereffizienz eignet sich ein YUV-Format wie NV12

Da sich das Zusammensetzen von Text und PNGs in RGB-Formaten deutlich einfacher denken lässt, ist es am Anfang bequem, die Frames als RGB32 / ARGB32 zu empfangen.

Aktiviert man MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, übernimmt der Source Reader die Konvertierung YUV -> RGB32 sowie die Deinterlace-Verarbeitung. Das ist praktisch, solange man „erst einmal Frames herausholen und verarbeiten“ will, kann bei langen oder hochauflösenden Videos aber schwerer werden – lohnt sich also im Produktivbetrieb zu überprüfen, wenn Geschwindigkeit gefragt ist.

4.2 Bild- und Textzusammensetzung mit GDI+ oder Direct2D / DirectWrite denken

Aus dem von Media Foundation empfangenen IMFSample holt man den Puffer heraus und legt darauf das Logo-Bild oder den Text.

Dieses Beispiel priorisiert, dass es sich in einer einzigen Datei unterbringen und einfach einfügen lässt, und verwendet daher GDI+ zum Zeichnen.

  • Bilder lassen sich laden
  • Text lässt sich zeichnen
  • Vergleichsweise wenig zusätzliche Vorbereitung
  • Passt gut in eine einzelne .cpp-Datei einer Konsolenanwendung

Bei langen Videos oder Massenverarbeitung von 4K hat dagegen D3D11 + Direct2D + DirectWrite mehr Potenzial. Ein natürlicher Ablauf ist: zuerst mit GDI+ implementieren, dann bei Bedarf an Geschwindigkeit auf Direct2D / DirectWrite umsteigen.

4.3 RGB32 lässt sich nicht immer direkt nach H.264 schreiben

Das ist die Stelle, an der man am ehesten hängen bleibt.

Beim Zurückschreiben nach MP4 (H.264) setzt Microsofts H.264-Encoder häufig YUV-Eingaben wie I420 / IYUV / NV12 / YUY2 / YV12 voraus. Das heißt: Man kann nicht einfach davon ausgehen, dass es reicht, mit dem gut zeichenbaren RGB32 / ARGB32 zusammenzusetzen und das Ergebnis direkt an IMFSinkWriter zu übergeben.

In der Implementierung wird deshalb eine der folgenden Konvertierungen nötig.

  • Einen Video Processor MFT dazwischenschalten für RGB32 / ARGB32 -> NV12
  • Die Konvertierung RGB -> NV12 selbst implementieren

Dieses Beispiel priorisiert die Unterbringung in einer einzigen Datei und verwendet deshalb die selbst geschriebene Konvertierung. Im Produktivbetrieb ist auch ein Aufbau mit einem Video Processor MFT naheliegend, der Farbraumkonvertierung, Größenänderung und Deinterlacing gebündelt behandeln kann.

4.4 Ausgabe mit IMFSinkWriter schreiben

Für die Videoausgabe eignet sich IMFSinkWriter gut.

Das Prinzip ist einfach:

  • Ausgabestream-Typ … das Format, in dem in die Datei geschrieben werden soll Beispiel: MFVideoFormat_H264
  • Eingabestream-Typ … das Format, in dem die Anwendung Daten an den Sink Writer übergibt Beispiel: MFVideoFormat_NV12

und diese getrennt zu konfigurieren.

Aus Sicht des Sink Writer ergibt sich also folgende Aufteilung:

  • Die Anwendung übergibt unkomprimierte NV12-Frames
  • Der Sink Writer kodiert diese nach H.264 und schreibt sie ins MP4

4.5 Ton anfangs getrennt betrachten hilft bei der Übersicht

Sehr häufig will man nur ein Logo oder Text ins Video einfügen, ohne den Ton selbst zu verändern.

In der Praxis ist folgender Aufbau gut nutzbar:

  • Nur den Videostream durch Source Reader -> Zusammensetzen -> Sink Writer schicken
  • Den Audiostream komprimiert belassen und remuxen

Da sich dieses Beispiel jedoch auf das Einbrennen von Bild und Text in die Frames konzentriert, ist die Ausgabe hier ein MP4 nur mit Videobild. Eine Version, die den Ton erhält, lässt sich später im Rahmen einer Erweiterung leichter nachvollziehbar ergänzen.

5. Voraussetzungen und Verwendung dieses Beispiels

Für diesen Code gelten folgende Voraussetzungen:

  • Windows 10 / 11
  • C++-Konsolenanwendung in Visual Studio 2022
  • x64-Build
  • Diese .cpp-Datei verwendet keine vorkompilierten Header
  • Breite und Höhe des Eingabevideos sind gerade Zahlen
  • Die Eingabe ist eine gewöhnliche MP4-Videodatei
  • Die Ausgabe ist ein MP4 nur mit Videobild
  • Das Bild liegt in einem von GDI+ lesbaren Format vor, z. B. PNG / JPEG / BMP / GIF

Da NV12 4:2:0 ist, müssen Breite und Höhe gerade Zahlen sein. Ist diese Bedingung nicht erfüllt, liefert dieses Beispiel deshalb ausdrücklich einen Fehler.

5.1 Verwendung

  1. In Visual Studio eine Konsolenanwendung anlegen
  2. Diese .cpp-Datei vollständig einfügen
  3. Für diese .cpp-Datei die vorkompilierten Header deaktivieren
  4. Mit x64 bauen
  5. Wie folgt ausführen
OverlayMp4.exe input.mp4 overlay.png output.mp4
  • input.mp4 Ursprungsvideo
  • overlay.png Das aufzulegende Bild
  • output.mp4 Zielort der Ausgabe

Der Textstring ist am Anfang des Codes in kOverlayText fest auf HelloWorld gesetzt. Position und Größe lassen sich über die Konstanten im Code anpassen. Eine Erweiterung, die die Übergabe über Kommandozeilenargumente erlaubt, findet sich in 9.5.

5.2 Prüfen, ob es korrekt funktioniert hat

„Ohne Fehler durchgelaufen“ und „korrekt eingebrannt“ sind zwei verschiedene Dinge. Prüft man der Reihe nach die folgenden vier Punkte, findet man die meisten Fehler.

  1. Die Frame-Anzahl am Ende betrachten. Nach Abschluss der Verarbeitung gibt dieses Beispiel Done. frames= gefolgt von der Anzahl der geschriebenen Frames aus. Weicht diese deutlich von der Gesamtframezahl des Eingabevideos ab, wird irgendwo in der ReadSample-Schleife etwas verworfen
  2. Die Basisinformationen der Ausgabedatei mit der Eingabe vergleichen. Klickt man im Explorer mit rechts auf das Ausgabe-MP4 und öffnet Eigenschaften > Details, erscheinen Länge, Framebreite, Framehöhe und Framerate. Stimmt die Länge nicht mit der Eingabe überein, sollte man den Umgang mit dem Zeitstempel verdächtigen (7.4)
  3. Anfang, Mitte und Ende an drei Stellen mit dem Auge prüfen. Prüft man nur den ersten Frame und ist dann zufrieden, übersieht man leicht einen Fehler, bei dem das Overlay im weiteren Verlauf verschwindet. Am zuverlässigsten ist es, aus Eingabe und Ausgabe jeweils ein Standbild zum selben Zeitpunkt herauszuschneiden und nebeneinanderzulegen; das Vorgehen dafür ist in „Mit Media Foundation aus einem MP4 zu einem bestimmten Zeitpunkt ein Standbild herausschneiden“ beschrieben
  4. Prüfen, ob die Farben nicht seltsam wirken. Erscheinen Hauttöne oder Himmel unnatürlich, kann die Koeffizientenwahl in BgraToNv12 (BT.601 versus BT.709) nicht zur Eingabe passen

Beim ersten Test empfiehlt es sich, ein kurzes, wenige Sekunden langes MP4 sowie ein PNG mit klaren Konturen zu verwenden. Versucht man, gleich mit einem langen Video die erste Version zum Laufen zu bringen, dauert die Fehlereingrenzung entsprechend länger.

6. Einzeldateibasierter Code, den man direkt in eine .cpp einfügen kann

6.1 Karte des Codes

Zuerst eine Übersicht. Der Code ist lang, aber die eigentlich zu lesenden Kernstücke sind nur CopySampleToTopDownBgra, DrawOverlay, BgraToNv12 sowie die Schleife in wmain. Der Rest ist Initialisierung und Aufräumen.

Funktion / Klasse Rolle Ausführliche Erklärung
ScopedMf / ScopedGdiplus Verkoppelt MFStartup und die GDI+-Initialisierung/-Beendigung per RAII
ConfigureSourceReader Stellt die Ausgabe des Source Readers auf RGB32 ein und liest Breite, Höhe, fps und die Standard-Frame-Duration aus 4.1
GetDefaultStride Ermittelt den Standard-Stride aus dem Medientyp 7.2
BufferLock Sperrt den Puffer über IMF2DBuffer, falls vorhanden, sonst über IMFMediaBuffer 7.2
CopySampleToTopDownBgra Gleicht Stride und Ausrichtung an und normalisiert auf top-down BGRA 7.2
DrawOverlay Zeichnet mit GDI+ Bild und Text. Nur hier findet das eigentliche „Zeichnen“ statt 4.2 / 7.1
BgraToNv12 Konvertiert das fertig gezeichnete BGRA nach NV12 4.3 / 7.1
CreateNv12Sample Verpackt den NV12-Puffer in ein IMFSample und versieht es mit timestamp und duration 7.4
ChooseBitrate Bestimmt aus den Eingabeinformationen die Ausgabebitrate
CreateSinkWriter Konfiguriert den H.264-Typ der Ausgabe sowie den NV12-Typ, den diese Seite übergibt 4.4
while-Schleife in wmain Verarbeitet frameweise anhand von HRESULT / flags / sample aus ReadSample 7.3 / 7.4

Stellt man dies neben die Verarbeitungsübersicht aus Kapitel 3, entspricht CopySampleToTopDownBgra dem „unkomprimierten Frame“, DrawOverlay dem „Zeichnen mit GDI+“ und BgraToNv12 der „Konvertierung von BGRA nach NV12“.

6.2 Der gesamte Code

#define NOMINMAX
#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mferror.h>
#include <gdiplus.h>
#include <wrl/client.h>

#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cwchar>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfreadwrite.lib")
#pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "mf.lib")
#pragma comment(lib, "gdiplus.lib")

using Microsoft::WRL::ComPtr;

namespace
{
    const wchar_t* kOverlayText = L"HelloWorld";
    const float kMarginRatio = 0.03f;
    const float kImageMaxWidthRatio = 0.20f;
    const float kImageMaxHeightRatio = 0.20f;
    const float kMinFontPx = 24.0f;

    std::string HrToHex(HRESULT hr)
    {
        char buf[32]{};
        std::snprintf(buf, sizeof(buf), "0x%08X", static_cast<unsigned int>(hr));
        return std::string(buf);
    }

    void ThrowIfFailed(HRESULT hr, const char* message)
    {
        if (FAILED(hr))
        {
            throw std::runtime_error(std::string(message) + " failed. HRESULT=" + HrToHex(hr));
        }
    }

    void ThrowIfGdiplusError(Gdiplus::Status status, const char* message)
    {
        if (status != Gdiplus::Ok)
        {
            char buf[128]{};
            std::snprintf(buf, sizeof(buf), "%s failed. GDI+ status=%d", message, static_cast<int>(status));
            throw std::runtime_error(buf);
        }
    }

    BYTE ClampToByte(int value)
    {
        if (value < 0) return 0;
        if (value > 255) return 255;
        return static_cast<BYTE>(value);
    }

    class ScopedGdiplus
    {
    public:
        ScopedGdiplus()
        {
            Gdiplus::GdiplusStartupInput input;
            ThrowIfGdiplusError(Gdiplus::GdiplusStartup(&token_, &input, nullptr), "GdiplusStartup");
        }

        ~ScopedGdiplus()
        {
            if (token_ != 0)
            {
                Gdiplus::GdiplusShutdown(token_);
            }
        }

    private:
        ULONG_PTR token_ = 0;
    };

    class ScopedMf
    {
    public:
        ScopedMf()
        {
            ThrowIfFailed(CoInitializeEx(nullptr, COINIT_MULTITHREADED), "CoInitializeEx");
            comInitialized_ = true;

            ThrowIfFailed(MFStartup(MF_VERSION), "MFStartup");
            mfStarted_ = true;
        }

        ~ScopedMf()
        {
            if (mfStarted_)
            {
                MFShutdown();
            }

            if (comInitialized_)
            {
                CoUninitialize();
            }
        }

    private:
        bool comInitialized_ = false;
        bool mfStarted_ = false;
    };

    class BufferLock
    {
    public:
        explicit BufferLock(IMFMediaBuffer* buffer)
            : buffer_(buffer)
        {
            if (!buffer_)
            {
                throw std::runtime_error("BufferLock received a null buffer.");
            }

            buffer_.As(&buffer2D_);
        }

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

            HRESULT hr = S_OK;

            if (buffer2D_)
            {
                hr = buffer2D_->Lock2D(scanline0, actualStride);
            }
            else
            {
                BYTE* data = nullptr;
                hr = buffer_->Lock(&data, nullptr, nullptr);
                if (SUCCEEDED(hr))
                {
                    *actualStride = defaultStride;
                    if (defaultStride < 0)
                    {
                        *scanline0 = data + (static_cast<LONG>(heightInPixels) - 1) * std::abs(defaultStride);
                    }
                    else
                    {
                        *scanline0 = data;
                    }
                }
            }

            locked_ = SUCCEEDED(hr);
            return hr;
        }

        ~BufferLock()
        {
            if (!locked_)
            {
                return;
            }

            if (buffer2D_)
            {
                buffer2D_->Unlock2D();
            }
            else
            {
                buffer_->Unlock();
            }
        }

    private:
        ComPtr<IMFMediaBuffer> buffer_;
        ComPtr<IMF2DBuffer> buffer2D_;
        bool locked_ = false;
    };

    struct VideoFormatInfo
    {
        UINT32 width = 0;
        UINT32 height = 0;
        UINT32 fpsNum = 0;
        UINT32 fpsDen = 0;
        UINT32 parNum = 1;
        UINT32 parDen = 1;
        LONG sourceStride = 0;
        LONGLONG defaultFrameDuration = 0;
        UINT32 bitrate = 0;
    };

    LONG GetDefaultStride(IMFMediaType* type)
    {
        LONG stride = 0;

        HRESULT hr = type->GetUINT32(MF_MT_DEFAULT_STRIDE, reinterpret_cast<UINT32*>(&stride));
        if (SUCCEEDED(hr))
        {
            return stride;
        }

        GUID subtype = GUID_NULL;
        UINT32 width = 0;
        UINT32 height = 0;

        ThrowIfFailed(type->GetGUID(MF_MT_SUBTYPE, &subtype), "GetGUID(MF_MT_SUBTYPE)");
        ThrowIfFailed(MFGetAttributeSize(type, MF_MT_FRAME_SIZE, &width, &height), "MFGetAttributeSize(MF_MT_FRAME_SIZE)");
        ThrowIfFailed(MFGetStrideForBitmapInfoHeader(subtype.Data1, width, &stride), "MFGetStrideForBitmapInfoHeader");
        ThrowIfFailed(type->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(stride)), "SetUINT32(MF_MT_DEFAULT_STRIDE)");

        return stride;
    }

    UINT32 ChooseBitrate(IMFMediaType* nativeType, UINT32 width, UINT32 height, UINT32 fpsNum, UINT32 fpsDen)
    {
        UINT32 srcBitrate = 0;
        if (SUCCEEDED(nativeType->GetUINT32(MF_MT_AVG_BITRATE, &srcBitrate)) && srcBitrate > 0)
        {
            return srcBitrate;
        }

        const double fps = static_cast<double>(fpsNum) / static_cast<double>(fpsDen);
        double estimated = static_cast<double>(width) * static_cast<double>(height) * fps * 0.07;

        if (estimated < 1500000.0)
        {
            estimated = 1500000.0;
        }

        if (estimated > 25000000.0)
        {
            estimated = 25000000.0;
        }

        return static_cast<UINT32>(estimated);
    }

    VideoFormatInfo ConfigureSourceReader(IMFSourceReader* reader)
    {
        ThrowIfFailed(reader->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE), "SetStreamSelection(all,false)");
        ThrowIfFailed(reader->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE), "SetStreamSelection(video,true)");

        ComPtr<IMFMediaType> nativeType;
        ThrowIfFailed(reader->GetNativeMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, 0, &nativeType), "GetNativeMediaType(video)");

        ComPtr<IMFMediaType> requestedType;
        ThrowIfFailed(MFCreateMediaType(&requestedType), "MFCreateMediaType(video requested)");
        ThrowIfFailed(requestedType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "SetGUID(video requested major)");
        ThrowIfFailed(requestedType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32), "SetGUID(video requested subtype RGB32)");
        ThrowIfFailed(reader->SetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, nullptr, requestedType.Get()), "SetCurrentMediaType(video RGB32)");

        ComPtr<IMFMediaType> currentType;
        ThrowIfFailed(reader->GetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, &currentType), "GetCurrentMediaType(video)");

        VideoFormatInfo info;
        ThrowIfFailed(MFGetAttributeSize(currentType.Get(), MF_MT_FRAME_SIZE, &info.width, &info.height), "Get video frame size");

        HRESULT hr = MFGetAttributeRatio(currentType.Get(), MF_MT_FRAME_RATE, &info.fpsNum, &info.fpsDen);
        if (FAILED(hr))
        {
            ThrowIfFailed(MFGetAttributeRatio(nativeType.Get(), MF_MT_FRAME_RATE, &info.fpsNum, &info.fpsDen), "Get video frame rate");
        }

        if (info.fpsNum == 0 || info.fpsDen == 0)
        {
            throw std::runtime_error("Video frame rate is zero.");
        }

        hr = MFGetAttributeRatio(currentType.Get(), MF_MT_PIXEL_ASPECT_RATIO, &info.parNum, &info.parDen);
        if (FAILED(hr) || info.parNum == 0 || info.parDen == 0)
        {
            info.parNum = 1;
            info.parDen = 1;
        }

        info.sourceStride = GetDefaultStride(currentType.Get());
        info.defaultFrameDuration = (10000000LL * info.fpsDen) / info.fpsNum;
        if (info.defaultFrameDuration <= 0)
        {
            throw std::runtime_error("Calculated frame duration is invalid.");
        }

        info.bitrate = ChooseBitrate(nativeType.Get(), info.width, info.height, info.fpsNum, info.fpsDen);
        return info;
    }

    ComPtr<IMFSinkWriter> CreateSinkWriter(const std::wstring& outputPath, const VideoFormatInfo& videoInfo, DWORD* streamIndex)
    {
        if (streamIndex == nullptr)
        {
            throw std::runtime_error("streamIndex is null.");
        }

        ComPtr<IMFAttributes> attributes;
        ThrowIfFailed(MFCreateAttributes(&attributes, 1), "MFCreateAttributes(sink)");
        ThrowIfFailed(attributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE), "SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS)");

        ComPtr<IMFSinkWriter> writer;
        ThrowIfFailed(MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, attributes.Get(), &writer), "MFCreateSinkWriterFromURL");

        ComPtr<IMFMediaType> outputType;
        ThrowIfFailed(MFCreateMediaType(&outputType), "MFCreateMediaType(video output)");
        ThrowIfFailed(outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "SetGUID(output major)");
        ThrowIfFailed(outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264), "SetGUID(output subtype H264)");
        ThrowIfFailed(outputType->SetUINT32(MF_MT_AVG_BITRATE, videoInfo.bitrate), "SetUINT32(output bitrate)");
        ThrowIfFailed(outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive), "SetUINT32(output interlace)");
        ThrowIfFailed(MFSetAttributeSize(outputType.Get(), MF_MT_FRAME_SIZE, videoInfo.width, videoInfo.height), "MFSetAttributeSize(output frame size)");
        ThrowIfFailed(MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, videoInfo.fpsNum, videoInfo.fpsDen), "MFSetAttributeRatio(output fps)");
        ThrowIfFailed(MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, videoInfo.parNum, videoInfo.parDen), "MFSetAttributeRatio(output PAR)");
        ThrowIfFailed(writer->AddStream(outputType.Get(), streamIndex), "AddStream(video)");

        ComPtr<IMFMediaType> inputType;
        ThrowIfFailed(MFCreateMediaType(&inputType), "MFCreateMediaType(video input)");
        ThrowIfFailed(inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "SetGUID(input major)");
        ThrowIfFailed(inputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_NV12), "SetGUID(input subtype NV12)");
        ThrowIfFailed(inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive), "SetUINT32(input interlace)");
        ThrowIfFailed(MFSetAttributeSize(inputType.Get(), MF_MT_FRAME_SIZE, videoInfo.width, videoInfo.height), "MFSetAttributeSize(input frame size)");
        ThrowIfFailed(MFSetAttributeRatio(inputType.Get(), MF_MT_FRAME_RATE, videoInfo.fpsNum, videoInfo.fpsDen), "MFSetAttributeRatio(input fps)");
        ThrowIfFailed(MFSetAttributeRatio(inputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, videoInfo.parNum, videoInfo.parDen), "MFSetAttributeRatio(input PAR)");
        ThrowIfFailed(writer->SetInputMediaType(*streamIndex, inputType.Get(), nullptr), "SetInputMediaType(video)");

        ThrowIfFailed(writer->BeginWriting(), "BeginWriting");
        return writer;
    }

    void CopySampleToTopDownBgra(IMFSample* sample, const VideoFormatInfo& videoInfo, std::vector<BYTE>& bgra)
    {
        ComPtr<IMFMediaBuffer> buffer;
        ThrowIfFailed(sample->ConvertToContiguousBuffer(&buffer), "ConvertToContiguousBuffer");

        BufferLock lock(buffer.Get());

        BYTE* scanline0 = nullptr;
        LONG actualStride = 0;
        ThrowIfFailed(lock.LockBuffer(videoInfo.sourceStride, videoInfo.height, &scanline0, &actualStride), "LockBuffer");

        const size_t dstStride = static_cast<size_t>(videoInfo.width) * 4;
        bgra.resize(dstStride * videoInfo.height);

        for (UINT32 y = 0; y < videoInfo.height; ++y)
        {
            const BYTE* srcRow = scanline0 + static_cast<LONG>(y) * actualStride;
            BYTE* dstRow = bgra.data() + static_cast<size_t>(y) * dstStride;
            std::memcpy(dstRow, srcRow, dstStride);

            for (UINT32 x = 0; x < videoInfo.width; ++x)
            {
                dstRow[static_cast<size_t>(x) * 4 + 3] = 0xFF;
            }
        }
    }

    void DrawOverlay(std::vector<BYTE>& bgra, UINT32 width, UINT32 height, Gdiplus::Image& overlayImage)
    {
        const INT stride = static_cast<INT>(width * 4);

        Gdiplus::Bitmap frameBitmap(
            static_cast<INT>(width),
            static_cast<INT>(height),
            stride,
            PixelFormat32bppPARGB,
            bgra.data());
        ThrowIfGdiplusError(frameBitmap.GetLastStatus(), "Create frame bitmap");

        Gdiplus::Graphics graphics(&frameBitmap);
        ThrowIfGdiplusError(graphics.GetLastStatus(), "Create graphics");

        graphics.SetCompositingMode(Gdiplus::CompositingModeSourceOver);
        graphics.SetCompositingQuality(Gdiplus::CompositingQualityHighQuality);
        graphics.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic);
        graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
        graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit);

        const Gdiplus::REAL margin = std::max<Gdiplus::REAL>(16.0f, static_cast<Gdiplus::REAL>(height) * kMarginRatio);
        const Gdiplus::REAL maxImageW = static_cast<Gdiplus::REAL>(width) * kImageMaxWidthRatio;
        const Gdiplus::REAL maxImageH = static_cast<Gdiplus::REAL>(height) * kImageMaxHeightRatio;

        const Gdiplus::REAL srcW = static_cast<Gdiplus::REAL>(overlayImage.GetWidth());
        const Gdiplus::REAL srcH = static_cast<Gdiplus::REAL>(overlayImage.GetHeight());
        if (srcW <= 0.0f || srcH <= 0.0f)
        {
            throw std::runtime_error("Overlay image has invalid size.");
        }

        const Gdiplus::REAL imageScale =
            std::min<Gdiplus::REAL>(1.0f, std::min(maxImageW / srcW, maxImageH / srcH));

        const Gdiplus::REAL drawW = srcW * imageScale;
        const Gdiplus::REAL drawH = srcH * imageScale;

        Gdiplus::RectF imageRect(margin, margin, drawW, drawH);
        Gdiplus::SolidBrush imagePlate(Gdiplus::Color(96, 0, 0, 0));
        graphics.FillRectangle(
            &imagePlate,
            imageRect.X - 8.0f,
            imageRect.Y - 8.0f,
            imageRect.Width + 16.0f,
            imageRect.Height + 16.0f);

        graphics.DrawImage(&overlayImage, imageRect);

        const Gdiplus::REAL fontPx =
            std::max<Gdiplus::REAL>(kMinFontPx, static_cast<Gdiplus::REAL>(height) * 0.06f);

        Gdiplus::Font font(L"Segoe UI", fontPx, Gdiplus::FontStyleBold, Gdiplus::UnitPixel);
        ThrowIfGdiplusError(font.GetLastStatus(), "Create font");

        Gdiplus::StringFormat stringFormat;
        stringFormat.SetAlignment(Gdiplus::StringAlignmentNear);
        stringFormat.SetLineAlignment(Gdiplus::StringAlignmentNear);

        Gdiplus::RectF measureLayout(
            margin,
            static_cast<Gdiplus::REAL>(height) - margin - fontPx * 2.0f,
            static_cast<Gdiplus::REAL>(width) - margin * 2.0f,
            fontPx * 2.0f);

        Gdiplus::RectF measured;
        graphics.MeasureString(kOverlayText, -1, &font, measureLayout, &stringFormat, &measured);

        Gdiplus::RectF textBg(
            measured.X - 12.0f,
            measured.Y - 8.0f,
            measured.Width + 24.0f,
            measured.Height + 16.0f);

        Gdiplus::SolidBrush textPlate(Gdiplus::Color(128, 0, 0, 0));
        graphics.FillRectangle(&textPlate, textBg);

        Gdiplus::SolidBrush shadowBrush(Gdiplus::Color(220, 0, 0, 0));
        Gdiplus::RectF shadowLayout = measureLayout;
        shadowLayout.X += 2.0f;
        shadowLayout.Y += 2.0f;
        graphics.DrawString(kOverlayText, -1, &font, shadowLayout, &stringFormat, &shadowBrush);

        Gdiplus::SolidBrush textBrush(Gdiplus::Color(235, 255, 255, 255));
        graphics.DrawString(kOverlayText, -1, &font, measureLayout, &stringFormat, &textBrush);
    }

    void BgraToNv12(const BYTE* bgra, UINT32 width, UINT32 height, BYTE* nv12)
    {
        const bool useBt709 = (width > 1024 || height > 576);

        const int yR = useBt709 ? 47 : 66;
        const int yG = useBt709 ? 157 : 129;
        const int yB = useBt709 ? 16 : 25;

        const int uR = useBt709 ? -26 : -38;
        const int uG = useBt709 ? -87 : -74;
        const int uB = 112;

        const int vR = 112;
        const int vG = useBt709 ? -102 : -94;
        const int vB = useBt709 ? -10 : -18;

        BYTE* yPlane = nv12;
        BYTE* uvPlane = nv12 + static_cast<size_t>(width) * height;

        const size_t srcStride = static_cast<size_t>(width) * 4;

        for (UINT32 y = 0; y < height; ++y)
        {
            const BYTE* srcRow = bgra + static_cast<size_t>(y) * srcStride;
            BYTE* dstY = yPlane + static_cast<size_t>(y) * width;

            for (UINT32 x = 0; x < width; ++x)
            {
                const BYTE b = srcRow[x * 4 + 0];
                const BYTE g = srcRow[x * 4 + 1];
                const BYTE r = srcRow[x * 4 + 2];

                const int Y = ((yR * r + yG * g + yB * b + 128) >> 8) + 16;
                dstY[x] = ClampToByte(Y);
            }
        }

        for (UINT32 y = 0; y < height; y += 2)
        {
            const BYTE* row0 = bgra + static_cast<size_t>(y) * srcStride;
            const BYTE* row1 = bgra + static_cast<size_t>(y + 1) * srcStride;
            BYTE* dstUV = uvPlane + static_cast<size_t>(y / 2) * width;

            for (UINT32 x = 0; x < width; x += 2)
            {
                int b = 0;
                int g = 0;
                int r = 0;

                for (UINT32 dy = 0; dy < 2; ++dy)
                {
                    const BYTE* row = (dy == 0) ? row0 : row1;
                    for (UINT32 dx = 0; dx < 2; ++dx)
                    {
                        const UINT32 ix = x + dx;
                        b += row[ix * 4 + 0];
                        g += row[ix * 4 + 1];
                        r += row[ix * 4 + 2];
                    }
                }

                b = (b + 2) / 4;
                g = (g + 2) / 4;
                r = (r + 2) / 4;

                const int U = ((uR * r + uG * g + uB * b + 128) >> 8) + 128;
                const int V = ((vR * r + vG * g + vB * b + 128) >> 8) + 128;

                dstUV[x + 0] = ClampToByte(U);
                dstUV[x + 1] = ClampToByte(V);
            }
        }
    }

    ComPtr<IMFSample> CreateNv12Sample(
        const std::vector<BYTE>& bgra,
        const VideoFormatInfo& videoInfo,
        LONGLONG sampleTime,
        LONGLONG sampleDuration)
    {
        const DWORD bufferSize =
            static_cast<DWORD>(videoInfo.width * videoInfo.height * 3 / 2);

        ComPtr<IMFMediaBuffer> buffer;
        ThrowIfFailed(MFCreateMemoryBuffer(bufferSize, &buffer), "MFCreateMemoryBuffer");

        BYTE* dst = nullptr;
        DWORD maxLength = 0;
        DWORD currentLength = 0;
        ThrowIfFailed(buffer->Lock(&dst, &maxLength, &currentLength), "Lock(NV12 buffer)");

        try
        {
            BgraToNv12(bgra.data(), videoInfo.width, videoInfo.height, dst);
        }
        catch (...)
        {
            buffer->Unlock();
            throw;
        }

        ThrowIfFailed(buffer->Unlock(), "Unlock(NV12 buffer)");
        ThrowIfFailed(buffer->SetCurrentLength(bufferSize), "SetCurrentLength(NV12 buffer)");

        ComPtr<IMFSample> sample;
        ThrowIfFailed(MFCreateSample(&sample), "MFCreateSample");
        ThrowIfFailed(sample->AddBuffer(buffer.Get()), "AddBuffer(output sample)");
        ThrowIfFailed(sample->SetSampleTime(sampleTime), "SetSampleTime");
        ThrowIfFailed(sample->SetSampleDuration(sampleDuration), "SetSampleDuration");

        return sample;
    }
}

int wmain(int argc, wchar_t* argv[])
{
    if (argc != 4)
    {
        std::wcerr << L"Usage: OverlayMp4.exe <input.mp4> <overlayImage.png> <output.mp4>" << std::endl;
        return 1;
    }

    const std::wstring inputPath = argv[1];
    const std::wstring imagePath = argv[2];
    const std::wstring outputPath = argv[3];

    try
    {
        if (_wcsicmp(inputPath.c_str(), outputPath.c_str()) == 0)
        {
            throw std::runtime_error("Input and output paths must be different.");
        }

        ScopedMf mf;
        ScopedGdiplus gdiplus;

        ComPtr<IMFAttributes> readerAttributes;
        ThrowIfFailed(MFCreateAttributes(&readerAttributes, 1), "MFCreateAttributes(reader)");
        ThrowIfFailed(
            readerAttributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE),
            "SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING)");

        ComPtr<IMFSourceReader> reader;
        ThrowIfFailed(
            MFCreateSourceReaderFromURL(inputPath.c_str(), readerAttributes.Get(), &reader),
            "MFCreateSourceReaderFromURL");

        VideoFormatInfo videoInfo = ConfigureSourceReader(reader.Get());

        if ((videoInfo.width % 2) != 0 || (videoInfo.height % 2) != 0)
        {
            throw std::runtime_error(
                "This sample requires even video width and height because NV12 is 4:2:0.");
        }

        Gdiplus::Image overlayImage(imagePath.c_str());
        ThrowIfGdiplusError(overlayImage.GetLastStatus(), "Load overlay image");

        DWORD videoStreamIndex = 0;
        ComPtr<IMFSinkWriter> writer =
            CreateSinkWriter(outputPath, videoInfo, &videoStreamIndex);

        std::vector<BYTE> bgra;
        LONGLONG firstTimestamp = -1;
        unsigned long long frameCount = 0;

        while (true)
        {
            DWORD flags = 0;
            LONGLONG timestamp = 0;
            ComPtr<IMFSample> inputSample;

            ThrowIfFailed(
                reader->ReadSample(
                    MF_SOURCE_READER_FIRST_VIDEO_STREAM,
                    0,
                    nullptr,
                    &flags,
                    &timestamp,
                    &inputSample),
                "ReadSample(video)");

            if ((flags & MF_SOURCE_READERF_CURRENTMEDIATYPECHANGED) != 0)
            {
                throw std::runtime_error("Dynamic video format change is not supported in this sample.");
            }

            if ((flags & MF_SOURCE_READERF_NATIVEMEDIATYPECHANGED) != 0)
            {
                throw std::runtime_error("Native video format change is not supported in this sample.");
            }

            if ((flags & MF_SOURCE_READERF_STREAMTICK) != 0)
            {
                if (firstTimestamp < 0)
                {
                    firstTimestamp = timestamp;
                }

                ThrowIfFailed(
                    writer->SendStreamTick(videoStreamIndex, timestamp - firstTimestamp),
                    "SendStreamTick");
            }

            if (inputSample)
            {
                if (firstTimestamp < 0)
                {
                    firstTimestamp = timestamp;
                }

                LONGLONG duration = 0;
                if (FAILED(inputSample->GetSampleDuration(&duration)) || duration <= 0)
                {
                    duration = videoInfo.defaultFrameDuration;
                }

                CopySampleToTopDownBgra(inputSample.Get(), videoInfo, bgra);
                DrawOverlay(bgra, videoInfo.width, videoInfo.height, overlayImage);

                ComPtr<IMFSample> outputSample =
                    CreateNv12Sample(bgra, videoInfo, timestamp - firstTimestamp, duration);

                ThrowIfFailed(
                    writer->WriteSample(videoStreamIndex, outputSample.Get()),
                    "WriteSample(video)");

                ++frameCount;
            }

            if ((flags & MF_SOURCE_READERF_ENDOFSTREAM) != 0)
            {
                break;
            }
        }

        ThrowIfFailed(writer->Finalize(), "Finalize");

        std::wcout
            << L"Done. frames=" << frameCount
            << L", output=" << outputPath
            << std::endl;

        return 0;
    }
    catch (const std::exception& ex)
    {
        std::cerr << ex.what() << std::endl;
        return 1;
    }
}

7. Worauf man bei dieser Implementierung achten sollte

7.1 Gut zeichenbares Format und für den Encoder gut geeignetes Format sind unterschiedlich

Dieses Beispiel folgt diesem Ablauf:

  • Ausgabe des Source Reader: RGB32
  • Zeichnen: GDI+
  • Eingabe des Sink Writer: NV12

Der Grund ist einfach: Für das Auflegen von Text oder PNGs ist RGB gut handhabbar, für die Übergabe an die H.264-Kodierung ist NV12 gut handhabbar.

Beim Lesen der Implementierung lässt sich das leichter verfolgen, wenn man in „Zeichenstufe“ und „Stufe, die vor der Kodierung angleicht“ unterteilt.

7.2 Stride und Ausrichtung werden zuerst angeglichen, bevor gezeichnet wird

Videoframes liegen im Speicher nicht zwangsläufig so, wie sie später aussehen sollen.

  • Der Stride stimmt nicht immer mit Breite * 4 überein
  • Die Ausrichtung oben/unten kann vertauscht sein
  • IMF2DBuffer und IMFMediaBuffer verhalten sich leicht unterschiedlich

Dieser Code normalisiert deshalb zunächst auf einen top-down-BGRA-Puffer, bevor gezeichnet wird. Gleicht man dies vorab an, lässt sich der Zeichencode deutlich geradliniger halten.

7.3 ReadSample nicht nur am HRESULT, sondern auch an flags und sample prüfen

ReadSample kann S_OK liefern, obwohl sample == nullptr ist. Typische Beispiele sind:

  • MF_SOURCE_READERF_STREAMTICK
  • MF_SOURCE_READERF_ENDOFSTREAM
  • weitere Stream-Ereignisse

Deshalb müssen in der Schleife HRESULT, flags und inputSample gemeinsam geprüft werden. Übersieht man insbesondere STREAMTICK und ENDOFSTREAM, gerät die nachgelagerte Zeitleistenverarbeitung leicht durcheinander.

7.4 timestamp und duration besser aus der Eingabe übernehmen

Der Zeitstempel liegt in Einheiten von 100 ns vor. Auch die duration muss separat aus dem IMFSample ausgelesen werden.

Statt bei fester fps jedes Mal fest hochzuzählen, ist es robuster, timestamp und duration des Eingabe-Samples so weit wie möglich zu übernehmen. Auch dieses Beispiel weicht nur dann auf den aus fps berechneten Standardwert aus, wenn sich die duration nicht auslesen lässt.

7.5 GDI+ ist leichtgewichtig einzuführen, hat aber bei langen oder hochauflösenden Videos eine nächste Ausbaustufe

GDI+ eignet sich gut für ein einzeldateibasiertes Beispiel, kann aber bei langen Videos oder Massenverarbeitung von 4K gegenüber D3D11 + Direct2D + DirectWrite im Nachteil sein.

  • Zunächst mit GDI+ den gesamten Ablauf zum Laufen bringen
  • Danach bei Bedarf auf Direct2D / DirectWrite umstellen
  • Die Farbkonvertierung zum Video Processor MFT oder auf die GPU verlagern

Dieses schrittweise Vorgehen lässt sich erweitern, ohne das Design zu zerstören.

7.6 Dieses Beispiel konzentriert sich ausschließlich auf das Videobild

Packt man auch den Ton vollständig in denselben Artikel, verliert die Darstellung leicht ihren roten Faden. Deshalb konzentriert sich dieses Beispiel auf das Einbrennen von Bild und Text in die Videoframes und gibt nur ein MP4 mit reinem Videobild aus.

In der Praxis bietet sich als nächster Schritt folgender Aufbau an:

  • Nur das Videobild über Source Reader -> Zusammensetzen -> Sink Writer schicken
  • Den Ton komprimiert remuxen

8. Wenn die „gegebenen Videodaten“ keine Datei, sondern eine MP4-Byte-Folge im Speicher sind

Der vorliegende Code verwendet MFCreateSourceReaderFromURL, die Eingabe ist also ein Dateipfad.

Lautet die Anforderung jedoch, „dasselbe mit einer über eine API erhaltenen MP4-Byte-Folge zu tun“, ändert sich am Grundgedanken nichts. Nur der Eingang ändert sich.

  • Einen IStream oder einen eigenen Stream bereitstellen
  • Diesen als IMFByteStream an den Source Reader übergeben
  • Danach genauso RGB32 -> Zeichnen -> NV12 -> Sink Writer

Der Kern liegt also nicht darin, wie die Videodaten vorliegen, sondern darin, wie man in jeden dekodierten Frame hineinzeichnet.

9. Für den Produktivbetrieb erweitern

9.1 Audio-Remuxing ergänzen

Die naheliegendste erste Erweiterung ist, den Ton unverändert zu erhalten. Kodiert man nur das Videobild neu und schreibt den Ton komprimiert im selben Format zurück, lässt sich die Anforderung erfüllen, ohne die Implementierung stark auszuweiten.

Der Sink Writer unterstützt ausdrücklich die Kombination, komprimierte Eingabe unverändert im selben Format auszugeben, für Remuxing ohne Neukodierung. Zu ergänzen sind drei Stellen:

  1. Den Audiostream komprimiert entgegennehmen. Mit reader->SetStreamSelection(MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE) aktivieren und den über GetNativeMediaType erhaltenen Typ unverändert an SetCurrentMediaType übergeben. Will man das Dekodieren vermeiden, gibt man auf Seite des Source Readers den nativen Typ an – so lautet die Konvention
  2. Dem Sink Writer diesen Typ sowohl als Eingabe- als auch als Ausgabetyp zuweisen. An writer->AddStream(audioType.Get(), &audioStreamIndex) und writer->SetInputMediaType(audioStreamIndex, audioType.Get(), nullptr) denselben Medientyp übergeben
  3. Die Zeitbasis mit dem Videobild gemeinsam nutzen. Das firstTimestamp, das der Code aus 6.2 für das Videobild verwendet, ebenso aus den Audio-Samples abziehen. Verwendet man hier getrennte Bezugspunkte, laufen Ton und Bild auseinander

Auch der Aufruf von ReadSample ändert sich: von der derzeitigen Form, die nur das Videobild angibt, zu einer Form mit MF_SOURCE_READER_ANY_STREAM, bei der anhand des zurückgegebenen Stream-Index verteilt wird.

Der Sink Writer führt dabei, sofern der Encoder es nicht bereitstellt, kein Resampling des Tons und keine Größen- oder Framerate-Änderung des Videobilds durch. Kann die MP4-Senke das Eingangsformat des Tons nicht entgegennehmen, ist statt Remuxing eine Neukodierung nötig.

9.2 Einen Video Processor MFT dazwischenschalten

Dieses Beispiel konvertiert BGRA -> NV12 selbst, um alles in einer Datei unterzubringen. Im Produktivbetrieb ist ein Aufbau mit einem dazwischengeschalteten Video Processor MFT ebenfalls sehr naheliegend.

Mit einem Video Processor MFT lassen sich

  • Farbraumkonvertierung
  • Größenänderung
  • Deinterlacing
  • Framerate-Konvertierung

gebündelt handhaben.

9.3 GDI+ durch Direct2D / DirectWrite ersetzen

Overlays wie Logo-Bilder, Untertitel oder Zeitstempel lassen sich in vielen Fällen bereits gut mit GDI+ umsetzen, für mehr Performance ist jedoch Direct2D / DirectWrite im Vorteil.

Insbesondere bei

  • hoher Auflösung
  • langen Videos
  • großen Stückzahlen
  • dem Wunsch, künftig auf einen GPU-Pfad umzusteigen

kommt ein Aufbau mit D3D11 / DXGI-Surface infrage.

9.4 custom MFT erwägen, sobald ein „wiederverwendbarer Video-Effekt“ gebraucht wird

In Media Foundation lassen sich Effekte als IMFTransform implementieren. Will man dieselbe Overlay-Verarbeitung in mehreren Anwendungen oder Pipelines wiederverwenden, ist ein custom MFT eine saubere Wahl.

Für die erste Version gilt jedoch:

  • Der IMFTransform-Vertrag muss erfüllt werden
  • Die Verwaltung der Ein-/Ausgabe-Medientypen nimmt zu
  • Registrierung und Debugging werden aufwendiger

Deshalb ist es in der Praxis oft praktikabler, zunächst mit Source Reader + Zusammensetzen + Sink Writer korrekt zum Laufen zu bringen und erst bei Bedarf als MFT auszulagern.

9.5 Den Text parametrisieren und Japanisch darstellbar machen

Der Beispieltext ist in kOverlayText fest auf HelloWorld gesetzt. Für Anwendungen, die Gerätenummern oder Namen von Mitarbeitenden einbrennen, möchte man diesen zunächst zu einem Argument machen. Um Japanisch darzustellen, muss zusätzlich die Schriftart geändert werden.

Die Änderung betrifft vier Stellen.

1. Im anonymen Namespace den Namen der zu verwendenden Schriftart ergänzen. Das bestehende kOverlayText bleibt als Standardwert erhalten.

    const wchar_t* kOverlayText = L"HelloWorld";          // bestehend. Dient als Standardwert, wenn kein Argument übergeben wird
    const wchar_t* kFontFamilyName = L"Yu Gothic UI";     // Schriftart, die Japanisch darstellt
    const wchar_t* kFallbackFontFamilyName = L"Segoe UI"; // für Umgebungen ohne die obige Schrift

2. DrawOverlay so erweitern, dass ein zu zeichnender String übergeben werden kann.

    void DrawOverlay(
        std::vector<BYTE>& bgra,
        UINT32 width,
        UINT32 height,
        Gdiplus::Image& overlayImage,
        const std::wstring& overlayText)      // neu

3. Innerhalb von DrawOverlay die Schrifterzeugung und die Verweise auf den String austauschen. Die ursprüngliche Zeile Gdiplus::Font font(L"Segoe UI", ...) wird durch Folgendes ersetzt.

        // Ist die gewünschte Schriftart nicht vorhanden, auf die Standardschrift ausweichen
        Gdiplus::FontFamily preferred(kFontFamilyName);
        Gdiplus::FontFamily fallback(kFallbackFontFamilyName);
        const Gdiplus::FontFamily& family = preferred.IsAvailable() ? preferred : fallback;
        if (!family.IsAvailable())
        {
            throw std::runtime_error("Neither the preferred nor the fallback font family is installed.");
        }

        Gdiplus::Font font(&family, fontPx, Gdiplus::FontStyleBold, Gdiplus::UnitPixel);
        ThrowIfGdiplusError(font.GetLastStatus(), "Create font");

Anschließend werden alle Vorkommen von kOverlayText in MeasureString und den beiden Aufrufen von DrawString durch overlayText.c_str() ersetzt. Korrigiert man nicht alle drei Stellen, bleibt allein der Schatten mit dem alten Text zurück.

4. In wmain das Argument entgegennehmen und an DrawOverlay übergeben.

    if (argc < 4 || argc > 5)
    {
        std::wcerr
            << L"Usage: OverlayMp4.exe <input.mp4> <overlayImage.png> <output.mp4> [text]"
            << std::endl;
        return 1;
    }

    const std::wstring inputPath = argv[1];
    const std::wstring imagePath = argv[2];
    const std::wstring outputPath = argv[3];
    const std::wstring overlayText = (argc == 5) ? std::wstring(argv[4]) : std::wstring(kOverlayText);

Und der Aufruf innerhalb der Schleife lautet dann wie folgt.

                DrawOverlay(bgra, videoInfo.width, videoInfo.height, overlayImage, overlayText);

Zwei Punkte sind beim Umgang mit Japanisch zu beachten.

  • Schreibt man Japanisch als Literal in die .cpp, muss die Quelldatei entweder als UTF-8 mit BOM gespeichert oder mit /utf-8 bei MSVC gebaut werden. Lässt man das aus, entsteht Zeichensalat. Übergibt man den Text über ein Kommandozeilenargument, empfängt wmain ihn als UTF-16, sodass dieses Problem dort nicht auftritt
  • Die Schriftart ist nicht zwangsläufig in jeder Umgebung installiert. Man sollte deshalb wie im obigen Code stets einen Fallback bereithalten. Wechselt bei fehlender Schrift stillschweigend eine andere Schriftart ein, lässt sich die Ursache eines verschobenen Layouts nur schwer nachverfolgen

10. Zusammenfassung

Beim Einbrennen von Bild oder Text in alle Frames eines MP4-Videos mit Media Foundation lohnt es sich, in diese vier Teile zu zerlegen:

  • Herausholen: IMFSourceReader
  • Zeichnen: GDI+ oder Direct2D / DirectWrite
  • In ein für den Encoder gut geeignetes Format bringen: NV12 und Ähnliches
  • Zurückschreiben: IMFSinkWriter

Und wenn man „alles in eine .cpp-Datei packen und sofort ausführen“ möchte, ist wie in diesem Beispiel folgender Aufbau ziemlich geradlinig:

Source Reader -> RGB32 -> Bild + HelloWorld mit GDI+ -> BGRA nach NV12 -> Sink Writer

Für den weiteren Ausbau im Produktivbetrieb lohnt es sich, in dieser Reihenfolge vorzugehen:

  1. Audio-Remuxing ergänzen
  2. GDI+ durch Direct2D / DirectWrite ersetzen
  3. Die NV12-Konvertierung zum Video Processor MFT oder auf die GPU verlagern
  4. Für lange, hochauflösende Videos auf einen D3D11-Surface-basierten Aufbau umsteigen
  5. Bei Bedarf an Wiederverwendbarkeit als custom MFT auslagern

Packt man alles auf einmal an, kommen COM, Stride, Farbraum und Surface-Verwaltung gleichzeitig auf einen zu. Es ist deutlich einfacher zu entwerfen und zu debuggen, wenn man zunächst Stufe für Stufe zum Laufen bringt und danach nur dort nachschärft, wo es nötig ist.

11. Verwandte Artikel

12. Quellen

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.

Wie sieht der grundlegende Ablauf aus, um mit Media Foundation Bild und Text in jeden Frame eines MP4 einzubrennen?
Das Grundmuster lautet: mit dem Source Reader dekodieren -> auf unkomprimierten Frames zusammensetzen -> bei Bedarf die Farbe konvertieren -> mit dem Sink Writer neu kodieren. Das Platzieren von Bild und Text selbst ist nicht die Aufgabe von Media Foundation, sondern die von Zeichen-APIs wie GDI+, Direct2D/DirectWrite oder WIC. Für die erste lauffähige Version ist der Aufbau Source Reader -> RGB32 -> Zeichnen mit GDI+ -> NV12 -> Sink Writer am verständlichsten. Wenn Geschwindigkeit und Erweiterbarkeit im Vordergrund stehen, lohnt sich ein Aufbau mit D3D11/DXGI-Surface und Direct2D/DirectWrite.
Kann ich RGB32-Frames direkt an den H.264-Encoder übergeben?
Nicht unbedingt. Microsofts H.264-Encoder setzt meist YUV-Eingaben wie I420/IYUV/NV12/YUY2/YV12 voraus, sodass nach dem Zusammensetzen im gut zu zeichnenden RGB32/ARGB32 in der Regel eine Konvertierungsstufe nötig wird. Entweder schaltet man einen Video Processor MFT dazwischen, der RGB32/ARGB32 nach NV12 konvertiert, oder man implementiert die RGB-zu-NV12-Konvertierung selbst. Da NV12 4:2:0 ist, müssen Breite und Höhe des Frames außerdem gerade sein.
Sollte man für das Zeichnen des Overlays GDI+ oder Direct2D verwenden?
Für eine erste Implementierung eignet sich GDI+, weil es Bilder laden und Text zeichnen kann und wenig zusätzliche Vorbereitung braucht – ideal für ein einzeldateibasiertes Beispiel. Bei langen Videos, 4K und Massenverarbeitung kann dagegen D3D11+Direct2D+DirectWrite performancemäßig im Vorteil sein. Ein schrittweises Vorgehen – erst mit GDI+ den gesamten Ablauf zum Laufen bringen, dann bei Bedarf auf Direct2D/DirectWrite umstellen und die Farbkonvertierung zum Video Processor MFT oder auf die GPU verlagern – lässt sich erweitern, ohne das Design zu zerstören.
Worauf muss ich bei ReadSample von IMFSourceReader achten?
ReadSample kann S_OK zurückgeben, obwohl sample nullptr ist. Typische Beispiele sind Stream-Ereignisse wie MF_SOURCE_READERF_STREAMTICK oder MF_SOURCE_READERF_ENDOFSTREAM. Deshalb müssen in der Schleife HRESULT, flags und sample gemeinsam geprüft werden. Außerdem wird der Zeitstempel in 100-ns-Einheiten angegeben, und es ist robuster, timestamp und duration möglichst aus dem Eingabe-Sample zu übernehmen, statt sie unter der Annahme einer festen Framerate fest hochzuzählen.

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