Cómo grabar imágenes y texto en los fotogramas de un MP4 con Media Foundation
· Actualizado el: · Go Komura · Media Foundation, C++, Desarrollo en Windows, GDI+, Direct2D, DirectWrite, H.264
Marcas de agua de logotipo, resultados de inspección, números de equipo, nombres de operarios, marcas de tiempo. El requisito de crear un nuevo MP4 con esta información grabada en todos los fotogramas de un vídeo MP4 aparece con bastante frecuencia en interfaces de supervisión, inspección, trazabilidad y análisis.
Sin embargo, en cuanto se empieza a trabajar con Media Foundation aparecen en fila IMFSourceReader, IMFSample, IMFMediaBuffer, IMFTransform e IMFSinkWriter, y de repente deja de estar claro en qué punto exacto hay que superponer el texto o el PNG.
En este artículo, primero se ordena el panorama general Source Reader -> dibujado -> conversión de color -> Sink Writer, y a continuación se presenta un ejemplo en un único archivo que se puede pegar tal cual en una aplicación de consola en C++ de Visual Studio.
El ejemplo lee el MP4 indicado, dibuja en cada fotograma la imagen indicada junto con el texto HelloWorld, y genera el MP4 de salida.
Además, este ejemplo prioriza poder pegarlo y ejecutarlo tal cual desde el principio, por lo que se ha configurado para volver a codificar únicamente el vídeo. Sería posible incluir también el remux de audio en el mismo programa, pero como el tema central del artículo es “grabar imágenes y texto en cada fotograma”, primero nos centramos en eso.
El código que aparece en este artículo está publicado en GitHub como un conjunto de código de ejemplo (el .cpp de un solo archivo y la configuración de compilación con CMake).
media-foundation-overlay-image-text-on-mp4-frames - komurasoft-blog-samples (GitHub)
Público destinatario y entorno necesario
Este artículo está dirigido a desarrolladores de nivel intermedio que están empezando a escribir procesamiento de vídeo en Windows con C++. Se presupone que ya ha tenido contacto con los fundamentos de COM (ComPtr, HRESULT, conteo de referencias) y que Media Foundation es algo con lo que está a punto de empezar.
El entorno necesario para ejecutar el ejemplo es el siguiente. Los detalles y las condiciones de los datos de entrada están resumidos en el capítulo 5.
| Elemento | Requisito |
|---|---|
| Sistema operativo | Windows 10 / 11 |
| Entorno de desarrollo | Aplicación de consola en C++ de Visual Studio 2022 |
| Configuración de compilación | x64 |
| Encabezado precompilado | Configurar este .cpp para que no lo use |
| Vídeo de entrada | Un MP4 normal. El ancho y el alto deben ser pares (porque NV12 es 4:2:0) |
| Salida | MP4 solo de vídeo. No incluye audio |
Términos que conviene tener claros de antemano
A partir de la tabla del capítulo 3 aparecen términos en inglés sin explicación previa. Se dejan aquí resumidos, uno por línea.
| Término | Significado |
|---|---|
| remux | Consiste en reconstruir únicamente el contenedor, dejando intactos los datos comprimidos internos. Al no volver a codificar, no se pierde calidad de imagen ni de sonido, y el procesamiento resulta ligero |
| topology | Es el grafo con el que Media Foundation representa “de qué componente a qué componente fluyen los datos”. Equivale a un diagrama de configuración que conecta la fuente, las transformaciones y los destinos |
| custom MFT | Es un Media Foundation Transform que se escribe uno mismo. Al implementar IMFTransform, se puede insertar un efecto como una pieza más dentro del pipeline de Media Foundation |
| stride | Es la cantidad de bytes que ocupa una fila de la imagen en memoria. No siempre coincide con ancho × 4; puede haber relleno al final de cada fila |
1. La conclusión, en primer lugar
- El esquema básico para colocar imágenes o texto en cada fotograma de un MP4 es:
decodificar con Source Reader -> componer sobre el fotograma sin comprimir -> convertir el color si hace falta -> volver a codificar con Sink Writer. - El propio proceso de colocar imágenes o texto no es tarea de Media Foundation. Aquí conviene pensar en términos de una API de dibujado como
GDI+,Direct2D,DirectWriteoWIC. - Para volver a
MP4 (H.264), suele hacer falta una etapa de conversión que conecte el formatoRGB32 / ARGB32, fácil de dibujar, con formatos comoNV12 / I420 / YUY2, que el codificador acepta con más facilidad. - Para poner en marcha el primer ejemplo, una configuración clara es
Source Reader -> RGB32 -> dibujado con GDI+ -> NV12 -> Sink Writer. - Si se prioriza la velocidad o la escalabilidad, conviene orientarse hacia
D3D11 / DXGI surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer, que ofrece más margen de crecimiento.
2. Por qué este problema resulta algo confuso
“Poner texto en un vídeo” mezcla, en realidad, estos cuatro temas.
-
El tema del contenedor y el códec
mp4es un contenedor, no un fotograma en sí. Por dentro, normalmente contiene datos comprimidos enH.264oH.265. -
El tema de decodificar y codificar Mientras los datos siguen comprimidos, una API de dibujado 2D normal no puede colocar directamente encima texto o un PNG. Primero hay que volver a un fotograma sin comprimir.
-
El tema del dibujado El texto, el logotipo, la composición con transparencia de un PNG y el dibujado de texto con antialiasing no son responsabilidad del propio Media Foundation. Eso es trabajo de
GDI+o deDirect2D / DirectWrite / WIC. -
El tema del espacio de color y el formato de píxel El formato que resulta fácil de dibujar y el formato que prefiere el codificador no coinciden. Este es el punto en el que, de forma discreta, suele atascarse el trabajo.
Dicho en una frase, la forma más clara de plantearlo es: no se trata de “poner texto con Media Foundation”, sino de “hacer circular los fotogramas con Media Foundation, colocar el contenido con una API de dibujado, aplicar la conversión de color necesaria y luego codificar”.
3. La tabla de referencia inicial
| Enfoque | Configuración | Situación adecuada | Puntos a cuidar |
|---|---|---|---|
| Primero, que funcione correctamente | Source Reader -> RGB32 -> composición -> NV12 -> Sink Writer |
Procesamiento por lotes, herramientas internas, implementación inicial | Tiende a aumentar las copias y conversiones en el lado de la CPU |
| Aumentar la velocidad | D3D11 / DXGI surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer |
Vídeos largos, alta resolución, procesamiento masivo | Aumenta la gestión de D3D11 y DXGI |
| Convertirlo en un componente reutilizable | Implementarlo como un MFT personalizado e insertarlo en la topology |
Efectos que se usan en varias aplicaciones, o cuando se quiere integrar en un pipeline de MF | Aumenta la dificultad de implementación, registro y depuración |
El ejemplo de este artículo se centra exclusivamente en la configuración “primero, que funcione correctamente”, la primera de la tabla.
3.1 Idea general del procesamiento
flowchart LR
accTitle: Idea general del procesamiento para grabar imagen y texto con Media Foundation
accDescr: Diagrama que muestra el flujo desde input.mp4, pasando por IMFSourceReader, el fotograma sin comprimir en RGB32, el dibujado de la imagen y HelloWorld con GDI+, la conversión de BGRA a NV12 y IMFSinkWriter hasta output.mp4, junto con la rama de la muestra de audio que se copia tal cual o se vuelve a codificar antes de llegar también a IMFSinkWriter.
A[input.mp4] --> B[IMFSourceReader]
B --> C[Fotograma sin comprimir<br/>RGB32]
C --> D[Dibujar imagen + HelloWorld con GDI+]
D --> E[Conversión de BGRA a NV12]
E --> F[IMFSinkWriter]
F --> G[output.mp4]
B --> H[Muestra de audio]
H --> I[Copiar tal cual<br/>o volver a codificar]
I --> F
Aquí lo importante es que el dibujado en sí no es tarea de Media Foundation. Media Foundation se encarga de hacer entrar y salir los fotogramas; colocar la imagen y el texto se deja en manos de la API de dibujado.
4. Cómo dividir el pipeline para pensarlo por partes
4.1 Recibir la entrada con IMFSourceReader
Si la entrada es una ruta de archivo, resulta claro usar MFCreateSourceReaderFromURL; si son datos de vídeo en memoria, conviene crear un IMFByteStream y usar MFCreateSourceReaderFromByteStream.
Lo primero que hay que decidir aquí es si recibir el fotograma en un formato fácil de dibujar o en un formato orientado al codificador.
- Si se quiere simplificar la implementación,
RGB32oARGB32 - Si se prioriza la eficiencia de codificación, un formato YUV como
NV12
Sin embargo, como componer texto o un PNG resulta mucho más sencillo con formatos RGB, lo más cómodo es recibir en RGB32 / ARGB32 como primer paso.
Al activar MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, Source Reader se encarga de la conversión YUV -> RGB32 y del desentrelazado.
Esto resulta útil en la etapa en la que “primero se quiere extraer y manejar el fotograma”, pero en vídeos largos o de alta resolución tiende a volverse pesado, así que si en producción se necesita velocidad, vale la pena revisar la configuración más adelante.
4.2 Pensar la composición de imagen y texto con GDI+ o Direct2D / DirectWrite
Se extrae el búfer del IMFSample recibido de Media Foundation y, sobre él, se coloca la imagen del logotipo o el texto.
Este ejemplo prioriza que sea fácil de pegar como un solo archivo completo, así que usa GDI+ para el dibujado.
- Permite cargar imágenes
- Permite dibujar texto
- Requiere relativamente poca preparación adicional
- Es fácil de encajar en un único
.cppde aplicación de consola
Por otro lado, para vídeos largos o el procesamiento masivo en 4K, D3D11 + Direct2D + DirectWrite ofrece más margen de crecimiento.
Lo natural es empezar la primera implementación con GDI+ y pasar a Direct2D / DirectWrite cuando llegue el momento de optimizar la velocidad.
4.3 No siempre se puede escribir a H.264 directamente desde RGB32
Este es el punto donde más suele atascarse el trabajo.
Al volver a MP4 (H.264), el codificador H.264 de Microsoft suele presuponer una entrada de tipo YUV, como I420 / IYUV / NV12 / YUY2 / YV12.
Es decir, no siempre basta con componer en RGB32 / ARGB32, que es fácil de dibujar, y enviarlo tal cual a IMFSinkWriter.
Por eso, la implementación necesita alguna de estas dos conversiones.
- Intercalar un
Video Processor MFTpara convertir deRGB32 / ARGB32aNV12 - Implementar por cuenta propia la conversión de
RGBaNV12
Este ejemplo prioriza completarse en un solo archivo, así que aplica la segunda opción, la conversión propia.
En producción, también es una opción sólida intercalar un Video Processor MFT, que puede encargarse de forma conjunta de la conversión del espacio de color, el cambio de tamaño y el desentrelazado.
4.4 Escribir la salida con IMFSinkWriter
Para la salida de vídeo, IMFSinkWriter resulta cómodo de manejar.
La idea es sencilla:
- Tipo de flujo de salida … el formato que se quiere escribir en el archivo
ejemplo:
MFVideoFormat_H264 - Tipo de flujo de entrada … el formato que la aplicación entrega a
Sink Writerejemplo:MFVideoFormat_NV12
y se configuran por separado.
Es decir, visto desde Sink Writer, la relación es la siguiente:
- La aplicación entrega fotogramas sin comprimir en
NV12 Sink Writerlos codifica en H.264 y los escribe en el MP4
4.5 Al principio, resulta más claro tratar el audio por separado
Es muy habitual querer solo añadir un logotipo o texto al vídeo sin tocar el propio audio.
En la práctica, resulta cómoda una configuración así:
- Solo el stream de vídeo pasa por
Source Reader -> composición -> Sink Writer - El stream de audio se remuxa manteniéndose compressed
No obstante, este ejemplo se centra en grabar imágenes y texto en el fotograma, así que la salida es un MP4 solo de vídeo. La versión que conserva el audio conviene añadirla más adelante, en la etapa de ampliación, para que resulte más fácil seguir el conjunto.
5. Requisitos previos y modo de uso de este ejemplo
Los requisitos previos de este código son los siguientes.
- Windows 10 / 11
- Aplicación de consola en C++ de Visual Studio 2022
- Compilación en
x64 - Este archivo
.cppno usa encabezados precompilados - El ancho y el alto del vídeo de entrada son pares
- La entrada es un archivo de vídeo MP4 normal
- La salida es un MP4 solo de vídeo
- La imagen está en un formato que GDI+ puede leer, como PNG / JPEG / BMP / GIF
Como NV12 es 4:2:0, el ancho y el alto deben ser pares.
Por eso, este ejemplo genera un error explícito cuando no se cumple esa condición.
5.1 Modo de uso
- Crear un Console App en Visual Studio
- Pegar este
.cppcompleto - Configurar ese
.cpppara que use “No usar encabezados precompilados” - Compilar en
x64 - Ejecutarlo así
OverlayMp4.exe input.mp4 overlay.png output.mp4
input.mp4el vídeo originaloverlay.pngla imagen que se quiere superponeroutput.mp4el destino de salida
El texto está fijado como HelloWorld en la constante kOverlayText, al principio del código.
La posición y el tamaño también se pueden cambiar tocando las constantes dentro del código. La modificación para poder pasarlos como argumentos de línea de comandos se explica en 9.5.
5.2 Comprobar que ha funcionado correctamente
Que “haya terminado sin errores” y que “se haya grabado correctamente” son cosas distintas. Si se revisan estos cuatro puntos en orden, se detecta la mayoría de los fallos.
- Observar el número de fotogramas al terminar. Al finalizar el procesamiento, este ejemplo muestra
Done. frames=seguido del número de fotogramas escritos. Si difiere mucho del número total de fotogramas del vídeo de entrada, es que se ha perdido algo en algún punto del bucle deReadSample - Comparar la información básica del archivo de salida con la de entrada. Al hacer clic con el botón derecho sobre el MP4 de salida en el Explorador de archivos y abrir Propiedades > Detalles, aparecen la duración, el ancho de fotograma, el alto de fotograma y la tasa de fotogramas. Si la duración no coincide con la de la entrada, hay que sospechar del manejo del timestamp (7.4)
- Revisar visualmente tres puntos: el principio, la mitad y el final. Si solo se comprueba el primer fotograma y se da por bueno, se puede pasar por alto un fallo en el que la superposición desaparece a mitad de camino. Lo más fiable es extraer una imagen fija del mismo instante tanto de la entrada como de la salida y colocarlas una junto a la otra; ese procedimiento está descrito en “Cómo extraer una imagen fija de un MP4 en un instante concreto con Media Foundation”
- Comprobar que los colores no se vean raros. Si la piel de las personas o el cielo aparecen con un color poco natural, es posible que la selección de coeficientes de
BgraToNv12(BT.601 y BT.709) no coincida con la de la entrada
Para las primeras pruebas, se recomienda usar un MP4 corto, de unos pocos segundos, y un PNG con contornos bien definidos. Si se intenta poner en marcha el primer ejemplo con un vídeo largo, aislar el problema lleva mucho más tiempo.
6. Código en un solo archivo, listo para pegar en el .cpp
6.1 Mapa del código
Antes de nada, aquí está el mapa. El código es largo, pero el núcleo que realmente hay que leer son solo tres funciones —CopySampleToTopDownBgra, DrawOverlay y BgraToNv12— junto con el bucle de wmain. El resto es inicialización y limpieza.
| Función / clase | Papel | Explicación detallada |
|---|---|---|
ScopedMf / ScopedGdiplus |
Empareja con RAII la inicialización y el cierre de MFStartup y de GDI+ |
— |
ConfigureSourceReader |
Configura la salida de Source Reader en RGB32 y obtiene el ancho, el alto, el fps y la frame duration predeterminada |
4.1 |
GetDefaultStride |
Obtiene el stride predeterminado a partir del tipo de medio | 7.2 |
BufferLock |
Bloquea el búfer usando IMF2DBuffer si está disponible, o IMFMediaBuffer en caso contrario |
7.2 |
CopySampleToTopDownBgra |
Absorbe el stride y la orientación vertical, y normaliza el resultado a BGRA top-down | 7.2 |
DrawOverlay |
Dibuja la imagen y el texto con GDI+. Es la única “etapa de dibujado” | 4.2 / 7.1 |
BgraToNv12 |
Convierte a NV12 el BGRA ya dibujado | 4.3 / 7.1 |
CreateNv12Sample |
Envuelve el búfer NV12 en un IMFSample y le añade el timestamp y la duration |
7.4 |
ChooseBitrate |
Decide el bitrate de salida a partir de la información de la entrada | — |
CreateSinkWriter |
Configura el tipo H.264 del lado de salida y el tipo NV12 que se le entrega |
4.4 |
Bucle while de wmain |
Recorre un fotograma a la vez comprobando el HRESULT, los flags y el sample de ReadSample |
7.3 / 7.4 |
Puesto en paralelo con la idea general del procesamiento del capítulo 3, CopySampleToTopDownBgra corresponde al “fotograma sin comprimir”, DrawOverlay al “dibujado con GDI+” y BgraToNv12 a la “conversión de BGRA a NV12”.
6.2 Código completo
#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. Puntos clave para leer esta implementación
7.1 El formato fácil de dibujar y el formato que acepta bien el codificador son distintos
Este ejemplo sigue este flujo:
- Salida de
Source Reader:RGB32 - Dibujado:
GDI+ - Entrada de
Sink Writer:NV12
El motivo es sencillo: para colocar texto o un PNG, los formatos RGB son más fáciles de manejar, y para entregar datos a la codificación H.264, NV12 resulta más manejable.
Al leer la implementación, resulta más fácil de seguir si se separa en la “etapa de dibujado” y la “etapa de preparación previa a la codificación”.
7.2 Primero se absorben el stride y la orientación vertical, y luego se dibuja
Un fotograma de vídeo no siempre está dispuesto en memoria tal como se ve visualmente.
- El stride no siempre coincide con
ancho * 4 - La orientación vertical puede estar invertida
- El manejo difiere ligeramente entre
IMF2DBuffereIMFMediaBuffer
Por eso, este código primero normaliza los datos a un búfer BGRA top-down y luego dibuja sobre él. Si se alinea esto de antemano, el código del lado del dibujado puede quedar bastante más sencillo.
7.3 ReadSample hay que observarlo no solo por su HRESULT, sino también por flags y sample
ReadSample puede devolver S_OK y aun así tener sample == nullptr.
Los casos típicos son:
MF_SOURCE_READERF_STREAMTICKMF_SOURCE_READERF_ENDOFSTREAM- Otros eventos de flujo
Por eso, en el bucle es necesario observar juntos los tres elementos: HRESULT, flags e inputSample.
En particular, si se pasan por alto STREAMTICK y ENDOFSTREAM, el procesamiento de la línea de tiempo posterior tiende a descuadrarse.
7.4 Es más seguro conservar el timestamp y la duration de la entrada
La marca de tiempo se expresa en unidades de 100 ns.
Además, la duration hay que obtenerla por separado desde el IMFSample.
Es más robusto conservar en la medida de lo posible el timestamp y la duration del sample de entrada que sumar cada vez un valor fijo bajo la suposición de un fps constante.
Este ejemplo también recurre, solo cuando no se puede obtener la duration, a un valor predeterminado calculado a partir del fps.
7.5 GDI+ es ligero de introducir, pero para vídeos largos o de alta resolución hay una siguiente etapa
GDI+ resulta bastante adecuado para un ejemplo de un solo archivo, pero para vídeos largos o el procesamiento masivo en 4K, D3D11 + Direct2D + DirectWrite puede resultar más ventajoso.
- Primero, completar todo el flujo con
GDI+ - Después, sustituirlo por
Direct2D / DirectWritecuando haga falta - Desplazar la conversión de color hacia
Video Processor MFTo hacia la GPU
Avanzar de forma progresiva de esta manera permite ampliar sin romper el diseño.
7.6 Este ejemplo se centra únicamente en el vídeo
Si se incluyera también el audio en el mismo artículo, el hilo del contenido tendería a dispersarse. Por eso, este ejemplo se centra en grabar imágenes y texto en el fotograma de vídeo, y la salida es un MP4 solo de vídeo.
En la práctica, como siguiente etapa, resulta manejable ampliar hacia esta configuración:
- Solo el vídeo pasa por
Source Reader -> composición -> Sink Writer - El audio se remuxa manteniéndose compressed
8. Si los “datos de vídeo recibidos” no son un archivo sino una secuencia de bytes MP4 en memoria
Este código usa MFCreateSourceReaderFromURL, así que la entrada es una ruta de archivo.
Sin embargo, si el requisito es “hacer lo mismo con una secuencia de bytes de un mp4 recibida a través de una API”, el planteamiento no cambia. Lo único que cambia es la entrada.
- Preparar un
IStreamo un stream propio - Entregarlo a
Source ReadercomoIMFByteStream - A partir de ahí, el mismo
RGB32 -> dibujado -> NV12 -> Sink Writer
Es decir, lo esencial no está en cómo se posee el dato de vídeo, sino en cómo se dibuja sobre cada fotograma después de decodificarlo.
9. Cómo ampliar esto para producción
9.1 Añadir el remux de audio
La ampliación más práctica como primer paso es conservar el audio tal cual. Si se vuelve a codificar solo el vídeo y el audio se reescribe en el mismo formato manteniéndose compressed, se cumple el requisito sin aumentar demasiado la implementación.
Sink Writer admite explícitamente la combinación de escribir una entrada comprimida en la salida manteniendo el mismo formato, para un remux sin volver a codificar. Hay que añadir estos tres puntos.
- Recibir el stream de audio manteniéndolo comprimido. Se activa con
reader->SetStreamSelection(MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE), y el tipo obtenido conGetNativeMediaTypese pasa tal cual aSetCurrentMediaType. Especificar el tipo nativo cuando no se quiere decodificar es la convención del lado de Source Reader - Configurar en Sink Writer ese mismo tipo tanto en la entrada como en la salida. Se pasa el mismo tipo de medio a
writer->AddStream(audioType.Get(), &audioStreamIndex)y awriter->SetInputMediaType(audioStreamIndex, audioType.Get(), nullptr) - Usar la misma referencia de timestamp que el vídeo. El
firstTimestampque el código de 6.2 usa para el vídeo se resta de la misma manera también en las muestras de audio. Si se usan referencias distintas aquí, el audio y el vídeo quedan desincronizados
La llamada a ReadSample también hay que cambiarla: en lugar de la forma actual, que solo especifica el vídeo, hay que usar MF_SOURCE_READER_ANY_STREAM y repartir según el stream index que devuelve.
Además, Sink Writer no realiza el remuestreo de audio ni el cambio de tamaño o de tasa de fotogramas del vídeo, salvo que el codificador lo proporcione. Si el destino MP4 no puede aceptar el formato de audio de entrada, hará falta volver a codificar en lugar de hacer un remux.
9.2 Intercalar un Video Processor MFT
Este ejemplo prioriza completarse en un solo archivo y convierte BGRA -> NV12 por cuenta propia, pero en producción también resulta bastante sólida una configuración que intercale un Video Processor MFT.
Al usar Video Processor MFT, resulta más fácil manejar de forma conjunta:
- La conversión del espacio de color
- El cambio de tamaño
- El desentrelazado
- La conversión de la tasa de fotogramas
9.3 Sustituir GDI+ por Direct2D / DirectWrite
Para superposiciones como un logotipo, subtítulos o una marca de tiempo, GDI+ resulta suficiente en muchos casos, pero si se quiere optimizar el rendimiento, Direct2D / DirectWrite es más ventajoso.
En particular, si se dan condiciones como:
- Alta resolución
- Vídeos largos
- Grandes volúmenes
- El deseo de orientarse en el futuro hacia una ruta de GPU
entonces entra en consideración una configuración basada en D3D11 / DXGI surface.
9.4 Plantéese un MFT personalizado cuando se convierta en “un efecto de vídeo que se quiere reutilizar”
En Media Foundation, un efecto se puede implementar como IMFTransform.
Por eso, si se quiere reutilizar el mismo procesamiento de superposición en varias aplicaciones o pipelines, un MFT personalizado es una opción limpia.
Sin embargo, como primer ejemplo, dado que:
- Hay que cumplir el contrato de
IMFTransform - Aumenta la gestión de los tipos de medio de entrada y salida
- Sube la dificultad de registro y depuración
en la práctica suele resultar más manejable hacer que funcione correctamente primero con Source Reader + composición + Sink Writer, y separarlo como MFT solo cuando haga falta.
9.5 Convertir el texto en un argumento y poder dibujar japonés
En el ejemplo, el texto está fijado como HelloWorld en kOverlayText. Para casos de uso en los que se graban números de equipo o nombres de operarios, lo primero que se querrá es convertir esto en un argumento. Si se va a dibujar japonés, también hay que cambiar la fuente.
Los cambios se hacen en cuatro lugares.
1. Añadir en el namespace anónimo el nombre de la fuente que se va a usar. El kOverlayText existente se conserva como valor predeterminado.
const wchar_t* kOverlayText = L"HelloWorld"; // Existente. Se usa como valor predeterminado si se omite el argumento
const wchar_t* kFontFamilyName = L"Yu Gothic UI"; // Tipografía que muestra japonés
const wchar_t* kFallbackFontFamilyName = L"Segoe UI"; // Para entornos donde no está la anterior
2. Permitir que DrawOverlay reciba la cadena de texto que se va a dibujar.
void DrawOverlay(
std::vector<BYTE>& bgra,
UINT32 width,
UINT32 height,
Gdiplus::Image& overlayImage,
const std::wstring& overlayText) // Añadido
3. Dentro de DrawOverlay, sustituir la creación de la fuente y la referencia a la cadena. La línea original Gdiplus::Font font(L"Segoe UI", ...) se reemplaza por lo siguiente.
// Si el tipo de letra indicado no está instalado, recurrir al predeterminado
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");
Además, hay que sustituir el kOverlayText que se pasa a MeasureString y a las dos llamadas de DrawString por overlayText.c_str(), en los tres casos. Si no se corrigen los tres puntos, la sombra se queda con el texto antiguo.
4. Recibir el argumento en wmain y pasarlo a DrawOverlay.
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);
A continuación, la llamada dentro del bucle queda así.
DrawOverlay(bgra, videoInfo.width, videoInfo.height, overlayImage, overlayText);
Hay dos precauciones al trabajar con japonés.
- Si se escribe japonés como literal dentro del
.cpp, hay que guardar el código fuente en UTF-8 con BOM, o bien compilar con MSVC añadiendo/utf-8. Si se omite esto, el texto se corrompe. Cuando se pasa desde un argumento de línea de comandos,wmainlo recibe en UTF-16, así que este problema no se presenta - El tipo de letra no siempre está instalado en el entorno. Como en el código anterior, hay que preparar siempre una alternativa de reserva. Si en un entorno sin la fuente se pasa en silencio a otro tipo de letra, resulta difícil rastrear la causa de un desajuste en el diseño
10. Resumen
Al grabar imágenes o texto en todos los fotogramas de un vídeo MP4 con Media Foundation, resulta más claro descomponer el problema en estos cuatro pasos.
- Extraer:
IMFSourceReader - Dibujar:
GDI+oDirect2D / DirectWrite - Convertir a un formato que acepte bien el codificador:
NV12, entre otros - Volver a escribir:
IMFSinkWriter
Y si lo que se busca es “un ejemplo que se pega entero en un .cpp y funciona tal cual”, una configuración como la de este artículo es bastante directa:
Source Reader -> RGB32 -> imagen + HelloWorld con GDI+ -> de BGRA a NV12 -> Sink Writer
Para seguir ampliando esto de cara a producción, conviene pensarlo en este orden, que resulta más resistente:
- Añadir el remux de audio
- Sustituir
GDI+porDirect2D / DirectWrite - Desplazar la conversión a
NV12haciaVideo Processor MFTo hacia la GPU - Avanzar hacia una base con
D3D11 surfacepara vídeos largos y de alta resolución - Separarlo en un
MFTpersonalizado si se necesita reutilización
Si se intenta abarcarlo todo de golpe, la gestión de COM, del stride, del espacio de color y de las superficies llega toda a la vez. Es mucho más cómodo, tanto para el diseño como para la depuración, avanzar primero por etapas separadas y reforzar después solo lo que haga falta.
11. Artículos relacionados
- Introducción a Media Foundation: entender la API desde la perspectiva de COM
- Cómo extraer una imagen fija de un MP4 en un instante concreto con Media Foundation
12. Referencias
- El conjunto de código de ejemplo de este artículo (el
.cppde un solo archivo y la configuración de compilación con CMake) 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
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Cómo convertir YUV a RGB en Media Foundation
Cómo convertir fotogramas YUV a RGB en Media Foundation: conversión automática con Source Reader, conversión manual de NV12/YUY2, stride ...
Cómo extraer una imagen fija de un MP4 en un instante específico con Media Foundation
Extraemos con Source Reader el fotograma más cercano a un instante de un MP4, ajustamos el stride y el alpha de RGB32, y lo guardamos com...
Introducción a Media Foundation - Cómo entender la API desde la perspectiva de COM
Explicamos qué es Media Foundation junto con los términos básicos de su API multimedia en Windows —COM, HRESULT, IMFSourceReader, MFT— en...
Trampas de la memoria compartida y buenas prácticas para producción
Analizamos las trampas de usar memoria compartida en producción y el diseño que reduce la tasa de incidentes: sincronización, visibilidad...
Cómo invocar una DLL nativa de C# Native AOT desde C/C++
Publicar una biblioteca de C# como DLL nativa con Native AOT e invocar sus puntos UnmanagedCallersOnly desde C/C++: casos de uso, patrone...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
Este es un tema directamente relacionado con la implementación de aplicaciones Windows que combinan Media Foundation, GDI+, Direct2D / DirectWrite, conversión de color y salida de vídeo.
Consultoría técnica y revisión de diseño
También resulta útil para planificar cómo escalar una implementación de un solo archivo hacia una configuración lista para producción, y para decidir dónde separar el remux de audio o el uso de GPU.
Preguntas frecuentes
Preguntas habituales en las consultas sobre el tema del artículo.
- ¿Cuál es el flujo básico para grabar imágenes o texto en cada fotograma de un MP4 con Media Foundation?
- El esquema básico es: decodificar con Source Reader, componer sobre el fotograma sin comprimir, convertir el color si hace falta y volver a codificar con Sink Writer. El propio proceso de colocar imágenes o texto no es tarea de Media Foundation, sino de una API de dibujado como GDI+, Direct2D / DirectWrite o WIC. Para poner en marcha el primer ejemplo, una configuración clara es Source Reader -> RGB32 -> dibujado con GDI+ -> NV12 -> Sink Writer; si se prioriza la velocidad o la escalabilidad, conviene orientarse hacia una configuración con D3D11 / DXGI surface junto con Direct2D / DirectWrite, que ofrece más margen de crecimiento.
- ¿Se puede pasar directamente un fotograma en RGB32 a la codificación H.264?
- No siempre es posible. El codificador H.264 de Microsoft suele presuponer una entrada de tipo YUV, como I420, IYUV, NV12, YUY2 o YV12, así que después de componer en RGB32 / ARGB32, que es más fácil de dibujar, suele hacer falta una etapa de conversión. Se puede intercalar un Video Processor MFT para convertir de RGB32 a NV12, o bien implementar la conversión de RGB a NV12 por cuenta propia. Además, como NV12 es 4:2:0, el ancho y el alto del fotograma deben ser números pares.
- ¿Conviene usar GDI+ o Direct2D para dibujar la superposición?
- Para una primera implementación, GDI+ es adecuado para un ejemplo de un solo archivo, porque permite cargar imágenes y dibujar texto sin demasiada preparación adicional. En cambio, para vídeos largos, 4K o procesamiento masivo, la combinación D3D11 + Direct2D + DirectWrite puede resultar más ventajosa en cuanto a rendimiento. Un enfoque progresivo que no rompe el diseño consiste en completar primero todo el flujo con GDI+, sustituirlo por Direct2D / DirectWrite cuando llegue el momento de optimizar la velocidad, y desplazar la conversión de color hacia Video Processor MFT o hacia la GPU.
- ¿Qué hay que tener en cuenta al usar ReadSample de IMFSourceReader?
- ReadSample puede devolver S_OK y aun así entregar un sample en nullptr. Los casos típicos son eventos de flujo como MF_SOURCE_READERF_STREAMTICK o MF_SOURCE_READERF_ENDOFSTREAM. Por eso, en el bucle es necesario comprobar juntos el HRESULT, los flags y el sample. Además, la marca de tiempo se expresa en unidades de 100 ns, y es más robusto conservar en la medida de lo posible el timestamp y la duration del sample de entrada que calcular la duración sumando un valor fijo bajo la suposición de fps constante.
Perfil del autor
Página de presentación del autor del artículo.
Go Komura
Representante de KomuraSoft LLC
Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.