MP4-Frames mit Media Foundation Bilder und Text einbrennen
· Go Komura · 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,DirectWriteundWIC. - Beim Zurückschreiben nach
MP4 (H.264)wird häufig eine Konvertierungsstufe benötigt, die zwischen dem gut zu zeichnendenRGB32 / ARGB32und dem vom Encoder bevorzugtenNV12 / I420 / YUY2vermittelt. - Wenn man die erste Version zum Laufen bringen will, ist der Aufbau
Source Reader -> RGB32 -> Zeichnen mit GDI+ -> NV12 -> Sink Writergut 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.
-
Container versus Codec Ein
mp4ist ein Container, nicht die Frames selbst. Der Inhalt sind meist komprimierte Daten wieH.264oderH.265. -
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.
-
Zeichnen Text, Logos, alphaüberblendete PNGs und kantengeglättete Textdarstellung sind nicht die Aufgabe von Media Foundation selbst. Das ist die Aufgabe von
GDI+oderDirect2D / DirectWrite / WIC. -
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
flowchart LR
A[input.mp4] --> B[IMFSourceReader]
B --> C[Unkomprimierter Frame<br/>RGB32]
C --> D[Bild + HelloWorld mit GDI+ zeichnen]
D --> E[BGRA -> NV12-Konvertierung]
E --> F[IMFSinkWriter]
F --> G[output.mp4]
B --> H[Audio-Samples]
H --> I[Unverändert kopieren<br/>oder neu kodieren]
I --> F
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
RGB32oderARGB32 - 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 MFTdazwischenschalten fürRGB32 / ARGB32 -> NV12 - Die Konvertierung
RGB -> NV12selbst 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 Writerkodiert 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 Writerschicken - 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
- In Visual Studio eine Konsolenanwendung anlegen
- Diese
.cpp-Datei vollständig einfügen - Für diese
.cpp-Datei die vorkompilierten Header deaktivieren - Mit
x64bauen - Wie folgt ausführen
OverlayMp4.exe input.mp4 overlay.png output.mp4
input.mp4Ursprungsvideooverlay.pngDas aufzulegende Bildoutput.mp4Zielort 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.
- 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 derReadSample-Schleife etwas verworfen - 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)
- 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
- 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, ¤tType), "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, ¤tLength), "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,
×tamp,
&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
IMF2DBufferundIMFMediaBufferverhalten 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_STREAMTICKMF_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 / DirectWriteumstellen - Die Farbkonvertierung zum
Video Processor MFToder 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 Writerschicken - 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
IStreamoder einen eigenen Stream bereitstellen - Diesen als
IMFByteStreaman denSource 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:
- Den Audiostream komprimiert entgegennehmen. Mit
reader->SetStreamSelection(MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE)aktivieren und den überGetNativeMediaTypeerhaltenen Typ unverändert anSetCurrentMediaTypeübergeben. Will man das Dekodieren vermeiden, gibt man auf Seite des Source Readers den nativen Typ an – so lautet die Konvention - Dem Sink Writer diesen Typ sowohl als Eingabe- als auch als Ausgabetyp zuweisen. An
writer->AddStream(audioType.Get(), &audioStreamIndex)undwriter->SetInputMediaType(audioStreamIndex, audioType.Get(), nullptr)denselben Medientyp übergeben - 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-8bei MSVC gebaut werden. Lässt man das aus, entsteht Zeichensalat. Übergibt man den Text über ein Kommandozeilenargument, empfängtwmainihn 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+oderDirect2D / DirectWrite - In ein für den Encoder gut geeignetes Format bringen:
NV12und Ä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:
- Audio-Remuxing ergänzen
GDI+durchDirect2D / DirectWriteersetzen- Die
NV12-Konvertierung zumVideo Processor MFToder auf die GPU verlagern - Für lange, hochauflösende Videos auf einen
D3D11-Surface-basierten Aufbau umsteigen - Bei Bedarf an Wiederverwendbarkeit als custom
MFTauslagern
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
- Media Foundation verstehen – Warum sich die API wie COM anfühlt
- Mit Media Foundation aus einem MP4 zu einem bestimmten Zeitpunkt ein Standbild herausschneiden
12. Quellen
- Das vollständige Beispielcode-Paket zu diesem Artikel (eine einzelne
.cpp-Datei plus CMake-Build-Konfiguration) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/media-foundation-overlay-image-text-on-mp4-frames - Microsoft Learn: Using the Source Reader to Process Media Data
- Microsoft Learn: MFCreateSourceReaderFromByteStream
- Microsoft Learn: MFCreateMFByteStreamOnStream
- Microsoft Learn: IMFSourceReader::SetCurrentMediaType
- Microsoft Learn: MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING
- Microsoft Learn: MF_SOURCE_READER_ENABLE_ADVANCED_VIDEO_PROCESSING
- Microsoft Learn: IMFSourceReader::ReadSample
- Microsoft Learn: Working with Media Samples
- Microsoft Learn: IMF2DBuffer::Lock2D
- Microsoft Learn: Video Subtype GUIDs
- Microsoft Learn: H.264 Video Encoder
- Microsoft Learn: Video Processor MFT
- Microsoft Learn: Using the Sink Writer
- Microsoft Learn: Tutorial: Using the Sink Writer to Encode Video
- Microsoft Learn: Interoperability Overview (Direct2D)
- Microsoft Learn: Text Rendering with Direct2D and DirectWrite
- Microsoft Learn: Writing a Custom MFT
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
YUV mit Media Foundation in RGB konvertieren
Wie Sie YUV-Frames in Media Foundation nach RGB konvertieren: automatische Konvertierung über den Source Reader sowie manuelle NV12-/YUY2...
Ein Standbild aus einer MP4 zu einem bestimmten Zeitpunkt mit Media Foundation extrahieren
Wie man mit dem Source Reader die einem bestimmten Zeitpunkt nächstgelegene Frame aus einer MP4 holt, Stride und den RGB32-Alphakanal kor...
Media Foundation verstehen – Warum sich die API wie COM anfühlt
Was Media Foundation eigentlich ist, zusammen mit dem grundlegenden Vokabular der Windows-Medien-APIs – COM, HRESULT, IMFSourceReader, MF...
Fallstricke bei Shared Memory und Best Practices für die Praxis
Wir ordnen die Fallstricke beim praktischen Einsatz von Shared Memory und ein Design ein, das die Unfallrate senkt – von Synchronisation ...
Eine C# Native AOT DLL aus C/C++ aufrufen
Wie man eine C#-Klassenbibliothek mit Native AOT als native DLL veröffentlicht und Einstiegspunkte mit UnmanagedCallersOnly aus C/C++ auf...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Windows-App-Entwicklung
Ein Thema, das direkt mit der Windows-Anwendungsentwicklung über Media Foundation, GDI+, Direct2D / DirectWrite, Farbkonvertierung und Videoausgabe hinweg zusammenhängt.
Technische Beratung und Design-Review
Auch geeignet für die konzeptionelle Klärung, wie man von einer Einzeldatei-Implementierung zu einer produktionsreifen Architektur kommt und wo man Audio-Remuxing oder GPU-Verarbeitung abgrenzt.
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.