Comment incruster une image et du texte dans les images d'une vidéo MP4 avec Media Foundation

· Mis à jour le: · · Media Foundation, C++, Développement Windows, GDI+, Direct2D, DirectWrite, H.264

Filigrane de logo, résultat d’inspection, numéro d’équipement, nom de l’opérateur, horodatage. L’exigence de créer un nouveau MP4 en incrustant ce type d’informations dans toutes les images d’une vidéo MP4 est assez courante dans la surveillance, l’inspection, la traçabilité et les interfaces d’analyse.

Cependant, dès qu’on commence à manipuler Media Foundation, on se retrouve face à IMFSourceReader, IMFSample, IMFMediaBuffer, IMFTransform, IMFSinkWriter, et il devient soudain difficile de voir à quel endroit, en fin de compte, superposer le texte ou le PNG.

Dans cet article, nous commençons par clarifier la vue d’ensemble — Source Reader -> dessin -> conversion de couleur -> Sink Writer — puis nous présentons un exemple tenant en un seul fichier, que vous pouvez coller directement dans une application console C++ de Visual Studio. L’exemple lit le MP4 indiqué, dessine l’image indiquée ainsi que le texte HelloWorld sur chaque image, et produit un MP4 de sortie.

Notez que cet exemple privilégie le fait de pouvoir le coller et l’exécuter tel quel, et qu’il adopte donc une configuration qui ne réencode que la vidéo. On pourrait aussi intégrer le remuxage audio dans le même programme, mais comme le sujet de cet article est « incruster une image et du texte dans chaque image », nous nous concentrons d’abord sur ce point.

Le code présenté dans cet article est publié sur GitHub sous la forme d’un ensemble complet d’exemples (un .cpp autonome en un seul fichier, ainsi qu’une configuration de build CMake).

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

1. La conclusion d’abord

  • Le schéma de base pour placer une image ou du texte dans chaque image d’un MP4 est décoder avec le Source Reader -> composer sur les images non compressées -> convertir la couleur si nécessaire -> réencoder avec le Sink Writer.
  • Le placement de l’image ou du texte en lui-même n’est pas le travail de Media Foundation. Il est plus naturel d’y réfléchir avec une API de dessin comme GDI+, Direct2D, DirectWrite ou WIC.
  • Pour revenir à MP4(H.264), une étape de conversion reliant le RGB32 / ARGB32 facile à dessiner et le NV12 / I420 / YUY2 que l’encodeur accepte facilement devient souvent nécessaire.
  • Pour faire fonctionner une première version, la configuration Source Reader -> RGB32 -> dessin avec GDI+ -> NV12 -> Sink Writer est facile à suivre.
  • Pour privilégier la vitesse et l’extensibilité, évoluer vers D3D11 / DXGI surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer offre plus de marge de progression.

2. Pourquoi ce problème est un peu compliqué

« Mettre du texte dans une vidéo » mélange en réalité les quatre sujets suivants.

  1. La question du conteneur et du codec mp4 est un conteneur, pas les images elles-mêmes. Le contenu est le plus souvent des données compressées en H.264 ou H.265.

  2. La question du décodage / encodage Tant que les données restent compressées, on ne peut pas simplement superposer du texte ou un PNG avec une API de dessin 2D ordinaire. Il faut d’abord revenir à des images non compressées.

  3. La question du dessin Le texte, les logos, la composition avec transparence d’un PNG et le rendu de texte anticrénelé ne relèvent pas de Media Foundation lui-même. C’est le travail de GDI+ ou de Direct2D / DirectWrite / WIC.

  4. La question de l’espace colorimétrique et du format de pixels Le format facile à dessiner et le format que préfère l’encodeur ne coïncident pas. C’est le point où l’on se retrouve discrètement bloqué.

En résumé en une phrase : plutôt que de penser « mettre du texte avec Media Foundation », il est plus clair de considérer que « Media Foundation fait circuler les images, une API de dessin les superpose, puis on applique la conversion de couleur nécessaire avant d’encoder ».

3. Le tableau récapitulatif à regarder en premier

Approche Configuration Cas adaptés Points d’attention
Faire d’abord fonctionner correctement Source Reader -> RGB32 -> composition -> NV12 -> Sink Writer Traitement par lots, outils internes, implémentation initiale Les copies et conversions côté CPU tendent à s’accumuler
Augmenter la vitesse D3D11 / DXGI surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer Vidéos longues, haute résolution, traitement en masse La gestion de D3D11 et DXGI s’alourdit
En faire un composant réutilisable Implémenter en tant que MFT personnalisé et l’insérer dans une topology Effet utilisé par plusieurs applications, intégration dans un pipeline MF La difficulté d’implémentation, d’enregistrement et de débogage augmente

L’exemple de cet article se limite à la première ligne, la configuration « faire d’abord fonctionner correctement ».

3.1 Vue d’ensemble du traitement

input.mp4IMFSourceReaderImage non compresséeRGB32Dessiner l'image + HelloWorld avec GDI+Conversion BGRA -> NV12IMFSinkWriteroutput.mp4Échantillons audioCopier tel quelou réencoder

Ce qui compte ici, c’est que le dessin en lui-même n’est pas le travail de Media Foundation. Media Foundation est chargé de faire entrer et sortir les images ; le placement de l’image et du texte est délégué à une API de dessin.

4. Comment décomposer le pipeline

4.1 Recevoir l’entrée avec IMFSourceReader

Si l’entrée est un chemin de fichier, utilisez MFCreateSourceReaderFromURL ; si ce sont des données vidéo en mémoire, il est plus clair de créer un IMFByteStream et d’utiliser MFCreateSourceReaderFromByteStream.

Ce qu’il faut décider en premier ici, c’est s’il faut recevoir les images dans un format facile à dessiner, ou dans un format destiné à l’encodeur.

  • Pour simplifier l’implémentation, RGB32 ou ARGB32
  • Pour privilégier l’efficacité d’encodage, un format YUV tel que NV12

Cela dit, la composition de texte ou de PNG est nettement plus simple à raisonner avec une famille RGB, donc recevoir les images en RGB32 / ARGB32 dès le départ est le choix le plus pratique.

Si vous activez MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, le Source Reader effectue la conversion YUV -> RGB32 ainsi que le désentrelacement pour vous. C’est pratique au stade où l’on veut « d’abord extraire les images et les manipuler », mais cela tend à devenir coûteux avec des vidéos longues ou en haute résolution ; si vous avez besoin de vitesse en production, cela vaut la peine de revoir la configuration plus tard.

4.2 Penser la composition de l’image et du texte avec GDI+ ou Direct2D / DirectWrite

On extrait le tampon de l’IMFSample reçu de Media Foundation, puis on y place l’image du logo et le texte.

L’exemple de cet article privilégie le fait de tenir en un seul fichier facile à coller, et utilise donc GDI+ pour le dessin.

  • Il permet de charger des images
  • Il permet de dessiner du texte
  • Il demande relativement peu de préparation supplémentaire
  • Il s’intègre facilement dans un seul .cpp d’application console

En revanche, pour les usages traitant en masse des vidéos longues ou de la 4K, D3D11 + Direct2D + DirectWrite offre davantage de marge de progression. Il est naturel d’utiliser GDI+ pour la première implémentation, puis de migrer vers Direct2D / DirectWrite au moment où il faut optimiser la vitesse.

4.3 On ne peut pas nécessairement écrire directement du RGB32 en H.264

C’est le point où l’on bute le plus souvent.

Lorsqu’on revient à MP4(H.264), l’encodeur H.264 de Microsoft suppose le plus souvent une entrée de type YUV telle que I420 / IYUV / NV12 / YUY2 / YV12. Autrement dit, composer dans le RGB32 / ARGB32 facile à dessiner puis l’envoyer directement à IMFSinkWriter ne fonctionne pas forcément tel quel.

L’implémentation nécessite donc l’une des deux conversions suivantes.

  • Insérer un Video Processor MFT pour convertir RGB32 / ARGB32 -> NV12
  • Implémenter soi-même la conversion RGB -> NV12

L’exemple de cet article privilégie le fait de tenir en un seul fichier, et adopte donc la seconde option, la conversion faite maison. En production, une configuration insérant un Video Processor MFT, capable de gérer ensemble la conversion d’espace colorimétrique, le redimensionnement et le désentrelacement, est aussi une option solide.

4.4 Écrire la sortie avec IMFSinkWriter

Pour la sortie vidéo, IMFSinkWriter est le plus facile à utiliser.

Le principe est simple :

  • Le type de flux de sortie … le format que l’on veut écrire dans le fichier Exemple : MFVideoFormat_H264
  • Le type de flux d’entrée … le format que l’application transmet au Sink Writer Exemple : MFVideoFormat_NV12

se configurent séparément.

Autrement dit, du point de vue du Sink Writer :

  • l’application transmet des images non compressées en NV12
  • le Sink Writer les encode en H.264 et les écrit dans le MP4

c’est la relation qui s’établit.

4.5 Traiter l’audio séparément dès le départ simplifie les choses

Il est très fréquent de vouloir seulement ajouter un logo ou du texte à la vidéo, sans toucher à l’audio lui-même.

En pratique, la configuration suivante

  • le flux vidéo seul : Source Reader -> composition -> Sink Writer
  • le flux audio : remuxé en restant compressé

est facile à utiliser.

Cependant, comme l’exemple de cet article se concentre sur l’incrustation d’une image et d’un texte dans les images, la sortie est un MP4 uniquement vidéo. Une version conservant l’audio sera plus facile à suivre si on l’ajoute plus tard, à l’étape d’extension.

5. Prérequis et utilisation de cet exemple

Les prérequis de ce code sont les suivants.

  • Windows 10 / 11
  • Une application console C++ Visual Studio 2022
  • Build x64
  • Ce fichier .cpp n’utilise pas d’en-têtes précompilés
  • La largeur et la hauteur de la vidéo d’entrée sont paires
  • L’entrée est un fichier vidéo MP4 ordinaire
  • La sortie est un MP4 uniquement vidéo
  • L’image est dans un format que GDI+ peut lire, tel que PNG / JPEG / BMP / GIF

NV12 étant en 4:2:0, la largeur et la hauteur doivent être paires. C’est pourquoi cet exemple lève explicitement une erreur lorsque cette condition n’est pas remplie.

5.1 Utilisation

  1. Créer une Console App dans Visual Studio
  2. Coller ce .cpp en entier
  3. Régler ce fichier .cpp sur « Ne pas utiliser » les en-têtes précompilés
  4. Compiler en x64
  5. L’exécuter comme suit
OverlayMp4.exe input.mp4 overlay.png output.mp4
  • input.mp4 La vidéo source
  • overlay.png L’image à superposer
  • output.mp4 La destination de sortie

La chaîne de caractères est fixée à HelloWorld dans kOverlayText, en haut du code. La position et la taille peuvent aussi être modifiées en ajustant les constantes du code.

6. Code autonome en un seul fichier, à coller directement dans un .cpp

#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. Points à retenir en lisant cette implémentation

7.1 Le format facile à dessiner et le format accepté par l’encodeur sont différents

Cet exemple suit le flux suivant.

  • Sortie du Source Reader : RGB32
  • Dessin : GDI+
  • Entrée du Sink Writer : NV12

La raison est simple : les formats RGB sont faciles à utiliser pour superposer du texte ou un PNG, et NV12 est facile à transmettre à l’encodage H.264.

En lisant l’implémentation, il devient plus facile de suivre si l’on distingue « l’étape de dessin » et « l’étape de mise en forme avant l’encodage ».

7.2 Le stride et l’orientation verticale sont normalisés avant le dessin

Les images vidéo ne sont pas nécessairement disposées en mémoire de la même façon qu’elles apparaissent à l’écran.

  • Le stride peut ne pas correspondre à width * 4
  • L’orientation verticale peut être inversée
  • IMF2DBuffer et IMFMediaBuffer se manipulent de façon légèrement différente

C’est pourquoi ce code normalise d’abord les données vers un tampon BGRA orienté de haut en bas avant de dessiner. Régler ce point en amont permet de garder le code de dessin nettement plus simple.

7.3 Avec ReadSample, vérifier les flags et le sample, pas seulement le HRESULT

ReadSample peut retourner S_OK alors que sample == nullptr. Les cas typiques sont :

  • MF_SOURCE_READERF_STREAMTICK
  • MF_SOURCE_READERF_ENDOFSTREAM
  • d’autres événements de flux

La boucle doit donc examiner ensemble ces trois éléments : le HRESULT, les flags et l’inputSample. En particulier, si l’on néglige STREAMTICK et ENDOFSTREAM, le traitement de la timeline en aval a tendance à se dérégler.

7.4 Il est plus sûr de reprendre le timestamp et la duration de l’entrée

L’horodatage est exprimé en unités de 100 ns. De plus, la duration doit être récupérée séparément depuis l’IMFSample.

Plutôt que d’ajouter à chaque fois une valeur fixe calculée sur l’hypothèse d’un fps constant, il est plus robuste de reprendre autant que possible le timestamp / la duration du sample d’entrée. Cet exemple aussi ne se rabat sur une valeur par défaut calculée à partir du fps que lorsque la duration ne peut pas être obtenue.

7.5 GDI+ est léger à mettre en place, mais il y a une étape suivante pour les vidéos longues ou en haute résolution

GDI+ convient très bien à un exemple tenant en un seul fichier, mais pour les usages traitant en masse des vidéos longues ou de la 4K, D3D11 + Direct2D + DirectWrite peut avoir l’avantage.

  • D’abord faire fonctionner l’ensemble avec GDI+
  • Ensuite, remplacer par Direct2D / DirectWrite si nécessaire
  • Déplacer la conversion de couleur vers un Video Processor MFT ou le GPU

Une progression par étapes de ce type permet d’étendre le système sans casser la conception.

7.6 Cet exemple se limite à la vidéo

Si l’on entassait aussi l’audio dans le même article, le fil du propos risquerait de se disperser. C’est pourquoi cet exemple se concentre sur l’incrustation d’une image et d’un texte dans les images vidéo, et la sortie est un MP4 uniquement vidéo.

En pratique, l’étape suivante consisterait à évoluer vers

  • vidéo seule : Source Reader -> composition -> Sink Writer
  • audio : remuxé en restant compressé

ce qui est une configuration facile à gérer.

8. Si les « données vidéo fournies » sont une suite d’octets MP4 en mémoire plutôt qu’un fichier

Le code de cet article utilise MFCreateSourceReaderFromURL, donc l’entrée est un chemin de fichier.

Cependant, si l’exigence est « faire la même chose sur une suite d’octets mp4 reçue d’une API », le raisonnement ne change pas. Seul le point d’entrée change.

  • Préparer un IStream ou un flux personnalisé
  • Le transmettre au Source Reader sous la forme d’un IMFByteStream
  • Ensuite, c’est le même schéma : RGB32 -> dessin -> NV12 -> Sink Writer

L’essentiel réside donc, non pas dans la façon dont les données vidéo sont conservées, mais dans la manière de dessiner sur chaque image après décodage.

9. Pour faire évoluer vers la production

9.1 Ajouter le remuxage audio

L’extension la plus pratique à envisager en premier est de conserver l’audio tel quel. En ne réencodant que la vidéo et en réécrivant l’audio dans le même format tout en restant compressé, on répond à l’exigence sans alourdir considérablement l’implémentation.

9.2 Insérer un Video Processor MFT

L’exemple de cet article privilégie le fait de tenir en un seul fichier et convertit BGRA -> NV12 par ses propres moyens, mais en production, une configuration insérant un Video Processor MFT est aussi une option assez solide.

Avec Video Processor MFT, il devient plus facile de gérer ensemble :

  • la conversion d’espace colorimétrique
  • le redimensionnement
  • le désentrelacement
  • la conversion de fréquence d’images

9.3 Remplacer GDI+ par Direct2D / DirectWrite

Pour des incrustations comme une image de logo, des sous-titres ou un horodatage, GDI+ suffit souvent, mais si l’on veut optimiser la performance, Direct2D / DirectWrite a l’avantage.

En particulier, si l’on se trouve dans des conditions telles que

  • haute résolution
  • longue durée
  • grand nombre de vidéos
  • volonté future de basculer vers un chemin GPU

alors une configuration utilisant D3D11 / DXGI surface entre en ligne de compte.

9.4 Envisager un MFT personnalisé lorsque cela devient « un effet vidéo que l’on veut réutiliser »

Dans Media Foundation, un effet peut être implémenté sous la forme d’un IMFTransform. Si l’on souhaite donc réutiliser le même traitement d’incrustation dans plusieurs applications ou pipelines, un MFT personnalisé est un choix élégant.

Cependant, pour une première implémentation,

  • il faut satisfaire le contrat IMFTransform
  • la gestion des types de média en entrée et en sortie s’alourdit
  • la difficulté d’enregistrement et de débogage augmente

c’est pourquoi, en pratique, il est souvent plus simple de d’abord faire fonctionner correctement Source Reader + composition + Sink Writer, puis d’extraire un MFT seulement quand cela devient nécessaire.

10. Résumé

Lorsqu’on incruste une image ou du texte dans toutes les images d’une vidéo MP4 avec Media Foundation, décomposer le problème en ces quatre points permet d’y voir plus clair.

  • Extraire : IMFSourceReader
  • Dessiner : GDI+ ou Direct2D / DirectWrite
  • Convertir vers un format accepté par l’encodeur : NV12, etc.
  • Réécrire : IMFSinkWriter

Et si l’on veut « un exemple que l’on colle entièrement dans un seul .cpp et qui fonctionne tel quel », une configuration comme celle de cet article,

Source Reader -> RGB32 -> image + HelloWorld avec GDI+ -> BGRA vers NV12 -> Sink Writer

est tout à fait naturelle.

Si l’on veut ensuite faire évoluer cela pour la production, réfléchir dans cet ordre évite que les choses ne se déstructurent.

  1. Ajouter le remuxage audio
  2. Remplacer GDI+ par Direct2D / DirectWrite
  3. Déplacer la conversion NV12 vers un Video Processor MFT ou le GPU
  4. Passer à une base D3D11 surface pour les contenus longs et en haute résolution
  5. Extraire un MFT personnalisé si la réutilisabilité est nécessaire

Si l’on tente de tout faire d’un coup, COM, le stride, l’espace colorimétrique et la gestion des surfaces arrivent tous en même temps. Faire fonctionner les choses étape par étape d’abord, puis ne renforcer que les points nécessaires ensuite, rend la conception comme le débogage nettement plus faciles.

11. Articles connexes

12. Références

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Quel est le flux de base pour incruster une image ou du texte dans chaque image d'un MP4 avec Media Foundation ?
Le schéma de base est : « décoder avec le Source Reader -> composer sur les images non compressées -> convertir la couleur si nécessaire -> réencoder avec le Sink Writer ». Le placement de l'image ou du texte en lui-même n'est pas le travail de Media Foundation, mais celui d'une API de dessin comme GDI+, Direct2D/DirectWrite ou WIC. Pour faire fonctionner une première version, la configuration Source Reader -> RGB32 -> dessin avec GDI+ -> NV12 -> Sink Writer est facile à suivre ; si vous privilégiez la vitesse et l'extensibilité, vous gagnez en marge de manœuvre en évoluant vers une configuration utilisant une surface D3D11/DXGI avec Direct2D/DirectWrite.
Peut-on transmettre directement une image RGB32 à l'encodeur H.264 ?
Pas nécessairement. L'encodeur H.264 de Microsoft suppose le plus souvent une entrée de type YUV telle que I420/IYUV/NV12/YUY2/YV12, si bien qu'après avoir composé l'image dans le RGB32/ARGB32 facile à dessiner, une étape de conversion devient généralement nécessaire. On insère alors un Video Processor MFT pour convertir de RGB32 vers NV12, ou l'on implémente soi-même la conversion RGB vers NV12. De plus, comme NV12 est en 4:2:0, la largeur et la hauteur de l'image doivent être paires.
Faut-il utiliser GDI+ ou Direct2D pour dessiner l'incrustation ?
Pour une première implémentation, GDI+, qui permet de charger des images et de dessiner du texte avec peu de préparation supplémentaire, convient bien à un exemple tenant en un seul fichier. En revanche, pour les vidéos longues, la 4K ou le traitement en masse, D3D11 + Direct2D + DirectWrite peut avoir l'avantage en performance. Une progression par étapes — faire fonctionner l'ensemble avec GDI+ d'abord, puis passer à Direct2D/DirectWrite quand il faut gagner en vitesse, et déplacer la conversion de couleur vers un Video Processor MFT ou le GPU — permet d'étendre le système sans casser la conception.
À quoi faut-il faire attention en utilisant ReadSample de IMFSourceReader ?
ReadSample peut retourner S_OK alors que sample vaut nullptr. Les cas typiques sont les événements de flux tels que MF_SOURCE_READERF_STREAMTICK ou MF_SOURCE_READERF_ENDOFSTREAM. C'est pourquoi la boucle doit vérifier ensemble les trois éléments : le HRESULT, les flags et le sample. Par ailleurs, l'horodatage est exprimé en unités de 100 ns, et il est plus robuste de reprendre autant que possible le timestamp et la duration de l'échantillon d'entrée plutôt que d'ajouter une valeur fixe calculée à partir d'un fps supposé constant.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog