كيفيّة دمج الصورة والنصّ في كلّ إطار من فيديو MP4 باستخدام Media Foundation - ترتيب Source Reader والرسم وتحويل الألوان وSink Writer مع نسخة بملفّ .cpp واحد قابل للّصق مباشرةً

· · Media Foundation, C++, تطوير Windows, GDI+, Direct2D, DirectWrite, H.264

العلامة المائيّة للشعار، نتائج الفحص، رقم الجهاز، اسم المُشغّل، الـ timestamp.
متطلّب إنتاج MP4 جديد بعد دمج هذه المعلومات في جميع إطارات فيديو MP4 متطلّب طبيعيّ جدّاً في واجهات المراقبة والفحص والتتبّع والتحليل.

لكن بمجرّد أن تبدأ بلمس Media Foundation، تتراصّ أمامك واجهات IMFSourceReader وIMFSample وIMFMediaBuffer وIMFTransform وIMFSinkWriter، فيصبح فجأةً من الصعب رؤية أين بالضبط تضع النصّ أو ملفّ PNG.

في هذا المقال، نرتّب أوّلاً الصورة الكاملة المتمثّلة في Source Reader -> الرسم -> تحويل الألوان -> Sink Writer، ثمّ نعرض بعد ذلك عيّنةً بملفّ واحد كامل يمكن لصقها مباشرةً في تطبيق وحدة تحكّم C++ بـ Visual Studio.
العيّنة تقرأ ملفّ MP4 محدّداً، وترسم على كلّ إطار صورةً محدّدة وعبارة HelloWorld، وتنتج ملفّ MP4 ناتجاً.

ولأنّ الأولويّة هي إمكانيّة لصق الكود وتشغيله مباشرةً، فإنّ هذه العيّنة تعيد ترميز الفيديو فقط.
يمكن إدراج remux الصوت في نفس الملفّ، لكنّ موضوع المقال هو «دمج الصورة والنصّ في كلّ إطار»، لذلك نركّز أوّلاً على ذلك.

1. الخلاصة أوّلاً

  • الشكل الأساسيّ لإدخال صورة أو نصّ في كلّ إطار من MP4 هو فكّ التشفير عبر Source Reader -> دمج على الإطار غير المضغوط -> تحويل الألوان عند الحاجة -> إعادة الترميز عبر Sink Writer.
  • عمليّة وضع الصورة أو النصّ بحدّ ذاتها ليست من مهامّ Media Foundation. هذا الجزء من الأنسب التفكير فيه عبر واجهات الرسم مثل GDI+ وDirect2D وDirectWrite وWIC.
  • إن كنت ستعود إلى MP4(H.264)، فإنّ مرحلة تحويل تربط بين RGB32 / ARGB32 السهل للرسم وNV12 / I420 / YUY2 الذي يتقبّله الـ encoder بسهولة كثيراً ما تكون ضروريّة.
  • لتشغيل أوّل نسخة عمليّة، فإنّ بنية Source Reader -> RGB32 -> الرسم بـ GDI+ -> NV12 -> Sink Writer بنية واضحة.
  • إن كانت الأولويّة للسرعة وقابليّة التوسّع، فإنّ التحوّل نحو D3D11 / DXGI surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer يفتح آفاقاً للنموّ.

2. لماذا هذه المسألة معقّدة قليلاً

«إدخال نصّ في الفيديو» هو في الواقع مزيج من أربع قصص مختلفة.

  1. قصّة الحاوية والـ codec
    mp4 هو حاوية وليس الإطار نفسه. محتواه في الغالب بيانات مضغوطة بـ H.264 أو H.265.

  2. قصّة فكّ التشفير / الترميز
    لا يمكن وضع نصّ أو PNG مباشرةً عبر واجهات الرسم ثنائيّة الأبعاد العاديّة على البيانات المضغوطة. يجب أوّلاً العودة إلى إطار غير مضغوط.

  3. قصّة الرسم
    النصوص والشعارات ودمج PNG الشفّاف ورسم النصّ بـ anti-aliasing ليست من اختصاص Media Foundation نفسها. هذا الجزء وظيفة GDI+ أو Direct2D / DirectWrite / WIC.

  4. قصّة فضاء الألوان وصيغة البكسل
    الصيغة السهلة للرسم لا تتطابق مع الصيغة التي يحبّها الـ encoder. هذا الموضع الذي كثيراً ما يتعطّل بهدوء.

في جملة واحدة مختصرة، فإنّ التفكير الأسهل ليس «إدخال نصّ بـ Media Foundation» بل «تحريك الإطارات بـ Media Foundation، ووضع المحتوى عبر واجهات الرسم، ثمّ إجراء تحويل الألوان اللازم قبل الترميز».

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[uncompressed frame<br/>RGB32]
    C --> D[Draw image + HelloWorld with GDI+]
    D --> E[BGRA -> NV12 conversion]
    E --> F[IMFSinkWriter]
    F --> G[output.mp4]

    B --> H[audio sample]
    H --> I[copy as is<br/>or re-encode]
    I --> F

النقطة المهمّة هنا أنّ الرسم نفسه ليس من مهامّ Media Foundation.
Media Foundation مسؤولة عن إدخال الإطارات وإخراجها، أمّا وضع الصورة والنصّ فيُترك لواجهات الرسم.

4. كيف نقسم خطّ الأنابيب من الناحية الذهنيّة

4.1 الإدخال يُستقبل عبر IMFSourceReader

إن كان الإدخال مسار ملفّ، فإنّ MFCreateSourceReaderFromURL بنية واضحة، وإن كان بيانات فيديو في الذاكرة، فإنّ إنشاء IMFByteStream واستخدام MFCreateSourceReaderFromByteStream بنية واضحة.

أوّل ما يجب اتّخاذ القرار بشأنه هنا هو هل نستقبل الإطار بصيغة سهلة للرسم، أم بصيغة مناسبة للـ encoder.

  • لتسهيل التنفيذ: RGB32 أو ARGB32
  • لإيلاء الأولويّة لكفاءة الترميز: YUV مثل NV12

لكن بما أنّ دمج النصّ وPNG أسهل بكثير في عائلة RGB، فإنّ استقبال RGB32 / ARGB32 كخطوة أولى نهج عمليّ جدّاً.

عند تفعيل MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING، يقوم Source Reader بتحويل YUV -> RGB32 وإلغاء التشابك (deinterlace) نيابةً عنك.
هذا مفيد في مرحلة «أريد أوّلاً استخراج الإطار والتعامل معه»، لكنّه يثقل في الفيديوهات الطويلة أو ذات الدقّة العالية، لذلك إن كانت السرعة مطلوبة في الإنتاج، فمن المفيد إعادة النظر في البنية لاحقاً.

4.2 دمج الصورة والنصّ يُفكَّر فيه عبر GDI+ أو Direct2D / DirectWrite

نستخرج الـ buffer من IMFSample الذي تلقّيناه من Media Foundation، ونضع فوقه الشعار أو النصّ.

عيّنة هذه المرّة تستخدم GDI+ للرسم، إعطاءً للأولويّة لـ سهولة اللصق ضمن ملفّ واحد كامل.

  • يمكنه قراءة الصور
  • يمكنه رسم النصّ
  • التحضيرات الإضافيّة قليلة نسبيّاً
  • يسهل احتواؤه في .cpp واحد لتطبيق وحدة تحكّم

من جهة أخرى، في معالجة كميّات كبيرة من الفيديو الطويل أو 4K، فإنّ D3D11 + Direct2D + DirectWrite يفتح آفاقاً أوسع للنموّ.
التسلسل الطبيعيّ هو: GDI+ للتنفيذ الأوّل، ثمّ الانتقال إلى Direct2D / DirectWrite في مرحلة عصر السرعة.

4.3 ليس من المضمون أن يقبل H.264 صيغة RGB32 كما هي

هنا أكثر النقاط التي تتعطّل عندها.

عند العودة إلى MP4(H.264)، فإنّ encoder الـ 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 يتعامل بسهولة مع إخراج الفيديو.

الفكرة بسيطة:

  • نوع stream الإخراج … الصيغة التي نريد كتابتها في الملفّ
    مثال: MFVideoFormat_H264
  • نوع stream الإدخال … الصيغة التي يمرّرها التطبيق إلى Sink Writer
    مثال: MFVideoFormat_NV12

نُعدّ هذين بشكل منفصل.

أي من منظور Sink Writer،

  • جهة التطبيق تمرّر إطار NV12 غير مضغوط
  • يقوم Sink Writer بترميزه إلى H.264 وكتابته في MP4

تلك هي العلاقة.

4.5 الأسهل هو التفكير في الصوت بشكل منفصل في البداية

كثيراً ما تكون الحاجة فقط إدخال شعار أو نصّ في الفيديو دون تغيير الصوت نفسه.

في الممارسة العمليّة، فإنّ بنية:

  • stream الفيديو فقط: Source Reader -> الدمج -> Sink Writer
  • stream الصوت: remux مضغوط كما هو

بنية مريحة جدّاً.

لكنّ عيّنة هذه المرّة، تركيزاً على دمج الصورة والنصّ في الإطار، تجعل الإخراج MP4 يحتوي على الفيديو فقط.
نسخة الإبقاء على الصوت يسهل تتبّعها كاملةً إن أُضيفت في مرحلة التوسيع لاحقاً.

5. شروط هذه العيّنة وطريقة استخدامها

كُتب هذا الكود وفق الشروط التالية.

  • Windows 10 / 11
  • تطبيق وحدة تحكّم C++ في Visual Studio 2022
  • بناء x64
  • ملفّ .cpp هذا لا يستخدم رؤوس مُسبقة الترجمة (precompiled headers)
  • عرض وارتفاع فيديو الإدخال زوجيّان
  • الإدخال ملفّ فيديو MP4 عاديّ
  • الإخراج MP4 يحتوي على الفيديو فقط
  • الصورة بصيغة يقرأها GDI+ مثل PNG / JPEG / BMP / GIF

بما أنّ NV12 هو 4:2:0، فإنّ العرض والارتفاع يجب أن يكونا زوجيّين.
لذلك، تتعامل هذه العيّنة بوضوح مع عدم استيفاء الشرط على أنّه خطأ.

5.1 طريقة الاستخدام

  1. أنشئ Console App في Visual Studio
  2. الصق هذا الـ .cpp بالكامل
  3. اضبط الـ .cpp على «عدم استخدام» الرؤوس مُسبقة الترجمة
  4. ابنِ على x64
  5. شغّل كالتالي
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, &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);
        }
    }

    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,
            PixelFormat32bppRGB,
            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. النقاط التي يجب الإمساك بها عند قراءة هذا التنفيذ

7.1 الصيغة السهلة للرسم تختلف عن الصيغة التي يستقبلها الـ encoder

في هذه العيّنة،

  • مخرج Source Reader: RGB32
  • الرسم: GDI+
  • مدخل Sink Writer: NV12

يتمّ اعتماد هذا التدفّق.

السبب بسيط: عائلة RGB أسهل تعاملاً عند وضع النصّ أو PNG، وNV12 أسهل تعاملاً عند تمريره إلى ترميز H.264.

عند قراءة التنفيذ، فإنّ تقسيمه إلى «مرحلة الرسم» و«مرحلة التهيئة قبل الترميز» يجعل المتابعة أسهل.

7.2 يتمّ امتصاص stride والاتّجاه الرأسيّ أوّلاً قبل الرسم

إطارات الفيديو ليست بالضرورة مرتّبةً في الذاكرة بالشكل المرئيّ.

  • قد لا يساوي stride القيمة width * 4
  • قد يكون الاتّجاه الرأسيّ مقلوباً
  • معاملة IMF2DBuffer وIMFMediaBuffer مختلفة قليلاً

لذا فإنّ هذا الكود يطبّع أوّلاً إلى buffer BGRA من نوع top-down ثمّ يرسم.
بعد ضبط هذا الجزء أوّلاً، يصبح كود جانب الرسم سلساً جدّاً.

7.3 لا يكفي النظر في HRESULT فقط في ReadSample، بل أيضاً في flags وsample

قد يعيد ReadSample قيمة S_OK مع كون sample == nullptr.
الأمثلة النموذجيّة هي:

  • MF_SOURCE_READERF_STREAMTICK
  • MF_SOURCE_READERF_ENDOFSTREAM
  • وأحداث stream أخرى

لذلك في الحلقة، يجب النظر معاً في HRESULT وflags وinputSample الثلاثة.
خصوصاً إن تجاهلت STREAMTICK وENDOFSTREAM، فإنّ معالجة الـ timeline في المرحلة التالية تنهار بسهولة.

7.4 من الأكثر أماناً وراثة timestamp وduration من المدخل

الـ timestamp يُقاس بوحدات 100ns.
كما أنّه يجب الحصول على 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 أو stream مخصّصاً
  • مرّره إلى 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

طبقات overlay مثل صور الشعار والترجمات والـ timestamps كافية بـ GDI+ في كثير من الحالات، لكن لعصر الأداء فإنّ Direct2D / DirectWrite أفضل.

خصوصاً إن وُجدت شروط مثل:

  • دقّة عالية
  • مدّة طويلة
  • عدد كبير من الملفّات
  • الرغبة المستقبليّة في الانتقال إلى مسار GPU

فإنّ بنية تستخدم D3D11 / DXGI surface تدخل في حقل الرؤية.

9.4 يُنظر في custom MFT عندما يصبح «مؤثّر فيديو يُراد إعادة استخدامه»

في Media Foundation يمكن تنفيذ المؤثّرات على شكل IMFTransform.
لذلك إن أردت إعادة استخدام نفس معالجة الـ overlay عبر تطبيقات أو خطوط أنابيب متعدّدة، فإنّ custom MFT خيار أنيق.

لكن كأوّل تنفيذ، فإنّ:

  • الالتزام بعقد IMFTransform ضروريّ
  • إدارة أنواع الوسائط للمدخل والمخرج تتزايد
  • ترتفع صعوبة التسجيل وتصحيح الأخطاء

لذلك في الممارسة العمليّة كثيراً ما يكون الأسهل تشغيل Source Reader + الدمج + Sink Writer بشكل صحيح أوّلاً، ثمّ استخراجه كـ MFT عند الحاجة.

10. الخلاصة

عند دمج صورة أو نصّ في جميع إطارات فيديو MP4 باستخدام Media Foundation، فإنّ التفكير وفق التقسيم التالي يسهّل الترتيب.

  • الاستخراج: IMFSourceReader
  • الرسم: GDI+ أو Direct2D / DirectWrite
  • التهيئة لما يستقبله الـ encoder بسهولة: NV12 ونحوها
  • إعادة الكتابة: IMFSinkWriter

وإن كنت تريد «عيّنةً تلصقها كاملةً في .cpp واحد فتعمل مباشرةً»، فإنّ بنية هذه المرّة:

Source Reader -> RGB32 -> صورة + HelloWorld بـ GDI+ -> BGRA to NV12 -> Sink Writer

بنية سلسة جدّاً.

عند التوسّع نحو الإنتاج بعدها، فإنّ التفكير وفق هذا الترتيب أقلّ عرضةً للانهيار:

  1. إضافة remux الصوت
  2. استبدال GDI+ بـ Direct2D / DirectWrite
  3. نقل تحويل NV12 إلى Video Processor MFT أو إلى جانب GPU
  4. التقدّم إلى بنية قائمة على D3D11 surface للفيديوهات الطويلة وعالية الدقّة
  5. استخراجه كـ custom MFT إن لزمت قابليّة إعادة الاستخدام

تكديس كلّ شيء دفعةً واحدةً يجعل COM وstride وفضاء الألوان وإدارة الـ surfaces تنهال عليك معاً.
البدء بتمرير المراحل واحدةً واحدةً أوّلاً ثمّ تقوية ما يلزم لاحقاً يجعل التصميم وتصحيح الأخطاء أسهل بكثير.

11. مقالات ذات صلة من شركة كومورا سوفت ذ.م.م.

12. مراجع

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.

روابط عامة

العودة إلى المدونة