用 Media Foundation 把圖片與文字烙印進 MP4 影格的方法
· 更新日期: · Go Komura · Media Foundation, C++, Windows 開發, GDI+, Direct2D, DirectWrite, H.264
更新紀錄(2 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
引用本文(DOI: 10.5281/zenodo.21616308)
本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。
Go Komura(2026)。〈用 Media Foundation 把圖片與文字烙印進 MP4 影格的方法〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616308 https://comcomponent.com/zh-TW/blog/2026/03/16/009-media-foundation-overlay-image-text-on-mp4-frames/
- DOI(最新版本)
- 10.5281/zenodo.21616308
- DOI(此版本)
- 10.5281/zenodo.22297146
Logo 浮水印、檢驗結果、設備編號、作業人員姓名、時間戳記。 把這類資訊 烙印到 MP4 影片的所有影格,再產出一支新的 MP4,這種需求在監控、檢驗、佐證資料、分析 UI 裡其實相當常見。
不過真的開始碰 Media Foundation,IMFSourceReader、IMFSample、IMFMediaBuffer、IMFTransform、IMFSinkWriter 一字排開,到底該在哪裡疊上文字或 PNG 反而突然變得很難看清楚。
本文先梳理 Source Reader -> 繪製 -> 色彩轉換 -> Sink Writer 的整體樣貌,接著附上一份 可以直接貼進 Visual Studio 的 C++ 主控台應用程式、單一檔案完結的範例。
範例會讀取指定的 MP4,把指定的圖片與 HelloWorld 畫進每一個影格,然後產出輸出用的 MP4。
另外,這份範例以 先能貼上去就跑起來 為優先,採用 只重新編碼影像 的構成。 把音訊 remux 也塞進同一支程式當然做得到,但本文的主題是「把圖片與文字烙印到每一個影格」,所以先聚焦在這件事上。
flowchart TB
accTitle: 這份範例的取捨方式
accDescr: 這篇文章的範例以先貼上去就能跑為優先,採用只重新編碼影像的構成,音訊remux則留到主題的烙印跑通之後再擴充的圖。
fo1["以先貼上去就能跑為優先"] --> fo2["只重新編碼影像"]
fo2 --> fo3["聚焦在每個影格的烙印"]
fo1 -.-> fo4["音訊remux留到之後擴充"]
圖 1: 第一支程式只保留烙印這個主題跑得通的最小構成。
本文出現的程式碼已經整理成一整套範例(單一檔案完結的 .cpp 與 CMake 的建置設定),公開在 GitHub 上。
media-foundation-overlay-image-text-on-mp4-frames - komurasoft-blog-samples (GitHub)
目標讀者與必要環境
本文寫給 接下來要用 C++ 撰寫 Windows 影片處理的中階開發者。假設讀者碰過 COM 的基本概念(ComPtr、HRESULT、參考計數),而 Media Foundation 則是才要開始的階段。
執行範例所需要的環境如下。細節與輸入資料的條件整理在第 5 章。
| 項目 | 前提 |
|---|---|
| OS | Windows 10 / 11 |
| 開發環境 | Visual Studio 2022 的 C++ 主控台應用程式 |
| 建置組態 | x64 |
| 先行編譯標頭檔 | 這個 .cpp 設定為不使用 |
| 輸入影片 | 一般的 MP4。寬與高必須是偶數(因為 NV12 是 4:2:0) |
| 輸出 | 只有影像的 MP4。不會附帶音訊 |
先要掌握的術語
從第 3 章的表開始,會出現沒有解釋的英文。先在這裡一行一行寫清楚。
| 術語 | 意思 |
|---|---|
| remux | 裡頭的壓縮資料原封不動,只把容器重新做一次。因為不重新編碼,畫質與音質都不會劣化,處理也很輕 |
| topology | Media Foundation 用來表示「資料要從哪個元件流到哪個元件」的圖。相當於把來源、轉換、接收端串起來的構成圖 |
| custom MFT | 自己撰寫的 Media Foundation Transform。實作 IMFTransform 之後,就能把特效當成元件插進 Media Foundation 的管線 |
| stride | 影像一列在記憶體上佔用的位元組數。不一定等於 寬 × 4,列尾有可能留有空白 |
1. 先說結論
- 在 MP4 每一個影格上放進圖片或文字,基本形是
用 Source Reader 解碼 -> 在未壓縮影格上合成 -> 必要時做色彩轉換 -> 用 Sink Writer 重新編碼。 - 把圖片或文字放上去這件事本身不是 Media Foundation 的工作。 這部分交給
GDI+、Direct2D、DirectWrite、WIC這類繪製 API 來想,會直接了當得多。 - 要寫回
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,成長空間會比較大。
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 20 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
2. 為什麼這個問題有點麻煩
「在影片裡加文字」實際上混了下面 4 件事。
-
容器與編解碼器的事
mp4是容器,不等於影格本身。裡面通常是H.264或H.265的壓縮資料。 -
解碼 / 編碼的事 維持壓縮狀態的話,一般的 2D 繪製 API 沒辦法直接把文字或 PNG 放上去。必須先還原成未壓縮的影格。
-
繪製的事 文字、Logo、PNG 的透明合成、帶反鋸齒的文字繪製,都不是 Media Foundation 本體的職責。這裡是
GDI+或Direct2D / DirectWrite / WIC的工作。 -
色彩空間與像素格式的事 好畫的格式,和編碼器喜歡的格式並不一致。這一點不起眼,卻很容易卡住。
粗略地說一句,與其想成「用 Media Foundation 加文字」,不如想成「用 Media Foundation 讓影格流動,用繪製 API 放上內容,補上必要的色彩轉換之後再編碼」,這樣最好整理。
flowchart TB
accTitle: 混在一起的4件事
accDescr: 在影片裡加文字這個需求,混雜了容器與編解碼器、解碼與編碼、繪製、色彩空間與像素格式這4件事的圖。
mixq["在影片裡加文字"] --> t1["容器與編解碼器的事"]
mixq --> t2["解碼與編碼的事"]
mixq --> t3["繪製的事"]
t3 -.-> t4["色彩空間與像素格式的事"]
圖 2: 卡住的時候,先分辨自己現在是落在哪一件事裡。
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[用 GDI+ 繪製圖片 + HelloWorld]
D --> E[BGRA -> NV12 轉換]
E --> F[IMFSinkWriter]
F --> G[output.mp4]
B --> H[音訊樣本]
H --> I[直接複製<br/>或重新編碼]
I --> F
圖 3: 用 Source Reader 取出,用 GDI+ 繪製,轉成 NV12 之後再用 Sink Writer 寫回。
這裡重要的是,繪製本身不是 Media Foundation 的工作。 Media Foundation 負責把影格拿進拿出,把圖片與文字放上去則交給繪製 API。
4. 管線要怎麼拆開來想
4.1 輸入用 IMFSourceReader 接收
輸入如果是檔案路徑就用 MFCreateSourceReaderFromURL,如果是記憶體上的影片資料,就建立 IMFByteStream 再用 MFCreateSourceReaderFromByteStream,這樣的構成最好懂。
這裡最先要決定的是,要用好畫的格式接收,還是用適合編碼器的格式接收。
- 想讓實作簡單就用
RGB32或ARGB32 - 想優先考慮編碼效率就用
NV12之類的 YUV
不過,文字與 PNG 的合成用 RGB 系來想壓倒性地容易,所以第一手先用 RGB32 / ARGB32 接收會比較輕鬆。
flowchart TB
accTitle: 第一手要用哪種格式接收
accDescr: 想讓實作簡單就用RGB32或ARGB32,想優先考慮編碼效率就用NV12等YUV,但文字與PNG的合成用RGB系比較好想,所以第一手用RGB系接收比較輕鬆的圖。
rq1{"要優先什麼"}
rq1 -->|"實作的簡單"| rf1["用RGB32 / ARGB32接收"]
rq1 -->|"編碼效率"| rf2["用NV12等YUV接收"]
rf1 -.-> rf3["合成用RGB系比較好想"]
圖 4: 猶豫的時候,先以好畫為優先,從 RGB 系接收開始。
啟用 MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING 之後,Source Reader 會替你做 YUV -> RGB32 的轉換與去交錯。
在「先把影格取出來處理」的階段這很方便,但長片或高解析度影片容易變重,如果正式環境需要速度,之後值得重新檢視構成。
flowchart TB
accTitle: ENABLE_VIDEO_PROCESSING的得失
accDescr: 啟用MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING後Source Reader會做YUV到RGB32的轉換與去交錯,但長片或高解析度容易變重,正式環境需要速度時值得重新檢視構成的圖。
ev1["啟用旗標"] --> ev2["把YUV到RGB32的轉換交出去"]
ev1 --> ev3["去交錯也交出去"]
ev2 -.-> ev4["長片或高解析度容易變重"]
圖 5: 讓取出變輕鬆的便利旗標,在速度上是有代價的。
4.2 圖片與文字的合成用 GDI+ 或 Direct2D / DirectWrite 來想
從 Media Foundation 收到的 IMFSample 取出緩衝區,在上面放上 Logo 圖片與文字。
這次的範例以 單一檔案完結、容易貼上 為優先,繪製用的是 GDI+。
- 可以讀取圖片
- 可以繪製文字
- 額外準備相對少
- 容易收進主控台應用程式的一個
.cpp裡
另一方面,長片或 4K 大量處理的用途下,D3D11 + Direct2D + DirectWrite 的成長空間比較大。
第一版實作用 GDI+,等到要壓低耗時的階段再轉到 Direct2D / DirectWrite,這樣的流程比較自然。
4.3 不見得能維持 RGB32 直接寫成 H.264
這裡是最容易卡住的地方。
要寫回 MP4(H.264) 時,Microsoft 的 H.264 編碼器多半以 I420 / IYUV / NV12 / YUY2 / YV12 等 YUV 系輸入 為前提。
也就是說,在好畫的 RGB32 / ARGB32 上合成完之後,直接丟給 IMFSinkWriter 就結束,不見得成立。
因此實作上會需要下面其中一種轉換。
- 插入
Video Processor MFT做RGB32 / ARGB32 -> NV12 - 自己實作
RGB -> NV12轉換
這次的範例以 單一檔案完結 為優先,採用後者的 自行轉換。
正式環境裡,插入能把色彩空間轉換、尺寸變更、去交錯一併處理的 Video Processor MFT,也是很有力的構成。
flowchart TB
accTitle: 從RGB接到NV12的兩條路
accDescr: 在好畫的RGB32或ARGB32上合成完之後,必須選擇插入Video Processor MFT轉換或自己實作RGB到NV12的轉換,而這份範例為了單一檔案完結選了自行轉換的圖。
cv1["用RGB系合成完了"] --> cv2["用Video Processor MFT轉換"]
cv1 --> cv3["自己轉換成NV12"]
cv3 -.-> cv4["範例以單一檔案完結為優先"]
cv2 -.-> cv5["正式環境是有力的構成"]
圖 6: 轉換這一段一定要有,要選的只是把它放在哪一邊。
4.4 輸出用 IMFSinkWriter 寫出
影片輸出用 IMFSinkWriter 比較好處理。
想法很單純,
- 輸出串流型別 … 想寫進檔案的格式
例:
MFVideoFormat_H264 - 輸入串流型別 … 應用程式交給
Sink Writer的格式 例:MFVideoFormat_NV12
把這兩者分開設定。
也就是說,從 Sink Writer 的角度看,
- 應用程式端交出
NV12的未壓縮影格 Sink Writer把它編碼成 H.264 再寫進 MP4
會是這樣的關係。
flowchart TB
accTitle: Sink Writer輸入輸出型別的分工
accDescr: Sink Writer要把應用程式交出的輸入串流型別設成NV12,把想寫進檔案的輸出串流型別設成H.264,分開設定之後由Sink Writer負責編碼並寫進MP4的圖。
ap1["應用程式交出NV12影格"] --> sw1["Sink Writer"]
sw1 --> sw2["編碼成H.264"]
sw2 --> sw3["寫進MP4"]
sw1 -.-> sw4["輸入型別與輸出型別分開設定"]
圖 7: 把交出去的格式與寫出來的格式分開設定,是 Sink Writer 的作法。
4.5 音訊一開始分開想比較好整理
只想在影片上加 Logo 或文字,音訊本身完全不想動,這種情況相當多。
實務上,
- 只有影像 stream 走
Source Reader -> 合成 -> Sink Writer - 音訊 stream 維持 compressed 直接 remux
這樣的構成好用。
不過這次的範例為了把焦點對準 把圖片與文字烙印進影格,輸出是只有影像的 MP4。 保留音訊的版本,放到之後擴充的階段再加,比較容易追整體。
flowchart TB
accTitle: 影像與音訊分開來想
accDescr: 實務上只讓影像串流從Source Reader經過合成流到Sink Writer,音訊串流則維持壓縮直接remux的構成比較好用,而這份範例為了收斂焦點把輸出限定成只有影像的圖。
vs1["影像串流"] --> vs2["合成後送到Sink Writer"]
as1["音訊串流"] --> as2["維持壓縮直接remux"]
as2 -.-> as3["範例不處理"]
圖 8: 要改的只有影像的話,音訊就不要動,連著容器一起搬。
5. 這份範例的前提與用法
這段程式碼的前提如下。
- Windows 10 / 11
- Visual Studio 2022 的 C++ 主控台應用程式
x64建置- 這個
.cpp檔案 不使用先行編譯標頭檔 - 輸入影片的寬與高是偶數
- 輸入是一般的 MP4 影片檔
- 輸出是 只有影像的 MP4
- 圖片是 PNG / JPEG / BMP / GIF 等 GDI+ 讀得到的格式
NV12 是 4:2:0,所以 寬與高必須是偶數。
因此這份範例在不滿足條件時,會明確地以錯誤中止。
flowchart TB
accTitle: 寬與高必須是偶數這個前提
accDescr: NV12是4:2:0的色度次取樣,所以輸入影片的寬與高必須是偶數,這份範例在不滿足條件時會明確地以錯誤停下來的圖。
nvq1["NV12是4:2:0"] --> nvq2["寬與高必須是偶數"]
nvq2 --> nvq3{"輸入是偶數嗎"}
nvq3 -->|"是偶數"| nvq4["繼續處理"]
nvq3 -->|"含有奇數"| nvq5["明確地以錯誤停下來"]
圖 9: 與其默默地亂掉,不如在入口就擋下不滿足前提的輸入。
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。
位置與大小也能靠修改程式裡的常數來調整。改成能用命令列引數傳入的做法放在 9.5。
5.2 確認是否正確運作
「沒有出錯就結束了」和「正確烙印上去了」是兩回事。依序看下面 4 點,大部分的失敗都找得出來。
- 查看結束時的影格數。 這份範例處理結束後,會在
Done. frames=後面印出寫入的影格數。如果和輸入影片的總影格數差很多,就是在ReadSample的迴圈某處漏掉了 - 把輸出檔案的基本資訊和輸入比對。 在檔案總管對輸出的 MP4 按右鍵,開啟內容 > 詳細資料,可以看到長度、畫面格寬度、畫面格高度、畫面播放速率。如果長度和輸入對不上,就要懷疑 timestamp 的處理(7.4)
- 開頭、中間、結尾這 3 個地方用眼睛看。 只確認第一個影格就放心,會漏掉中途疊加消失的問題。從輸入與輸出各切出同一時刻的靜態影像並排比對最確實,步驟整理在「用 Media Foundation 從 MP4 的指定時間擷取靜態影像的方法」
- 查看顏色有沒有異常。 如果人的膚色或天空變成不自然的顏色,可能是
BgraToNv12的係數選擇(BT.601 與 BT.709)和輸入不合
第一次嘗試時,建議用 幾秒鐘的短 MP4,加上輪廓清楚的 PNG。用長片去跑第一支程式,釐清原因會花掉很多時間。
flowchart TB
accTitle: 確認是否正確運作的步驟
accDescr: 沒有出錯就結束和正確烙印上去是兩回事,所以要依序查看影格數的核對、輸出檔案基本資訊的比較、開頭與中間與結尾的目視、顏色異常的確認這4點的圖。
ck1["把影格數與輸入核對"] --> ck2["用內容比較長度與尺寸"]
ck2 --> ck3["目視開頭・中間・結尾"]
ck3 --> ck4["確認顏色是否不自然"]
ck4 -.-> ck5["異常就懷疑係數選擇"]
圖 10: 沒有出錯和正確是兩回事,用 4 個觀點依序確認。
6. 可以直接貼進 .cpp 的單一檔案完結程式碼
6.1 程式碼地圖
先把地圖放出來。程式碼很長,但實際上該讀的重點只有 CopySampleToTopDownBgra、DrawOverlay、BgraToNv12 這 3 個,加上 wmain 的迴圈。其餘都是初始化與收尾。
| 函式 / 類別 | 職責 | 詳細說明 |
|---|---|---|
ScopedMf / ScopedGdiplus |
用 RAII 把 MFStartup 與 GDI+ 的初始化和結束成對綁在一起 |
— |
ConfigureSourceReader |
把 Source Reader 的輸出設成 RGB32,並取出寬、高、fps 與預設的 frame duration |
4.1 |
GetDefaultStride |
從媒體型別求出預設的 stride | 7.2 |
BufferLock |
有 IMF2DBuffer 就用它,沒有就用 IMFMediaBuffer 鎖定緩衝區 |
7.2 |
CopySampleToTopDownBgra |
吸收 stride 與上下方向的差異,正規化成 top-down 的 BGRA | 7.2 |
DrawOverlay |
用 GDI+ 繪製圖片與文字。只有這裡是「繪製的一段」 | 4.2 / 7.1 |
BgraToNv12 |
把畫完的 BGRA 轉換成 NV12 | 4.3 / 7.1 |
CreateNv12Sample |
把 NV12 緩衝區包成 IMFSample,並加上 timestamp 與 duration |
7.4 |
ChooseBitrate |
從輸入的資訊決定輸出的位元率 | — |
CreateSinkWriter |
設定輸出側的 H.264 型別,以及我們這邊要交出的 NV12 型別 |
4.4 |
wmain 的 while 迴圈 |
一邊查看 ReadSample 的 HRESULT / flags / sample,一邊逐格處理 |
7.3 / 7.4 |
和第 3 章的處理示意並排來看,CopySampleToTopDownBgra 對應「未壓縮影格」,DrawOverlay 對應「用 GDI+ 繪製」,BgraToNv12 對應「BGRA 轉 NV12」。
flowchart TB
accTitle: 該讀的3個核心函式
accDescr: 長長的程式碼裡實際該讀的核心只有CopySampleToTopDownBgra、DrawOverlay、BgraToNv12這3個加上wmain的迴圈,分別對應未壓縮影格的正規化、繪製、NV12轉換的圖。
lp1["wmain的迴圈逐格處理"] --> fn1["CopySampleToTopDownBgra"]
fn1 --> fn2["DrawOverlay"]
fn2 --> fn3["BgraToNv12"]
fn1 -.-> ro1["正規化成未壓縮影格"]
fn2 -.-> ro2["繪製圖片與文字"]
fn3 -.-> ro3["整理成可供編碼的形式"]
圖 11: 其餘都是初始化與收尾,核心只有這 3 個函式與迴圈。
6.2 程式碼全文
#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的處理方式略有不同
因此這段程式碼先 正規化成 top-down 的 BGRA 緩衝區,然後才繪製。 先把這裡統一好,繪製側的程式碼就能寫得相當直接了當。
flowchart TB
accTitle: 繪製前先正規化的理由
accDescr: stride與寬度乘以4對不上、上下方向相反、IMF2DBuffer與IMFMediaBuffer處理方式不同這些浮動,先用正規化成top-down的BGRA緩衝區來吸收,然後才繪製的圖。
ir1["stride對不上"] --> nr1["正規化成top-down的BGRA"]
ir2["上下方向有時相反"] --> nr1
ir3["緩衝區種類不同處理也不同"] --> nr1
nr1 --> nr2["繪製程式碼變得直接了當"]
圖 12: 記憶體上的浮動只要在一個地方吸收掉,繪製側就什麼都不必知道。
7.3 ReadSample 不能只看 HRESULT,還要看 flags 與 sample
ReadSample 就算是 S_OK,也可能出現 sample == nullptr。
典型的例子是,
MF_SOURCE_READERF_STREAMTICKMF_SOURCE_READERF_ENDOFSTREAM- 其他串流事件
這幾種。
因此在迴圈裡必須把 HRESULT、flags、inputSample 三者湊齊一起看。
尤其是漏看 STREAMTICK 與 ENDOFSTREAM,後段的時間軸處理很容易亂掉。
flowchart TB
accTitle: ReadSample要看的3點
accDescr: ReadSample就算是S_OK也可能讓sample變成nullptr,STREAMTICK與ENDOFSTREAM這類串流事件是典型例子,所以迴圈裡要把HRESULT與flags與sample三者湊齊確認的圖。
rs1["ReadSample傳回"] --> rs2["確認HRESULT"]
rs2 --> rs3["確認flags"]
rs3 --> rs4["確認sample是否存在"]
rs4 -.-> rs5["S_OK也可能是nullptr"]
圖 13: 不要只靠一個傳回值判斷,用 3 點一組來處理一個影格。
7.4 timestamp 與 duration 沿用輸入比較安全
時間戳記的單位是 100ns。
另外 duration 必須另外從 IMFSample 取得。
與其以固定 fps 為前提每次寫死累加,盡量沿用輸入 sample 的 timestamp / duration 比較不容易亂掉。
這份範例也只有在取不到 duration 時,才退回用 fps 計算出來的預設值。
flowchart TB
accTitle: timestamp與duration的處理
accDescr: 不要以固定fps為前提寫死累加,而是盡量沿用輸入sample的timestamp與duration,只有在取不到duration時才退回用fps算出來的預設值的圖。
ts1["從輸入sample取出"] --> ts2{"duration取得到嗎"}
ts2 -->|"取得到"| ts3["直接沿用"]
ts2 -->|"取不到"| ts4["使用來自fps的預設值"]
ts3 -.-> ts5["比寫死累加不容易亂掉"]
圖 14: 時刻不要自己造,從輸入沿用才是基本姿態。
7.5 GDI+ 導入很輕,但長片與高解析度還有下一個階段
GDI+ 相當適合單一檔案完結的範例,但長片或 4K 大量處理的用途下,D3D11 + Direct2D + DirectWrite 有時會比較有利。
- 先用
GDI+把整條流程跑通 - 之後有需要再換成
Direct2D / DirectWrite - 色彩轉換搬到
Video Processor MFT或 GPU 側
用這種分階段的推進方式,就能在不破壞設計的情況下擴充。
flowchart TB
accTitle: 繪製API的分階段推進
accDescr: 先用GDI+把整條流程跑通,有需要再換成Direct2D與DirectWrite,色彩轉換則搬到Video Processor MFT或GPU側,這種不破壞設計的分階段推進方式的圖。
gd1["先用GDI+把整條流程跑通"] --> gd2["需要時換成Direct2D"]
gd2 --> gd3["把色彩轉換搬到MFT或GPU"]
gd1 -.-> gd4["長片與4K大量處理是下一個階段"]
圖 15: 從導入的輕巧開始,再從需要效能的地方依序替換。
7.6 這份範例只鎖定影像
把音訊也全部塞進同一篇文章,話題的主軸容易散掉。 因此這份範例把焦點對準 把圖片與文字烙印進影像影格,輸出是只有影像的 MP4。
實務上,下一個階段
- 只有影像走
Source Reader -> 合成 -> Sink Writer - 音訊維持 compressed 直接 remux
延伸成這樣的構成比較好處理。
8. 如果「拿到的影片資料」不是檔案而是記憶體上的 MP4 位元組序列
這次的程式碼用的是 MFCreateSourceReaderFromURL,所以輸入是檔案路徑。
不過,如果需求是「想對 API 拿到的 mp4 位元組序列做同樣的事」,想法不會變。 要改的只有入口。
- 準備
IStream或自訂的串流 - 把它當成
IMFByteStream交給Source Reader - 之後一樣是
RGB32 -> 繪製 -> NV12 -> Sink Writer
也就是說,本質不在 影片資料怎麼持有,而在解碼後的每個影格要怎麼畫上去。
flowchart TB
accTitle: 輸入是位元組序列也只改入口
accDescr: 輸入是檔案路徑就用MFCreateSourceReaderFromURL,是記憶體上的MP4位元組序列就準備IMFByteStream交給Source Reader,之後從RGB32到繪製、NV12、Sink Writer的流程完全相同的圖。
in1["檔案路徑"] --> sr1["Source Reader"]
in2["記憶體上的位元組序列"] --> bs1["做成IMFByteStream"]
bs1 --> sr1
sr1 --> same1["之後的流程相同"]
圖 16: 資料的持有方式改變,變的也只有入口這一段。
9. 要在正式環境往下長的話
9.1 加上音訊 remux
第一個擴充方向裡最貼近實務的,是 把音訊原樣保留。 只重新編碼影像,音訊維持 compressed 用同一種格式寫回,這樣的構成能滿足需求,又不會讓實作大幅增加。
Sink Writer 明確支援 把壓縮過的輸入以同一種格式寫到輸出 這個組合,作為不重新編碼的 remux 用途。要加的是下面 3 個地方。
- 把音訊串流以壓縮狀態接收。 用
reader->SetStreamSelection(MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE)啟用,再把GetNativeMediaType取得的型別原樣交給SetCurrentMediaType。不想讓它解碼時就指定 native 的型別,這是 Source Reader 側的作法 - 在 Sink Writer 把那個型別同時設成輸入與輸出。 對
writer->AddStream(audioType.Get(), &audioStreamIndex)與writer->SetInputMediaType(audioStreamIndex, audioType.Get(), nullptr)交出同一個媒體型別 - 把 timestamp 的基準和影像共用。 6.2 的程式碼在影像上使用的
firstTimestamp,音訊樣本也要用同樣的方式扣掉。這裡如果用不同的基準,聲音和畫面就會對不上
ReadSample 的呼叫也要從現在只指定影像的形式,改成使用 MF_SOURCE_READER_ANY_STREAM,再依傳回的 stream index 分流。
另外,除非編碼器有提供,Sink Writer 不會做音訊的重新取樣,也不會做影像的尺寸變更與影格速率轉換。輸入的音訊格式如果 MP4 的接收端無法接受,就不是 remux,而是需要重新編碼。
flowchart TB
accTitle: 音訊remux要加的3個地方
accDescr: 要把音訊原樣保留,就要加上以壓縮狀態接收音訊串流、在Sink Writer把同一個型別設成輸入與輸出、把timestamp的基準與影像共用這3個地方的圖。
ar1["以壓縮狀態接收音訊"] --> ar2["同一型別設成輸入與輸出"]
ar2 --> ar3["timestamp基準與影像共用"]
ar3 -.-> ar4["基準分開聲音就會對不上"]
圖 17: remux 只要加 3 個地方,但別忘了對齊時刻的基準。
9.2 插入 Video Processor MFT
這次的範例為了單一檔案完結,自己實作了 BGRA -> NV12 的轉換,但正式環境裡插入 Video Processor MFT 的構成也相當有力。
用 Video Processor MFT 之後,
- 色彩空間轉換
- 尺寸變更
- 去交錯
- 影格速率轉換
這些都比較容易一併處理。
9.3 把 GDI+ 換成 Direct2D / DirectWrite
Logo 圖片、字幕、時間戳記這類疊加,用 GDI+ 就夠的場合很多,但要壓效能的話 Direct2D / DirectWrite 比較有利。
尤其是在,
- 高解析度
- 長片
- 大量支數
- 將來想搬到 GPU 路徑
有這些條件時,使用 D3D11 / DXGI surface 的構成就進入視野了。
9.4 custom MFT 等到變成「想重複使用的影片特效」再考慮
Media Foundation 可以把特效實作成 IMFTransform。
因此如果想在多個應用程式或 pipeline 重複使用同一套疊加處理,custom MFT 是很乾淨的選項。
不過作為第一支程式,
- 必須滿足
IMFTransform的契約 - 輸入輸出媒體型別的管理會變多
- 註冊與偵錯的難度會提高
所以 先用 Source Reader + 合成 + Sink Writer 正確跑起來,有需要再切出成 MFT,在實務上通常比較好推進。
flowchart TB
accTitle: 考慮custom MFT的時機
accDescr: 先用Source Reader與合成與Sink Writer正確跑起來,等到想在多個應用程式或管線重複使用同一套疊加處理時,再切出成custom MFT,這個判斷流程的圖。
mf1["先用現在的構成正確跑起來"] --> mf2{"開始想重複使用了嗎"}
mf2 -->|"想在多個應用程式用"| mf3["切出成custom MFT"]
mf2 -->|"一支就夠用"| mf4["維持現在的構成"]
mf3 -.-> mf5["實作・註冊・偵錯的難度會提高"]
圖 18: 做成元件很乾淨,但等到有需要再做也不遲。
9.5 把字串改成引數,讓它能繪製日文
範例的字串固定寫在 kOverlayText,內容是 HelloWorld。要烙印設備編號或作業人員姓名的用途下,首先會想把這裡改成引數。要繪製日文時,字型也必須換。
要改的有 4 個地方。
1. 在匿名 namespace 加上要使用的字型名稱。 既有的 kOverlayText 留著當預設值。
const wchar_t* kOverlayText = L"HelloWorld"; // 既有。省略引數時當作預設值使用
const wchar_t* kFontFamilyName = L"Yu Gothic UI"; // 顯示得出日文的字型
const wchar_t* kFallbackFontFamilyName = L"Segoe UI"; // 給沒有上面那個字型的環境
2. 讓 DrawOverlay 能接收要繪製的字串。
void DrawOverlay(
std::vector<BYTE>& bgra,
UINT32 width,
UINT32 height,
Gdiplus::Image& overlayImage,
const std::wstring& overlayText) // 新增
3. 在 DrawOverlay 裡面,把字型的建立與字串的參照換掉。 把原本的 Gdiplus::Font font(L"Segoe UI", ...) 那一行,換成下面這段。
// 指定的字型如果沒有安裝,就退回預設的字型
Gdiplus::FontFamily preferred(kFontFamilyName);
Gdiplus::FontFamily fallback(kFallbackFontFamilyName);
const Gdiplus::FontFamily& family = preferred.IsAvailable() ? preferred : fallback;
if (!family.IsAvailable())
{
throw std::runtime_error("Neither the preferred nor the fallback font family is installed.");
}
Gdiplus::Font font(&family, fontPx, Gdiplus::FontStyleBold, Gdiplus::UnitPixel);
ThrowIfGdiplusError(font.GetLastStatus(), "Create font");
在此之上,把傳給 MeasureString 與兩次 DrawString 的 kOverlayText,全部換成 overlayText.c_str()。3 個地方都要改,否則只有陰影會留著舊的文字。
4. 在 wmain 接收引數,並交給 DrawOverlay。
if (argc < 4 || argc > 5)
{
std::wcerr
<< L"Usage: OverlayMp4.exe <input.mp4> <overlayImage.png> <output.mp4> [text]"
<< std::endl;
return 1;
}
const std::wstring inputPath = argv[1];
const std::wstring imagePath = argv[2];
const std::wstring outputPath = argv[3];
const std::wstring overlayText = (argc == 5) ? std::wstring(argv[4]) : std::wstring(kOverlayText);
然後,把迴圈裡的呼叫改成下面這樣。
DrawOverlay(bgra, videoInfo.width, videoInfo.height, overlayImage, overlayText);
處理日文時有 2 個注意事項。
- 如果要在
.cpp裡把日文寫成常值,就要把原始檔存成 UTF-8 with BOM,或是在 MSVC 加上/utf-8建置。 少了這一步就會出現亂碼。從命令列引數傳入的情況下,wmain是以 UTF-16 接收,所以不會發生這個問題 - 字型不一定裝在環境裡。 像上面的程式碼一樣,一定要準備 fallback。字型不存在時無聲無息換成別的字型,之後就很難追出版面對不上的原因
flowchart TB
accTitle: 繪製日文所需的變更點
accDescr: 把字串改成引數以繪製日文,要變更字型名稱常數的新增、DrawOverlay的引數新增、換成帶fallback的字型建立、wmain接收引數這4個地方,並注意亂碼與字型缺漏的圖。
jp1["新增字型名稱的常數"] --> jp2["在DrawOverlay新增引數"]
jp2 --> jp3["帶fallback地建立字型"]
jp3 --> jp4["在wmain接收引數並傳入"]
jp3 -.-> jp5["沒有的字型就退回預設"]
圖 19: 變更有 4 個地方,容易出事的只有字型的準備與字元編碼。
10. 總結
用 Media Foundation 把圖片或文字烙印到 MP4 影片的所有影格時,拆成這 4 件事來想,會更容易看清全貌。
- 取出:
IMFSourceReader - 繪製:
GDI+或Direct2D / DirectWrite - 改成編碼器容易接受的形式:
NV12等 - 寫回:
IMFSinkWriter
而如果想要「全部貼進一個 .cpp,就這樣直接跑得起來的範例」,像這次這樣
Source Reader -> RGB32 -> 用 GDI+ 畫上圖片 + HelloWorld -> BGRA to NV12 -> Sink Writer
這個構成相當直接了當。
要在正式環境往下長,照這個順序想比較不會亂掉。
- 加上音訊 remux
- 把
GDI+換成Direct2D / DirectWrite - 把
NV12轉換搬到Video Processor MFT或 GPU 側 - 為長片與高解析度轉向以
D3D11 surface為基礎的構成 - 需要重複使用時切出成 custom
MFT
一開始就全部塞滿,COM、stride、色彩空間、surface 管理會一次湧上來。 最初先分段跑通,之後只把需要的地方加強,設計與偵錯都會輕鬆很多。
flowchart TB
accTitle: 在正式環境往下長的順序
accDescr: 加上音訊remux,把GDI+換成Direct2D與DirectWrite,把NV12轉換搬到Video Processor MFT或GPU側,為長片與高解析度轉向以D3D11 surface為基礎的構成,需要重複使用時切出成custom MFT,照這個順序往下長比較不會亂掉的圖。
ex1["加上音訊remux"] --> ex2["把繪製換成Direct2D"]
ex2 --> ex3["把轉換搬到MFT或GPU"]
ex3 --> ex4["轉向以D3D11 surface為基礎"]
ex4 --> ex5["需要時切出成custom MFT"]
圖 20: 避開一次塞滿,照這個順序一段一段加強。
11. 相關文章
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
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
在 Media Foundation 中把 YUV 轉換成 RGB 的方法
本文從 Source Reader 的自動轉換、NV12/YUY2 的自行轉換、stride 與色彩空間,梳理在 Media Foundation 中把 YUV 影格轉換成 RGB 的方法。
用 Media Foundation 從 MP4 的指定時間擷取靜態影像的方法
整理如何用 Source Reader 取出 MP4 中最接近指定時間的影格,把 stride 與 RGB32 的 alpha 整理好之後存成 PNG 的實作步驟。
Media Foundation 入門 - 從 COM 的視角理解這套 API
說明 Media Foundation 是什麼,並依照最初該掌握的順序,一併整理 COM、HRESULT、IMFSourceReader、MFT 等 Windows 媒體 API 的基本用語。
Time Travel Debugging ── 把長期運轉中無法重現的問題「錄下來」再倒回去
一個月才出現一次的問題,當機傾印只拍得到結果。本文說明如何用 WinDbg 的 Time Travel Debugging(TTD) 錄下執行並倒回,涵蓋 TTD.exe 的錄製設計、環形緩衝區、TTD.Calls 查詢,以及和傾印的分工。
引數為什麼會壞掉 ── Windows 命令列引數的規則
在 Windows 上並不存在引數的陣列,傳給 CreateProcess 的是一條字串,分割由接收端負責。本文說明 CommandLineToArgvW・CRT・.NET 的分割規則,以及 .NET 的 ArgumentList 與 C++ 中正確的組裝方式。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
這個主題直接對應橫跨 Media Foundation、GDI+、Direct2D / DirectWrite、色彩轉換與影片輸出的 Windows 應用程式實作。
技術諮詢 & 設計審查
也適合用來梳理設計:單一檔案的實作要如何長成正式環境的架構,音訊 remux 與 GPU 化又該在哪裡劃分界線。
常見問題
整理諮詢這個主題時常見的問題。
- 用 Media Foundation 把圖片或文字烙印到 MP4 每一個影格的基本流程是什麼?
- 基本形是「用 Source Reader 解碼 → 在未壓縮影格上合成 → 必要時做色彩轉換 → 用 Sink Writer 重新編碼」。把圖片或文字放上去這件事本身不是 Media Foundation 的工作,而是 GDI+、Direct2D/DirectWrite、WIC 這類繪製 API 的工作。想讓第一支程式先跑起來,Source Reader → RGB32 → 用 GDI+ 繪製 → NV12 → Sink Writer 這個構成最好懂;如果優先考慮速度與擴充性,改用 D3D11/DXGI surface 搭配 Direct2D/DirectWrite 的構成,成長空間會比較大。
- 可以把 RGB32 的影格直接交給 H.264 編碼嗎?
- 不一定可以。Microsoft 的 H.264 編碼器多半以 I420/IYUV/NV12/YUY2/YV12 等 YUV 系輸入為前提,因此在好畫的 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 三者湊齊一起檢查。另外時間戳記的單位是 100ns,duration 與其以固定 fps 為前提每次寫死累加,不如盡量沿用輸入 sample 的 timestamp 與 duration,比較不容易亂掉。