Cómo grabar imágenes y texto en los fotogramas de un MP4 con Media Foundation

· Actualizado el: · · 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, DirectWrite o WIC.
  • Para volver a MP4 (H.264), suele hacer falta una etapa de conversión que conecte el formato RGB32 / ARGB32, fácil de dibujar, con formatos como NV12 / 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.

  1. El tema del contenedor y el códec mp4 es un contenedor, no un fotograma en sí. Por dentro, normalmente contiene datos comprimidos en H.264 o H.265.

  2. 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.

  3. 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 de Direct2D / DirectWrite / WIC.

  4. 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

Idea general del procesamiento para grabar imagen y texto con Media FoundationDiagrama 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.input.mp4IMFSourceReaderFotograma sin comprimirRGB32Dibujar imagen + HelloWorld con GDI+Conversión de BGRA a NV12IMFSinkWriteroutput.mp4Muestra de audioCopiar tal cualo volver a codificar

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, RGB32 o ARGB32
  • 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 .cpp de 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 MFT para convertir de RGB32 / ARGB32 a NV12
  • Implementar por cuenta propia la conversión de RGB a NV12

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 Writer ejemplo: 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 Writer los 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 .cpp no 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

  1. Crear un Console App en Visual Studio
  2. Pegar este .cpp completo
  3. Configurar ese .cpp para que use “No usar encabezados precompilados”
  4. Compilar en x64
  5. Ejecutarlo así
OverlayMp4.exe input.mp4 overlay.png output.mp4
  • input.mp4 el vídeo original
  • overlay.png la imagen que se quiere superponer
  • output.mp4 el 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.

  1. 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 de ReadSample
  2. 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)
  3. 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
  4. 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, &currentType), "GetCurrentMediaType(video)");

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

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

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

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

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

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

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

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

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

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

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

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

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

        BufferLock lock(buffer.Get());

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

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

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

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

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

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

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

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

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

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

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

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

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

        graphics.DrawImage(&overlayImage, imageRect);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        return sample;
    }
}

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

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

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

        ScopedMf mf;
        ScopedGdiplus gdiplus;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

                ++frameCount;
            }

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

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

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

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

7. 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 IMF2DBuffer e IMFMediaBuffer

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_STREAMTICK
  • MF_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 / DirectWrite cuando haga falta
  • Desplazar la conversión de color hacia Video Processor MFT o 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 IStream o un stream propio
  • Entregarlo a Source Reader como IMFByteStream
  • 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.

  1. Recibir el stream de audio manteniéndolo comprimido. Se activa con reader->SetStreamSelection(MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE), y el tipo obtenido con GetNativeMediaType se pasa tal cual a SetCurrentMediaType. Especificar el tipo nativo cuando no se quiere decodificar es la convención del lado de Source Reader
  2. 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 a writer->SetInputMediaType(audioStreamIndex, audioType.Get(), nullptr)
  3. Usar la misma referencia de timestamp que el vídeo. El firstTimestamp que 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, wmain lo 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+ o Direct2D / 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:

  1. Añadir el remux de audio
  2. Sustituir GDI+ por Direct2D / DirectWrite
  3. Desplazar la conversión a NV12 hacia Video Processor MFT o hacia la GPU
  4. Avanzar hacia una base con D3D11 surface para vídeos largos y de alta resolución
  5. Separarlo en un MFT personalizado 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

12. Referencias

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

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.

Volver al blog