Как встроить изображение и текст в кадры MP4 с помощью Media Foundation
· Го Комура · Media Foundation, C++, Разработка Windows, GDI+, Direct2D, DirectWrite, H.264
Водяной знак логотипа, результаты проверки, номер оборудования, имя оператора, метка времени. Требование встроить такую информацию во все кадры MP4-видео и получить новый MP4 довольно обычно для систем видеонаблюдения, инспекционного контроля, ведения журналов и аналитических интерфейсов.
Но стоит только начать работать с Media Foundation, как перед вами выстраиваются IMFSourceReader, IMFSample, IMFMediaBuffer, IMFTransform, IMFSinkWriter — и внезапно становится совершенно непонятно, в каком именно месте нужно накладывать текст или PNG.
В этой статье мы сначала разберём общую картину — Source Reader -> отрисовка -> преобразование цвета -> Sink Writer, — а затем приведём однофайловый пример, который можно сразу вставить в консольное C++-приложение Visual Studio.
Пример читает заданный MP4, рисует на каждом кадре заданное изображение и текст HelloWorld, после чего создаёт выходной MP4.
При этом пример в первую очередь рассчитан на то, чтобы его можно было сразу вставить и запустить, поэтому используется конфигурация, при которой повторно кодируется только видео. Remux аудио тоже можно уместить в одну программу, но поскольку тема статьи — «встраивание изображения и текста в каждый кадр», сначала мы сосредоточимся именно на этом.
Код из этой статьи опубликован на GitHub в виде полного набора примера (однофайловый .cpp и конфигурация сборки CMake).
media-foundation-overlay-image-text-on-mp4-frames - komurasoft-blog-samples (GitHub)
1. Сначала — коротко о главном
- Базовая схема встраивания изображения или текста в каждый кадр MP4 — это
декодирование через Source Reader -> композитинг поверх несжатых кадров -> при необходимости преобразование цвета -> повторное кодирование через Sink Writer. - Сама операция размещения изображения или текста — не задача Media Foundation. Здесь логичнее рассуждать в терминах API отрисовки:
GDI+,Direct2D,DirectWrite,WIC. - Если результат нужно вернуть в
MP4 (H.264), обычно требуется этап преобразования между удобным для отрисовкиRGB32 / ARGB32и удобным для кодировщикаNV12 / I420 / YUY2. - Чтобы быстро получить первую рабочую версию, понятнее всего конфигурация
Source Reader -> RGB32 -> отрисовка через GDI+ -> NV12 -> Sink Writer. - Если в приоритете скорость и расширяемость, есть смысл двигаться в сторону
D3D11 / DXGI surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer.
2. Почему эта задача немного запутана
«Встроить текст в видео» на самом деле объединяет четыре разных вопроса.
-
Вопрос контейнера и кодека
mp4— это контейнер, а не сами кадры. Внутри чаще всего лежат сжатые данныеH.264илиH.265. -
Вопрос декодирования / кодирования Пока данные остаются сжатыми, обычный 2D API отрисовки не может просто наложить на них текст или PNG. Сначала нужно вернуться к несжатым кадрам.
-
Вопрос отрисовки Текст, логотипы, прозрачное наложение PNG, сглаженный текст — всё это не входит в зону ответственности самой Media Foundation. Это задача
GDI+или связкиDirect2D / DirectWrite / WIC. -
Вопрос цветового пространства и формата пикселей Формат, удобный для отрисовки, и формат, который предпочитает кодировщик, не совпадают. Именно здесь незаметно возникают затруднения.
Если сформулировать одной фразой, удобнее думать не как «встраивание текста средствами Media Foundation», а так: «Media Foundation прогоняет кадры, API отрисовки накладывает содержимое, а перед кодированием при необходимости выполняется преобразование цвета».
3. Сводная таблица подходов
| Подход | Конфигурация | Когда подходит | На что обратить внимание |
|---|---|---|---|
| Сначала заставить работать правильно | Source Reader -> RGB32 -> композитинг -> NV12 -> Sink Writer |
Пакетная обработка, внутренние инструменты, первая реализация | Копирования и преобразования на стороне CPU легко накапливаются |
| Повысить скорость | D3D11 / DXGI surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer |
Длинные видео, высокое разрешение, массовая обработка | Растёт объём управления D3D11 и DXGI |
| Сделать переиспользуемым компонентом | Реализовать как custom MFT и встроить в topology |
Эффект, используемый в нескольких приложениях, или встраивание в конвейер MF | Возрастает сложность реализации, регистрации и отладки |
Пример из этой статьи ограничен верхней строкой таблицы — конфигурацией «сначала заставить работать правильно».
3.1 Общая схема обработки
flowchart LR
A[input.mp4] --> B[IMFSourceReader]
B --> C[Несжатый кадр<br/>RGB32]
C --> D[Отрисовка изображения + HelloWorld через GDI+]
D --> E[Преобразование BGRA -> NV12]
E --> F[IMFSinkWriter]
F --> G[output.mp4]
B --> H[Аудиосэмплы]
H --> I[Копирование как есть<br/>или повторное кодирование]
I --> F
Здесь важно, что сама отрисовка — не задача Media Foundation. Media Foundation отвечает за подачу и вывод кадров, а размещение изображения и текста делегируется API отрисовки.
4. Как разложить конвейер обработки
4.1 Приём входных данных через IMFSourceReader
Если на входе путь к файлу, понятнее всего использовать MFCreateSourceReaderFromURL; если видеоданные лежат в памяти — создать IMFByteStream и использовать MFCreateSourceReaderFromByteStream.
Первое, что нужно решить здесь, — принимать кадры в формате, удобном для отрисовки, или в формате, ориентированном на кодировщик.
- Если нужна простая реализация —
RGB32илиARGB32 - Если в приоритете эффективность кодирования — YUV-формат вроде
NV12
Тем не менее композитинг текста и PNG значительно проще рассуждать в RGB-форматах, поэтому проще всего для начала принимать кадры в RGB32 / ARGB32.
Если включить MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, Source Reader сам выполнит преобразование YUV -> RGB32 и деинтерлейсинг.
Это удобно на этапе «просто хочу получить кадры и начать с ними работать», но для длинных или видео высокого разрешения такой режим может оказаться тяжёлым, так что если в продакшене важна скорость, конфигурацию впоследствии стоит пересмотреть.
4.2 Наложение изображения и текста — задача для GDI+ или Direct2D / DirectWrite
Из полученного от Media Foundation IMFSample извлекается буфер, и поверх него накладывается изображение логотипа и текст.
Пример из этой статьи в первую очередь рассчитан на то, чтобы легко умещаться в один файл, поэтому для отрисовки используется GDI+.
- Умеет загружать изображения
- Умеет рисовать текст
- Требует сравнительно немного дополнительной подготовки
- Легко умещается в единственный
.cppконсольного приложения
С другой стороны, для длинных видео и массовой обработки 4K-контента больше возможностей даёт D3D11 + Direct2D + DirectWrite.
Естественный путь — начать с GDI+, а на этапе, когда потребуется скорость, перейти на Direct2D / DirectWrite.
4.3 RGB32 не всегда можно напрямую записать в H.264
Это самое частое место, где всё застревает.
При обратной записи в MP4 (H.264) кодировщик H.264 от Microsoft обычно рассчитан на вход семейства YUV — I420 / IYUV / NV12 / YUY2 / YV12.
То есть скомпоновать кадр в удобном для отрисовки RGB32 / ARGB32 и просто отправить его в IMFSinkWriter — вариант, который не гарантированно сработает.
Поэтому на практике нужно одно из двух преобразований.
- Вставить
Video Processor MFT, выполняющийRGB32 / ARGB32 -> NV12 - Реализовать собственное преобразование
RGB -> NV12
Пример из этой статьи в первую очередь рассчитан на однофайловость, поэтому используется второй вариант — собственное преобразование.
В продакшене также хорошим вариантом может быть вставка Video Processor MFT, который позволяет за один проход обработать преобразование цветового пространства, изменение размера и деинтерлейсинг.
4.4 Запись вывода через IMFSinkWriter
Для вывода видео удобнее всего IMFSinkWriter.
Идея простая:
- Тип выходного потока — формат, который нужно записать в файл
Пример:
MFVideoFormat_H264 - Тип входного потока — формат, который приложение передаёт
Sink WriterПример:MFVideoFormat_NV12
Эти два параметра настраиваются раздельно.
То есть с точки зрения Sink Writer:
- приложение передаёт несжатые кадры в формате
NV12 Sink Writerкодирует их в H.264 и записывает в MP4
— именно такое разделение ролей.
4.5 Аудио проще сначала рассматривать отдельно
Довольно часто требуется только добавить логотип или текст на видео, а само аудио менять не нужно.
На практике удобна такая конфигурация:
- поток видео:
Source Reader -> композитинг -> Sink Writer - поток аудио: remux без изменений, в сжатом виде
Однако пример из этой статьи сфокусирован именно на встраивании изображения и текста в кадры, поэтому на выходе получается MP4 только с видео. Версию с сохранением аудио проще добавить позже, на этапе расширения, — так общий ход изложения легче удержать в голове.
5. Предпосылки и использование этого примера
Предпосылки для этого кода следующие.
- Windows 10 / 11
- Консольное C++-приложение Visual Studio 2022
- Сборка
x64 - Этот файл
.cppне использует предкомпилированные заголовки - Ширина и высота входного видео чётные
- На входе — обычный файл MP4-видео
- На выходе — MP4 только с видео
- Изображение в формате, который умеет читать GDI+: PNG / JPEG / BMP / GIF и так далее
NV12 имеет формат 4:2:0, поэтому ширина и высота должны быть чётными.
Поэтому в этом примере при несоблюдении условия явно генерируется ошибка.
5.1 Использование
- Создайте в Visual Studio Console App
- Вставьте этот
.cppцеликом - Для этого
.cppотключите использование предкомпилированных заголовков — переключатель «Не использовать» - Соберите под
x64 - Запустите следующим образом
OverlayMp4.exe input.mp4 overlay.png output.mp4
input.mp4Исходное видеоoverlay.pngИзображение, которое нужно наложитьoutput.mp4Путь для сохранения результата
Текстовая строка задана в начале кода константой kOverlayText и равна HelloWorld.
Положение и размер также можно изменить, отредактировав константы в коде.
6. Однофайловый код, который можно вставить прямо в .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, ¤tType), "GetCurrentMediaType(video)");
VideoFormatInfo info;
ThrowIfFailed(MFGetAttributeSize(currentType.Get(), MF_MT_FRAME_SIZE, &info.width, &info.height), "Get video frame size");
HRESULT hr = MFGetAttributeRatio(currentType.Get(), MF_MT_FRAME_RATE, &info.fpsNum, &info.fpsDen);
if (FAILED(hr))
{
ThrowIfFailed(MFGetAttributeRatio(nativeType.Get(), MF_MT_FRAME_RATE, &info.fpsNum, &info.fpsDen), "Get video frame rate");
}
if (info.fpsNum == 0 || info.fpsDen == 0)
{
throw std::runtime_error("Video frame rate is zero.");
}
hr = MFGetAttributeRatio(currentType.Get(), MF_MT_PIXEL_ASPECT_RATIO, &info.parNum, &info.parDen);
if (FAILED(hr) || info.parNum == 0 || info.parDen == 0)
{
info.parNum = 1;
info.parDen = 1;
}
info.sourceStride = GetDefaultStride(currentType.Get());
info.defaultFrameDuration = (10000000LL * info.fpsDen) / info.fpsNum;
if (info.defaultFrameDuration <= 0)
{
throw std::runtime_error("Calculated frame duration is invalid.");
}
info.bitrate = ChooseBitrate(nativeType.Get(), info.width, info.height, info.fpsNum, info.fpsDen);
return info;
}
ComPtr<IMFSinkWriter> CreateSinkWriter(const std::wstring& outputPath, const VideoFormatInfo& videoInfo, DWORD* streamIndex)
{
if (streamIndex == nullptr)
{
throw std::runtime_error("streamIndex is null.");
}
ComPtr<IMFAttributes> attributes;
ThrowIfFailed(MFCreateAttributes(&attributes, 1), "MFCreateAttributes(sink)");
ThrowIfFailed(attributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE), "SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS)");
ComPtr<IMFSinkWriter> writer;
ThrowIfFailed(MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, attributes.Get(), &writer), "MFCreateSinkWriterFromURL");
ComPtr<IMFMediaType> outputType;
ThrowIfFailed(MFCreateMediaType(&outputType), "MFCreateMediaType(video output)");
ThrowIfFailed(outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "SetGUID(output major)");
ThrowIfFailed(outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264), "SetGUID(output subtype H264)");
ThrowIfFailed(outputType->SetUINT32(MF_MT_AVG_BITRATE, videoInfo.bitrate), "SetUINT32(output bitrate)");
ThrowIfFailed(outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive), "SetUINT32(output interlace)");
ThrowIfFailed(MFSetAttributeSize(outputType.Get(), MF_MT_FRAME_SIZE, videoInfo.width, videoInfo.height), "MFSetAttributeSize(output frame size)");
ThrowIfFailed(MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, videoInfo.fpsNum, videoInfo.fpsDen), "MFSetAttributeRatio(output fps)");
ThrowIfFailed(MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, videoInfo.parNum, videoInfo.parDen), "MFSetAttributeRatio(output PAR)");
ThrowIfFailed(writer->AddStream(outputType.Get(), streamIndex), "AddStream(video)");
ComPtr<IMFMediaType> inputType;
ThrowIfFailed(MFCreateMediaType(&inputType), "MFCreateMediaType(video input)");
ThrowIfFailed(inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "SetGUID(input major)");
ThrowIfFailed(inputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_NV12), "SetGUID(input subtype NV12)");
ThrowIfFailed(inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive), "SetUINT32(input interlace)");
ThrowIfFailed(MFSetAttributeSize(inputType.Get(), MF_MT_FRAME_SIZE, videoInfo.width, videoInfo.height), "MFSetAttributeSize(input frame size)");
ThrowIfFailed(MFSetAttributeRatio(inputType.Get(), MF_MT_FRAME_RATE, videoInfo.fpsNum, videoInfo.fpsDen), "MFSetAttributeRatio(input fps)");
ThrowIfFailed(MFSetAttributeRatio(inputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, videoInfo.parNum, videoInfo.parDen), "MFSetAttributeRatio(input PAR)");
ThrowIfFailed(writer->SetInputMediaType(*streamIndex, inputType.Get(), nullptr), "SetInputMediaType(video)");
ThrowIfFailed(writer->BeginWriting(), "BeginWriting");
return writer;
}
void CopySampleToTopDownBgra(IMFSample* sample, const VideoFormatInfo& videoInfo, std::vector<BYTE>& bgra)
{
ComPtr<IMFMediaBuffer> buffer;
ThrowIfFailed(sample->ConvertToContiguousBuffer(&buffer), "ConvertToContiguousBuffer");
BufferLock lock(buffer.Get());
BYTE* scanline0 = nullptr;
LONG actualStride = 0;
ThrowIfFailed(lock.LockBuffer(videoInfo.sourceStride, videoInfo.height, &scanline0, &actualStride), "LockBuffer");
const size_t dstStride = static_cast<size_t>(videoInfo.width) * 4;
bgra.resize(dstStride * videoInfo.height);
for (UINT32 y = 0; y < videoInfo.height; ++y)
{
const BYTE* srcRow = scanline0 + static_cast<LONG>(y) * actualStride;
BYTE* dstRow = bgra.data() + static_cast<size_t>(y) * dstStride;
std::memcpy(dstRow, srcRow, dstStride);
for (UINT32 x = 0; x < videoInfo.width; ++x)
{
dstRow[static_cast<size_t>(x) * 4 + 3] = 0xFF;
}
}
}
void DrawOverlay(std::vector<BYTE>& bgra, UINT32 width, UINT32 height, Gdiplus::Image& overlayImage)
{
const INT stride = static_cast<INT>(width * 4);
Gdiplus::Bitmap frameBitmap(
static_cast<INT>(width),
static_cast<INT>(height),
stride,
PixelFormat32bppPARGB,
bgra.data());
ThrowIfGdiplusError(frameBitmap.GetLastStatus(), "Create frame bitmap");
Gdiplus::Graphics graphics(&frameBitmap);
ThrowIfGdiplusError(graphics.GetLastStatus(), "Create graphics");
graphics.SetCompositingMode(Gdiplus::CompositingModeSourceOver);
graphics.SetCompositingQuality(Gdiplus::CompositingQualityHighQuality);
graphics.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic);
graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit);
const Gdiplus::REAL margin = std::max<Gdiplus::REAL>(16.0f, static_cast<Gdiplus::REAL>(height) * kMarginRatio);
const Gdiplus::REAL maxImageW = static_cast<Gdiplus::REAL>(width) * kImageMaxWidthRatio;
const Gdiplus::REAL maxImageH = static_cast<Gdiplus::REAL>(height) * kImageMaxHeightRatio;
const Gdiplus::REAL srcW = static_cast<Gdiplus::REAL>(overlayImage.GetWidth());
const Gdiplus::REAL srcH = static_cast<Gdiplus::REAL>(overlayImage.GetHeight());
if (srcW <= 0.0f || srcH <= 0.0f)
{
throw std::runtime_error("Overlay image has invalid size.");
}
const Gdiplus::REAL imageScale =
std::min<Gdiplus::REAL>(1.0f, std::min(maxImageW / srcW, maxImageH / srcH));
const Gdiplus::REAL drawW = srcW * imageScale;
const Gdiplus::REAL drawH = srcH * imageScale;
Gdiplus::RectF imageRect(margin, margin, drawW, drawH);
Gdiplus::SolidBrush imagePlate(Gdiplus::Color(96, 0, 0, 0));
graphics.FillRectangle(
&imagePlate,
imageRect.X - 8.0f,
imageRect.Y - 8.0f,
imageRect.Width + 16.0f,
imageRect.Height + 16.0f);
graphics.DrawImage(&overlayImage, imageRect);
const Gdiplus::REAL fontPx =
std::max<Gdiplus::REAL>(kMinFontPx, static_cast<Gdiplus::REAL>(height) * 0.06f);
Gdiplus::Font font(L"Segoe UI", fontPx, Gdiplus::FontStyleBold, Gdiplus::UnitPixel);
ThrowIfGdiplusError(font.GetLastStatus(), "Create font");
Gdiplus::StringFormat stringFormat;
stringFormat.SetAlignment(Gdiplus::StringAlignmentNear);
stringFormat.SetLineAlignment(Gdiplus::StringAlignmentNear);
Gdiplus::RectF measureLayout(
margin,
static_cast<Gdiplus::REAL>(height) - margin - fontPx * 2.0f,
static_cast<Gdiplus::REAL>(width) - margin * 2.0f,
fontPx * 2.0f);
Gdiplus::RectF measured;
graphics.MeasureString(kOverlayText, -1, &font, measureLayout, &stringFormat, &measured);
Gdiplus::RectF textBg(
measured.X - 12.0f,
measured.Y - 8.0f,
measured.Width + 24.0f,
measured.Height + 16.0f);
Gdiplus::SolidBrush textPlate(Gdiplus::Color(128, 0, 0, 0));
graphics.FillRectangle(&textPlate, textBg);
Gdiplus::SolidBrush shadowBrush(Gdiplus::Color(220, 0, 0, 0));
Gdiplus::RectF shadowLayout = measureLayout;
shadowLayout.X += 2.0f;
shadowLayout.Y += 2.0f;
graphics.DrawString(kOverlayText, -1, &font, shadowLayout, &stringFormat, &shadowBrush);
Gdiplus::SolidBrush textBrush(Gdiplus::Color(235, 255, 255, 255));
graphics.DrawString(kOverlayText, -1, &font, measureLayout, &stringFormat, &textBrush);
}
void BgraToNv12(const BYTE* bgra, UINT32 width, UINT32 height, BYTE* nv12)
{
const bool useBt709 = (width > 1024 || height > 576);
const int yR = useBt709 ? 47 : 66;
const int yG = useBt709 ? 157 : 129;
const int yB = useBt709 ? 16 : 25;
const int uR = useBt709 ? -26 : -38;
const int uG = useBt709 ? -87 : -74;
const int uB = 112;
const int vR = 112;
const int vG = useBt709 ? -102 : -94;
const int vB = useBt709 ? -10 : -18;
BYTE* yPlane = nv12;
BYTE* uvPlane = nv12 + static_cast<size_t>(width) * height;
const size_t srcStride = static_cast<size_t>(width) * 4;
for (UINT32 y = 0; y < height; ++y)
{
const BYTE* srcRow = bgra + static_cast<size_t>(y) * srcStride;
BYTE* dstY = yPlane + static_cast<size_t>(y) * width;
for (UINT32 x = 0; x < width; ++x)
{
const BYTE b = srcRow[x * 4 + 0];
const BYTE g = srcRow[x * 4 + 1];
const BYTE r = srcRow[x * 4 + 2];
const int Y = ((yR * r + yG * g + yB * b + 128) >> 8) + 16;
dstY[x] = ClampToByte(Y);
}
}
for (UINT32 y = 0; y < height; y += 2)
{
const BYTE* row0 = bgra + static_cast<size_t>(y) * srcStride;
const BYTE* row1 = bgra + static_cast<size_t>(y + 1) * srcStride;
BYTE* dstUV = uvPlane + static_cast<size_t>(y / 2) * width;
for (UINT32 x = 0; x < width; x += 2)
{
int b = 0;
int g = 0;
int r = 0;
for (UINT32 dy = 0; dy < 2; ++dy)
{
const BYTE* row = (dy == 0) ? row0 : row1;
for (UINT32 dx = 0; dx < 2; ++dx)
{
const UINT32 ix = x + dx;
b += row[ix * 4 + 0];
g += row[ix * 4 + 1];
r += row[ix * 4 + 2];
}
}
b = (b + 2) / 4;
g = (g + 2) / 4;
r = (r + 2) / 4;
const int U = ((uR * r + uG * g + uB * b + 128) >> 8) + 128;
const int V = ((vR * r + vG * g + vB * b + 128) >> 8) + 128;
dstUV[x + 0] = ClampToByte(U);
dstUV[x + 1] = ClampToByte(V);
}
}
}
ComPtr<IMFSample> CreateNv12Sample(
const std::vector<BYTE>& bgra,
const VideoFormatInfo& videoInfo,
LONGLONG sampleTime,
LONGLONG sampleDuration)
{
const DWORD bufferSize =
static_cast<DWORD>(videoInfo.width * videoInfo.height * 3 / 2);
ComPtr<IMFMediaBuffer> buffer;
ThrowIfFailed(MFCreateMemoryBuffer(bufferSize, &buffer), "MFCreateMemoryBuffer");
BYTE* dst = nullptr;
DWORD maxLength = 0;
DWORD currentLength = 0;
ThrowIfFailed(buffer->Lock(&dst, &maxLength, ¤tLength), "Lock(NV12 buffer)");
try
{
BgraToNv12(bgra.data(), videoInfo.width, videoInfo.height, dst);
}
catch (...)
{
buffer->Unlock();
throw;
}
ThrowIfFailed(buffer->Unlock(), "Unlock(NV12 buffer)");
ThrowIfFailed(buffer->SetCurrentLength(bufferSize), "SetCurrentLength(NV12 buffer)");
ComPtr<IMFSample> sample;
ThrowIfFailed(MFCreateSample(&sample), "MFCreateSample");
ThrowIfFailed(sample->AddBuffer(buffer.Get()), "AddBuffer(output sample)");
ThrowIfFailed(sample->SetSampleTime(sampleTime), "SetSampleTime");
ThrowIfFailed(sample->SetSampleDuration(sampleDuration), "SetSampleDuration");
return sample;
}
}
int wmain(int argc, wchar_t* argv[])
{
if (argc != 4)
{
std::wcerr << L"Usage: OverlayMp4.exe <input.mp4> <overlayImage.png> <output.mp4>" << std::endl;
return 1;
}
const std::wstring inputPath = argv[1];
const std::wstring imagePath = argv[2];
const std::wstring outputPath = argv[3];
try
{
if (_wcsicmp(inputPath.c_str(), outputPath.c_str()) == 0)
{
throw std::runtime_error("Input and output paths must be different.");
}
ScopedMf mf;
ScopedGdiplus gdiplus;
ComPtr<IMFAttributes> readerAttributes;
ThrowIfFailed(MFCreateAttributes(&readerAttributes, 1), "MFCreateAttributes(reader)");
ThrowIfFailed(
readerAttributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE),
"SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING)");
ComPtr<IMFSourceReader> reader;
ThrowIfFailed(
MFCreateSourceReaderFromURL(inputPath.c_str(), readerAttributes.Get(), &reader),
"MFCreateSourceReaderFromURL");
VideoFormatInfo videoInfo = ConfigureSourceReader(reader.Get());
if ((videoInfo.width % 2) != 0 || (videoInfo.height % 2) != 0)
{
throw std::runtime_error(
"This sample requires even video width and height because NV12 is 4:2:0.");
}
Gdiplus::Image overlayImage(imagePath.c_str());
ThrowIfGdiplusError(overlayImage.GetLastStatus(), "Load overlay image");
DWORD videoStreamIndex = 0;
ComPtr<IMFSinkWriter> writer =
CreateSinkWriter(outputPath, videoInfo, &videoStreamIndex);
std::vector<BYTE> bgra;
LONGLONG firstTimestamp = -1;
unsigned long long frameCount = 0;
while (true)
{
DWORD flags = 0;
LONGLONG timestamp = 0;
ComPtr<IMFSample> inputSample;
ThrowIfFailed(
reader->ReadSample(
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
0,
nullptr,
&flags,
×tamp,
&inputSample),
"ReadSample(video)");
if ((flags & MF_SOURCE_READERF_CURRENTMEDIATYPECHANGED) != 0)
{
throw std::runtime_error("Dynamic video format change is not supported in this sample.");
}
if ((flags & MF_SOURCE_READERF_NATIVEMEDIATYPECHANGED) != 0)
{
throw std::runtime_error("Native video format change is not supported in this sample.");
}
if ((flags & MF_SOURCE_READERF_STREAMTICK) != 0)
{
if (firstTimestamp < 0)
{
firstTimestamp = timestamp;
}
ThrowIfFailed(
writer->SendStreamTick(videoStreamIndex, timestamp - firstTimestamp),
"SendStreamTick");
}
if (inputSample)
{
if (firstTimestamp < 0)
{
firstTimestamp = timestamp;
}
LONGLONG duration = 0;
if (FAILED(inputSample->GetSampleDuration(&duration)) || duration <= 0)
{
duration = videoInfo.defaultFrameDuration;
}
CopySampleToTopDownBgra(inputSample.Get(), videoInfo, bgra);
DrawOverlay(bgra, videoInfo.width, videoInfo.height, overlayImage);
ComPtr<IMFSample> outputSample =
CreateNv12Sample(bgra, videoInfo, timestamp - firstTimestamp, duration);
ThrowIfFailed(
writer->WriteSample(videoStreamIndex, outputSample.Get()),
"WriteSample(video)");
++frameCount;
}
if ((flags & MF_SOURCE_READERF_ENDOFSTREAM) != 0)
{
break;
}
}
ThrowIfFailed(writer->Finalize(), "Finalize");
std::wcout
<< L"Done. frames=" << frameCount
<< L", output=" << outputPath
<< std::endl;
return 0;
}
catch (const std::exception& ex)
{
std::cerr << ex.what() << std::endl;
return 1;
}
}
7. На что обратить внимание при чтении этой реализации
7.1 Формат, удобный для отрисовки, и формат, удобный для кодировщика, — это разные вещи
В этом примере используется такой поток:
- Вывод
Source Reader:RGB32 - Отрисовка:
GDI+ - Вход
Sink Writer:NV12
Причина проста: для наложения текста и PNG удобнее RGB-форматы, а для передачи в кодировщик H.264 удобнее NV12.
При чтении реализации проще следить за кодом, если мысленно разделить его на «этап отрисовки» и «этап подготовки к кодированию».
7.2 Stride и направление кадра по вертикали нормализуются перед отрисовкой
Кадры видео не обязательно расположены в памяти так же, как выглядят визуально.
- Stride может не совпадать с
width * 4 - Кадр может храниться перевёрнутым по вертикали
IMF2DBufferиIMFMediaBufferобрабатываются немного по-разному
Поэтому в этом коде кадр сначала нормализуется в буфер BGRA с прямым порядком строк (top-down), а уже потом на нём выполняется отрисовка. Если привести этот момент к единому виду заранее, код отрисовки получается заметно проще.
7.3 При использовании ReadSample нужно проверять не только HRESULT, но и flags вместе с sample
ReadSample может вернуть S_OK, но при этом sample == nullptr.
Типичные случаи:
MF_SOURCE_READERF_STREAMTICKMF_SOURCE_READERF_ENDOFSTREAM- другие потоковые события
Поэтому в цикле нужно смотреть сразу на три значения: HRESULT, flags и inputSample.
В частности, если пропустить STREAMTICK или ENDOFSTREAM, дальнейшая обработка временной шкалы легко ломается.
7.4 Timestamp и duration безопаснее наследовать из входных данных
Метка времени задаётся в единицах по 100 нс.
Кроме того, duration нужно получать отдельно из IMFSample.
Вместо того чтобы каждый раз прибавлять фиксированное значение исходя из предполагаемого fps, надёжнее по возможности наследовать timestamp / duration входного sample.
В этом примере тоже используется такой подход: значение по умолчанию, вычисленное из fps, применяется только тогда, когда duration получить не удалось.
7.5 GDI+ легко внедрить, но для длинных и высокого разрешения видео есть следующий этап
GDI+ отлично подходит для однофайлового примера, но для длинных видео и массовой обработки 4K-контента выигрышнее может оказаться D3D11 + Direct2D + DirectWrite.
- Сначала прогнать весь конвейер целиком на
GDI+ - Затем, по мере необходимости, заменить его на
Direct2D / DirectWrite - Преобразование цвета перенести на
Video Processor MFTили GPU
Такое поэтапное движение позволяет расширять решение, не разрушая архитектуру.
7.6 Этот пример сфокусирован только на видео
Если добавить в ту же статью ещё и полноценную работу с аудио, основная мысль легко расплывается. Поэтому этот пример сосредоточен на встраивании изображения и текста в видеокадры, а на выходе получается MP4 только с видео.
На практике логично развить его дальше — до конфигурации:
- видео:
Source Reader -> композитинг -> Sink Writer - аудио: remux без изменений, в сжатом виде
— с ней удобно работать.
8. Если «переданные видеоданные» — это не файл, а байты MP4 в памяти
В этой статье код использует MFCreateSourceReaderFromURL, поэтому на входе — путь к файлу.
Но если требование звучит как «нужно сделать то же самое с байтами mp4, полученными через API», сама логика не меняется. Меняется только точка входа.
- Подготовить
IStreamили собственный поток - Передать его в
Source ReaderкакIMFByteStream - Дальше всё то же самое:
RGB32 -> отрисовка -> NV12 -> Sink Writer
Иначе говоря, суть заключается не в том, как хранятся видеоданные, а в том, как отрисовывать содержимое поверх каждого декодированного кадра.
9. Как развивать решение для продакшена
9.1 Добавить remux аудио
Самое практичное первое расширение — сохранить аудио без изменений. Если повторно кодировать только видео, а аудио записывать обратно в том же формате без перекодирования, требование выполняется без значительного роста объёма реализации.
9.2 Вставить Video Processor MFT
В этом примере преобразование BGRA -> NV12 реализовано вручную ради однофайловости, но в продакшене вставка Video Processor MFT — тоже весьма сильный вариант.
С Video Processor MFT становится проще обрабатывать за один раз:
- преобразование цветового пространства
- изменение размера
- деинтерлейсинг
- преобразование частоты кадров
9.3 Заменить GDI+ на Direct2D / DirectWrite
Для таких наложений, как логотип, субтитры или метка времени, GDI+ во многих случаях вполне достаточно, но если нужно выжать максимум производительности, преимущество будет за Direct2D / DirectWrite.
В частности, при наличии таких условий, как
- высокое разрешение
- большая длительность
- большой объём обрабатываемых видео
- планы перейти на GPU-путь в будущем,
в поле зрения попадает конфигурация на основе D3D11 / DXGI surface.
9.4 Custom MFT стоит рассматривать, когда речь заходит о «видеоэффекте, который хочется переиспользовать»
В Media Foundation эффект можно реализовать как IMFTransform.
Поэтому если одну и ту же логику наложения нужно использовать в нескольких приложениях или конвейерах, custom MFT — изящный выбор.
Однако для первой реализации стоит учитывать, что:
- нужно удовлетворить контракт
IMFTransform - растёт объём управления входными и выходными типами медиа
- усложняются регистрация и отладка,
поэтому на практике зачастую проще сначала заставить всё работать правильно на связке Source Reader + композитинг + Sink Writer, а выделить MFT уже тогда, когда в этом реально возникнет потребность.
10. Итоги
Когда нужно встроить изображение или текст во все кадры MP4-видео с помощью Media Foundation, картина становится понятнее, если разложить задачу на четыре части.
- Извлечение:
IMFSourceReader - Отрисовка:
GDI+илиDirect2D / DirectWrite - Приведение к формату, удобному для кодировщика:
NV12и так далее - Запись обратно:
IMFSinkWriter
И если нужен именно «пример, который целиком вставляется в один .cpp и сразу работает», конфигурация вроде той, что использована в этой статье,
Source Reader -> RGB32 -> изображение + HelloWorld через GDI+ -> BGRA в NV12 -> Sink Writer
выглядит вполне естественно.
Если затем развивать решение для продакшена, стоит двигаться в таком порядке — так конструкция не разваливается.
- Добавить remux аудио
- Заменить
GDI+наDirect2D / DirectWrite - Перенести преобразование
NV12наVideo Processor MFTили GPU - Для длинных видео высокого разрешения перейти на конфигурацию на основе
D3D11 surface - При необходимости переиспользования выделить логику в custom
MFT
Если попытаться сделать всё и сразу, на вас разом навалятся COM, stride, цветовые пространства и управление поверхностями. Гораздо проще и в проектировании, и в отладке — сначала прогнать решение по этапам, а усиливать только те части, которые действительно в этом нуждаются.
11. Связанные статьи
- Что такое Media Foundation — почему в нём проступает лицо COM и Windows Media API
- Как извлечь статичное изображение из MP4-видео в заданный момент времени с помощью Media Foundation — однофайловая версия, которую можно вставить прямо в .cpp
12. Источники
- Полный набор примера кода для этой статьи (однофайловый
.cppи конфигурация сборки CMake) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/media-foundation-overlay-image-text-on-mp4-frames - Microsoft Learn: Using the Source Reader to Process Media Data
- Microsoft Learn: MFCreateSourceReaderFromByteStream
- Microsoft Learn: MFCreateMFByteStreamOnStream
- Microsoft Learn: IMFSourceReader::SetCurrentMediaType
- Microsoft Learn: MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING
- Microsoft Learn: MF_SOURCE_READER_ENABLE_ADVANCED_VIDEO_PROCESSING
- Microsoft Learn: IMFSourceReader::ReadSample
- Microsoft Learn: Working with Media Samples
- Microsoft Learn: IMF2DBuffer::Lock2D
- Microsoft Learn: Video Subtype GUIDs
- Microsoft Learn: H.264 Video Encoder
- Microsoft Learn: Video Processor MFT
- Microsoft Learn: Using the Sink Writer
- Microsoft Learn: Tutorial: Using the Sink Writer to Encode Video
- Microsoft Learn: Interoperability Overview (Direct2D)
- Microsoft Learn: Text Rendering with Direct2D and DirectWrite
- Microsoft Learn: Writing a Custom MFT
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Как преобразовать YUV в RGB с помощью Media Foundation
Разбираем способы преобразования YUV-кадров в RGB в Media Foundation: автоматическое преобразование через Source Reader и самостоятельное...
Как извлечь статичное изображение из MP4 по заданному времени с помощью Media Foundation
Как с помощью Source Reader извлечь из MP4 кадр, ближайший к заданному времени, привести в порядок stride и alpha-байт RGB32 и сохранить ...
Введение в Media Foundation — понимание API через призму COM
Разбираем, что такое Media Foundation, вместе с базовой терминологией Windows-медиа-API — COM, HRESULT, IMFSourceReader, MFT — в том поря...
Разделяемая память: подводные камни и практические рекомендации
Разбираем подводные камни практического использования разделяемой памяти и проектирование, снижающее частоту сбоев: синхронизация, видимо...
Как вызвать C# Native AOT DLL из C/C++
Разбираем, как публиковать библиотеку классов C# в виде нативной DLL через Native AOT и вызывать точки входа UnmanagedCallersOnly из C/C+...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Тема напрямую связана с реализацией Windows-приложений, объединяющих Media Foundation, GDI+, Direct2D / DirectWrite, преобразование цвета и вывод видео.
Технические консультации и ревью дизайна
Подходит и для обсуждения того, как развить однофайловую реализацию до конфигурации промышленного уровня и где провести границу для remux аудио или переноса вычислений на GPU.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Каков базовый порядок действий, чтобы встроить изображение или текст в каждый кадр MP4 с помощью Media Foundation?
- Базовая схема такая: «декодирование через Source Reader -> композитинг поверх несжатых кадров -> при необходимости преобразование цвета -> повторное кодирование через Sink Writer». Сама операция размещения изображения или текста — это не задача Media Foundation, а задача API отрисовки, таких как GDI+, Direct2D/DirectWrite или WIC. Для первой рабочей версии понятнее всего конфигурация Source Reader -> RGB32 -> отрисовка через GDI+ -> NV12 -> Sink Writer, а если в приоритете скорость и расширяемость, есть смысл двигаться в сторону D3D11/DXGI surface и Direct2D/DirectWrite.
- Можно ли передавать кадры в формате RGB32 напрямую в кодировщик H.264?
- Не факт, что можно. Кодировщик H.264 от Microsoft обычно рассчитан на вход в форматах семейства YUV — I420/IYUV/NV12/YUY2/YV12, поэтому после композитинга в удобном для отрисовки RGB32/ARGB32 чаще всего требуется этап преобразования. Либо вставляется Video Processor MFT, преобразующий RGB32 в NV12, либо реализуется собственное преобразование RGB в NV12. Кроме того, NV12 имеет формат 4:2:0, поэтому ширина и высота кадра должны быть чётными.
- Что лучше использовать для отрисовки наложений — GDI+ или Direct2D?
- Для первой реализации GDI+ хорошо подходит для однофайлового примера, поскольку позволяет загружать изображения и рисовать текст практически без дополнительной подготовки. В то же время для длинных видео, 4K и массовой обработки D3D11 + Direct2D + DirectWrite может дать выигрыш в производительности. Разумный путь развития — сначала прогнать всё целиком на GDI+, а на этапе оптимизации скорости перейти на Direct2D/DirectWrite и перенести преобразование цвета на Video Processor MFT или GPU: такое поэтапное движение не ломает архитектуру и легко расширяется.
- На что обратить внимание при использовании IMFSourceReader::ReadSample?
- ReadSample может вернуть S_OK, но при этом sample окажется nullptr. Типичные случаи — потоковые события вроде MF_SOURCE_READERF_STREAMTICK и MF_SOURCE_READERF_ENDOFSTREAM. Поэтому в цикле чтения нужно проверять сразу три вещи: HRESULT, flags и sample. Кроме того, метка времени задаётся в единицах по 100 нс, и надёжнее по возможности наследовать timestamp и duration из входного sample, а не прибавлять фиксированное значение исходя из предполагаемого fps.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.
Публичные ссылки