用 Media Foundation 從 MP4 的指定時間擷取靜態影像的方法
· 更新日期: · Go Komura · Media Foundation, C++, Windows 開發, WIC
更新紀錄(2 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
引用本文(DOI: 10.5281/zenodo.21616277)
本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。
Go Komura(2026)。〈用 Media Foundation 從 MP4 的指定時間擷取靜態影像的方法〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616277 https://comcomponent.com/zh-TW/blog/2026/03/15/000-media-foundation-extract-still-image-from-mp4-at-specific-time/
- DOI(最新版本)
- 10.5281/zenodo.21616277
- DOI(此版本)
- 10.5281/zenodo.22297114
「想從 MP4 取出『第 12.3 秒的那一張』」這種需求其實相當常見。像是產生縮圖、檢驗日誌、監視錄影畫面的代表影格、設備日誌的佐證資料等等。
只是 Media Foundation 在這裡沒有那麼直接了當。看起來像是在 SetCurrentPosition 之後呼叫一次 ReadSample 就結束了,實際上卻牽涉 key frame、timestamp、stride、影像的上下方向、RGB32 的第 4 個位元組等等。做得草率一點,就會出現時間點稍微偏掉、影像上下顛倒、PNG 莫名其妙變透明這類不起眼卻很惱人的狀況。
flowchart TB
accTitle: 草率處理時會出的狀況
accDescr: 只是 seek 之後讀一次就儲存的實作,會牽涉 key frame 與 timestamp、stride、上下方向、RGB32 的第 4 個位元組,因而出現時間偏掉、上下顛倒、透明 PNG 等狀況的圖。
rough1["seek 之後讀一次就儲存"] --> tz1["時間點稍微偏掉"]
rough1 --> ud1["影像上下顛倒"]
rough1 --> tp1["PNG 莫名變透明"]
圖 1: 乍看之下做到這樣就結束的實作,會導向時間偏掉、上下顛倒、透明 PNG 這些不起眼的狀況。
Media Foundation 的整體樣貌本身,可以參考之前寫的 Media Foundation 入門 - 從 COM 的角度理解 API。這次則再往下一層,只聚焦在 從 MP4 抽出一張 這件事。
本文會用 IMFSourceReader,把 從 MP4 取出最接近指定時間的靜態影像一張並存成 PNG 的做法,連同實務上容易踩到的陷阱一起梳理。而且在最後放了 可以直接貼進 Visual Studio C++ 主控台應用程式專案 .cpp 的單檔完整程式碼。文章中不會有切碎的片段程式碼,只要把最後那一整塊帶走就能動。
另外,本文出現的程式碼,已經以範例程式碼整套(單檔完成的 C++ 主控台應用程式)的形式在 GitHub 上公開。
media-foundation-extract-still-image-from-mp4-at-specific-time - komurasoft-blog-samples (GitHub)
1. 先講結論
先把結論整理出來,就是這樣。
- 從 MP4 抽一張的話,這次用
Source Reader當入口,比Media Session更直接了當 IMFSourceReader::SetCurrentPosition不保證 exact seek。通常會落在 target 稍前,尤其偏向 key frame 那一側,所以之後必須用ReadSample往前推進,比較目標時間的前後ReadSample就算成功,也可能出現pSample == nullptr。不能只看HRESULT,還要看flags和pSample- 把輸出的媒體類型設成
MFVideoFormat_RGB32,後面比較好儲存 - 不過
RGB32的第 4 個位元組不一定是 alpha,直接寫進 PNG 有時會變成透明影像。儲存前填入0xFF讓它不透明比較安全 - 每列的
stride和 top-down / bottom-up 如果隨手處理,影像就會亂掉,所以取出的樣本要先整成 top-down 的連續 BGRA,再交給 PNG
換句話說,seek -> 讀一次 -> 儲存 有點草率;做到 seek -> 看 timestamp 比較前後 -> 注意 stride 再複製 -> 儲存 PNG 這種程度,就會穩定不少。
flowchart TB
accTitle: 草率的流程與穩定的流程
accDescr: seek 之後讀一次就儲存的流程很草率,改成 seek 之後一邊看 timestamp 一邊比較前後,注意 stride 複製之後再儲存 PNG 的流程就會穩定不少的圖。
sk1["seek"] --> cmp1["一邊看 timestamp 一邊比較前後"]
cmp1 --> cp1["注意 stride 再複製"]
cp1 --> sv1["儲存 PNG"]
sk1 -.->|"讀一次就儲存太草率"| ng1["偏掉與亂掉的原因"]
圖 2: 不要在 seek 之後馬上儲存,中間夾進前後比較與注意 stride 的複製,就會穩定。
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 15 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
2. 本文的前提
2.1. 目標讀者與前置知識
- 假設讀者在 C++ 裡呼叫過 Windows 的 COM API。只要理解「用
CoInitializeEx初始化,再用Release釋放介面指標」這個流程就夠了 - 傳回值的
HRESULT用SUCCEEDED/FAILED巨集判斷。失敗時直接以 16 進位值來讀。開頭是0x8007的值來自 Win32 錯誤,低 16 bit 就是 Win32 的錯誤碼(0x80070057與E_INVALIDARG是同一個值)。開頭是MF_E_的 Media Foundation 專屬錯誤,定義在mferror.h - 就算第一次接觸 Media Foundation 也讀得下去。從 COM 角度看到的整體樣貌,整理在 Media Foundation 入門 - 從 COM 的角度理解 API
2.2. 開發環境
| 項目 | 這次的前提 |
|---|---|
| OS | Windows 10 / Windows 11 |
| IDE | Visual Studio 2022(「使用 C++ 的桌面開發」工作負載) |
| SDK | Visual Studio 隨附的 Windows SDK(包含 Media Foundation 與 WIC 的標頭檔和函式庫) |
| 專案 | C++ 的「主控台應用程式」範本 |
| 平台 | x64 |
| 額外函式庫 | 無。mfplat.lib 這類連結指定,在程式碼裡用 #pragma comment(lib, ...) 就解決了 |
貼上程式碼的方式,以及先行編譯標頭檔周邊的細節注意事項,整理在「7. 建置與執行的筆記」。
2.3. 輸入與輸出的前提
這次的前提如下。
- 輸入是本機的 MP4 檔案
- 想要的是一張靜態影像
- 傳回的不是「正好在指定時間」,而是「最接近指定時間的影格」
- 實作採用同步模式的
IMFSourceReader - 儲存格式是用 WIC 寫出的 PNG
- 不使用外部函式庫,只用 Windows 標準 API 完成
- 以中途不會變更解析度的一般 MP4 為前提
如果要做到播放、音訊同步、seek bar、與 UI 連動,那會是另一種設計;但如果用途是 想取出一個影格,這樣做相當好懂。
flowchart TB
accTitle: 這次前提的構圖
accDescr: 以本機的 MP4 為輸入,用同步模式的 IMFSourceReader 取得最接近指定時間的影格,再用 WIC 存成 PNG,這種不需要外部函式庫的前提構成的圖。
mp1["本機的 MP4"] --> rd1["同步模式的 IMFSourceReader"]
rd1 --> nf1["最接近指定時間的影格"]
nf1 --> png1["用 WIC 儲存 PNG"]
rd1 -.-> std1["只用 Windows 標準 API 完成"]
圖 3: 從輸入到儲存,都用不需要外部函式庫的同步 Source Reader 構成走完。
3. 先看的整理表
3.1. 處理流程
| 要做的事 | 使用的 API | 角色 |
|---|---|---|
| 開啟 MP4 | MFCreateSourceReaderFromURL |
從檔案建立媒體來源 |
| 只選影像 | SetStreamSelection |
避免讀取音訊 |
| 轉成 RGB32 | SetCurrentMediaType + MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING |
取得方便儲存的未壓縮影格 |
| 移動到指定時間 | SetCurrentPosition |
以 100ns 為單位 seek |
| 讀取影格 | ReadSample |
一張一張取得解碼完成的樣本 |
| 比較前一張與後一張 | 樣本的 timestamp | 決定最接近指定時間的那一張 |
| 儲存成 PNG | WIC | 寫出成影像檔 |
3.2. 這次採用的判斷規則
雖然說是「指定時間的靜態影像」,但影片不是連續量,而是離散的影格,所以在實作上先決定要用哪條規則挑出一張,會比較輕鬆。
這次採用下面的規則。
- seek 之後繼續呼叫
ReadSample - 保留
timestamp < target的最後一個樣本 - 當
timestamp >= target的第一個樣本出現時,比較前一個樣本與目前樣本各自跟 target 的差 - 採用比較接近 target 的那一個
這樣就比較容易取到 最接近 target 的那一張,而不是「target 之後的第一張」。
flowchart TB
accTitle: 挑出最接近的一張的判斷規則
accDescr: seek 之後繼續呼叫 ReadSample,保留 target 之前的最後一個樣本,等 target 之後的第一個樣本出現時比較兩者的差,採用比較接近 target 的那一個的判斷規則的圖。
adv1["seek 之後繼續呼叫 ReadSample"] --> bf1["保留 target 之前的最後一個樣本"]
bf1 --> af1["target 之後的第一個樣本出現"]
af1 --> df1["比較兩者與 target 的差"]
df1 --> pk1["採用比較接近的那一個"]
圖 4: 先比較前後兩個候選再採用,取到的就不是「target 之後的第一張」,而是最接近的一張。
3.3. 處理的全貌
從處理的開頭到結尾,大致是 input.mp4 -> 建立 Source Reader -> 要求 RGB32 -> seek -> 反覆呼叫 ReadSample -> 比較 target 的前後 -> 重新排成 top-down BGRA -> 用 WIC 儲存 PNG。
看起來很單純,但 seek 的精度、樣本為 null、stride、第 4 個位元組的處理,各自都藏著小陷阱。只要不踩到這 4 個,實作本身會很順地收斂。
flowchart TB
accTitle: 處理中潛藏的 4 個陷阱
accDescr: 處理看起來單純,但有 seek 的精度、樣本為 null、stride、RGB32 的第 4 個位元組這 4 個小陷阱,只要不踩到就能讓實作順利收斂的圖。
lk1["看起來單純的處理"] --> t1["seek 的精度"]
lk1 --> t2["樣本為 null"]
lk1 --> t3["stride 與方向"]
t3 -.-> t4["第 4 個位元組的處理"]
t1 --> okf["不踩到就能順利收斂"]
t2 --> okf
t3 --> okf
圖 5: 看似單純的流程各處藏著 4 個陷阱,只要留意這幾點,實作就能順利收斂。
4. 先掌握的陷阱
先把這一章開始出現的用語簡短整理一下。
| 用語 | 意思 |
|---|---|
| key frame(關鍵影格) | 不參考前後影格,單獨就能解碼的影格。在 H.264 中,IDR 圖像等屬於這一類 |
| GOP(Group of Pictures) | 從某個 key frame 到下一個 key frame 之前的一組影格。GOP 越長,key frame 的間隔就拉得越開,seek 之後的位置也越容易離指定時間很遠 |
| stride | 影像緩衝區中一列所佔的位元組數。不一定等於 寬度 × 每個像素的位元組數,每列結尾可能會有 padding |
| top-down / bottom-up | 影像緩衝區的開頭是上緣那一列還是下緣那一列的差別。bottom-up 會以負的 stride 表示 |
MF_SOURCE_READERF_STREAMTICK |
ReadSample 傳回的 flag 之一,表示串流中有空隙(資料中斷)。帶著這個 flag 的呼叫拿不到影格,要重讀一次 |
4.1. SetCurrentPosition 不是 exact seek
如同 Microsoft Learn 的 IMFSourceReader::SetCurrentPosition 所寫,它不保證 exact seeking。影片通常會落在指定位置稍前,尤其偏向 key frame 那一側。而且它預設你之後會用 ReadSample 往前推進到目標位置。
因此,下面這種實作相當危險。
SetCurrentPosition(target)- 呼叫一次
ReadSample - 儲存那個影格
在 GOP 長的影片上,這樣就會偏掉。如果影片的 key frame 間隔拉開到 2 秒,就會存下比指定時間最多早了將近 2 秒的影格。
flowchart TB
accTitle: seek 之後只讀一次會偏掉的機制
accDescr: SetCurrentPosition 會落在指定位置稍前,尤其偏向 key frame 那一側,所以緊接著只讀一次就儲存時,在 GOP 長的影片上會存下比指定時間早很多的影格的圖。
sp1["SetCurrentPosition(target)"] --> kf1["落在 key frame 那一側稍前的位置"]
kf1 --> one1["只呼叫一次 ReadSample"]
one1 --> ng2["結果存下較早的影格"]
ng2 -.-> gp1["GOP 越長偏得越多"]
圖 6: seek 會偏向 key frame 那一側,只讀一次就可能存下早了整個 GOP 長度的畫面。
4.2. ReadSample 成功時也可能出現 pSample == nullptr
ReadSample 即使傳回 S_OK,ppSample 也可能是 NULL。到達尾端會傳回 MF_SOURCE_READERF_ENDOFSTREAM,遇到串流空隙會傳回 MF_SOURCE_READERF_STREAMTICK 等 flag。
只看 HRESULT 就直接取用 pSample 很危險。把 HRESULT、flags、pSample 湊成三件一組來看 才夠安全。
flowchart TB
accTitle: ReadSample 結果的三點確認
accDescr: ReadSample 即使傳回 S_OK 樣本也可能是 NULL,所以要把 HRESULT 與 flags 和 pSample 湊成三件一組確認,並處理尾端與串流空隙的 flag 的圖。
rs1["ReadSample 的結果"] --> h1["查看 HRESULT"]
rs1 --> f1["查看 flags"]
rs1 --> s1["查看 pSample"]
f1 -.-> gap1["可能出現尾端或空隙的 flag"]
s1 -.-> nl1["就算是 S_OK 也可能是 NULL"]
圖 7: 不要只憑成功代碼就取用,要把 HRESULT、flags、pSample 湊成三件一組確認。
4.3. 隨手處理 stride 與上下方向,影像就會亂掉
影像緩衝區不一定是以 width * bytesPerPixel 一路緊密排列。每列結尾可能會有 padding,RGB 系也可能是 bottom-up。Microsoft Learn 的 Image Stride 與 Uncompressed Video Buffers 也把這一點寫得相當清楚。
特別重要的是下面兩點。
IMF2DBuffer::Lock2D會傳回 scan line 0 的起始指標 與 實際的 stride- 在 bottom-up 影像中,stride 有可能變成負值
這次採用 Microsoft Learn 的 helper 想法,最後重新排成 top-down 的連續 BGRA 緩衝區 之後再交給 PNG。先把這裡整理好,儲存端就會單純很多。
flowchart TB
accTitle: 吸收 stride 與方向
accDescr: 用 IMF2DBuffer 的 Lock2D 取得 scan line 0 的起始指標與實際的 stride,連 bottom-up 時 stride 為負的情況也一併重新排成 top-down 的連續 BGRA 緩衝區之後再交給 PNG 的流程的圖。
l2d["用 Lock2D 取得"] --> sl0["scan line 0 的起始指標"]
l2d --> ast1["實際的 stride"]
ast1 -.-> neg1["bottom-up 時可能是負值"]
sl0 --> pack1["重新排成 top-down 的連續 BGRA"]
ast1 --> pack1
pack1 --> sim1["儲存端變得單純"]
圖 8: 用 Lock2D 吸收實際的 stride 與方向,整成 top-down 的連續 BGRA 之後再交給儲存。
4.4. 不要把 MFVideoFormat_RGB32 的第 4 個位元組斷定成 alpha
MFVideoFormat_RGB32 和名字給人的感覺相反,並不是可以直接交給 PNG 的「乾淨 RGBA」。Windows 的 32bit RGB 中,byte 0、1、2 是 B、G、R,byte 3 可能是 alpha,也可能是 ignore。重點在於它不是 ARGB32。
如果把這裡當成 GUID_WICPixelFormat32bppBGRA 直接儲存,第 4 個位元組裡放的可能是 0,影像就會莫名變透明。這次採取的方針是 儲存前把 alpha 填成 0xFF,讓它完全不透明。
flowchart TB
accTitle: RGB32 第 4 個位元組的處理
accDescr: MFVideoFormat_RGB32 的 byte 0 到 2 是 B、G、R,第 4 個位元組不一定是 alpha,直接寫進 PNG 有時會變透明,所以採取儲存前把 alpha 填成 0xFF 讓它不透明的方針的圖。
rgb1["MFVideoFormat_RGB32 的影格"] --> b4["第 4 個位元組不一定是 alpha"]
b4 -->|"直接儲存"| tp2["有時會變成透明 PNG"]
b4 -->|"填成 0xFF 再儲存"| op1["完全不透明的 PNG"]
圖 9: 不要斷定第 4 個位元組的意思,儲存前填成 0xFF 固定為不透明。
5. 實作流程
5.1. 以同步模式建立 Source Reader
這次只要取到一張就好,所以不用非同步回呼,而用同步的 ReadSample。同步模式下 ReadSample 會阻塞到下一個樣本出現為止,但如果只是單次擷取靜態影像,實作會相當直接了當。
建立 Reader 時要做的是這 4 件事。
MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING = TRUE- 先把所有 stream 關掉
- 只開啟
MF_SOURCE_READER_FIRST_VIDEO_STREAM - 把輸出的 type 設成
MFMediaType_Video/MFVideoFormat_RGB32
這樣一來,後段就能以「收到 RGB32 的影格」為前提來寫。
flowchart TB
accTitle: 建立 Reader 時的 4 個步驟
accDescr: 啟用影像處理,先把所有 stream 關掉再只開啟第一個影像 stream,最後把輸出的 type 設成 RGB32,說明建立 Reader 時這 4 個步驟順序的圖。
c1["啟用影像處理"] --> c2["關掉所有 stream"]
c2 --> c3["只開啟第一個影像 stream"]
c3 --> c4["把輸出的 type 設成 RGB32"]
c4 -.-> rdy1["後段可以用 RGB32 為前提來寫"]
圖 10: 依序做完建立 Reader 的 4 個步驟,後段就只剩下接收 RGB32 的影格。
5.2. seek 之後一邊看 timestamp 一邊逼近
SetCurrentPosition 之後不要馬上儲存。用 ReadSample 一邊讀樣本,一邊比較 target 之前的最後一張,以及跨過 target 的第一張。
多做這一步,就能吸收掉相當多 seek 的粗糙。
sequenceDiagram
accTitle: seek 之後比較前後的對話
accDescr: 應用程式用 SetCurrentPosition 做 seek 之後,反覆呼叫 ReadSample 查看 timestamp,等跨過 target 時比較前後的樣本並挑出比較接近的那一個的流程的圖。
participant A as 應用程式
participant R as Source Reader
A->>R: SetCurrentPosition(target)
loop 直到跨過 target 為止
A->>R: ReadSample
R-->>A: 樣本與 timestamp
end
A->>A: 比較前後的樣本,採用比較接近的那一個
圖 11: 不要在 seek 之後馬上儲存,先讀到跨過 target 為止,再比較前後。
5.3. 把樣本整成 top-down BGRA
取出的樣本不直接寫進 PNG,先重新排進 top-down 的 BGRA 緩衝區。
- 用
ConvertToContiguousBuffer併成單一緩衝區 - 用
BufferLockhelper 取得 scan line 0 與 actual stride - 逐列複製到 top-down 緩衝區
- 把 alpha 設成
0xFF
這樣儲存端就能把它當成「單純的 32bpp BGRA 影像」處理。
flowchart TB
accTitle: 從樣本整成 BGRA 的步驟
accDescr: 用 ConvertToContiguousBuffer 併成單一緩衝區,用 BufferLock 取得 scan line 0 與實際的 stride,逐列複製到 top-down 緩衝區再把 alpha 設成 0xFF 的整形步驟的圖。
cv1["ConvertToContiguousBuffer"] --> bl1["用 BufferLock 取得 scan line 0 與 stride"]
bl1 --> rc1["逐列複製到 top-down"]
rc1 --> al1["把 alpha 設成 0xFF"]
al1 --> out1["單純的 32bpp BGRA 影像"]
圖 12: 經過這 4 個階段的整形,儲存端就能把它當成單純的 32bpp BGRA 影像處理。
5.4. PNG 儲存交給 WIC
儲存使用 WIC 的 IWICBitmapEncoder / IWICBitmapFrameEncode。分工是 Media Foundation 取得影格,WIC 產生影像。這一段只用 Windows 的標準 API 就能完成。
flowchart LR
accTitle: Media Foundation 與 WIC 的分工
accDescr: Media Foundation 負責取得影格,WIC 負責產生影像並寫出 PNG,說明兩者分工的圖。
mf1["Media Foundation"] -->|"取得影格"| fr1["BGRA 影格"]
fr1 -->|"WIC 產生影像"| pg1["PNG 檔案"]
圖 13: 影格取得交給 Media Foundation,產生影像交給 WIC,只用標準 API 就能完成。
6. 實務上的檢查清單
| 項目 | 要看的地方 | 漏掉之後容易發生的事 |
|---|---|---|
| seek 精度 | 不要在 SetCurrentPosition 之後只讀一次就定案 |
存下比指定時間早很多的影格 |
| 樣本為 NULL | HRESULT、flags、pSample 全部都看 |
在尾端或 stream tick 時對 null 解參考 |
| stride | 吸收 actual stride 與上下方向 | 影像亂掉、上下顛倒 |
| RGB32 的第 4 個位元組 | 把 alpha 設成 0xFF |
變成透明 PNG |
| 時間範圍 | 遵守 0 <= target < duration |
在尾端附近出現非預期的行為 |
| 連續擷取 | 不重建 Reader,反覆 seek | 白白變慢 |
| 複製次數 | 大量處理時要留意 ConvertToContiguousBuffer 的成本 |
多耗掉 CPU 與記憶體頻寬 |
| 格式變化 | 中途會改變解析度的特殊影片要另外設計 | 寬度與高度的前提被打破 |
其中「連續擷取」與「複製次數」這兩列,不是抽一張本身的問題,而是擴大到從同一部影片抽出幾十張時才會顯現的項目。這次的範例是取一張就結束的構成,所以要抽多張時,請改成不重建 Source Reader,而是反覆呼叫 SetCurrentPosition 與 ReadSample。包含 Source Reader 以外的選項(Media Session 等)在內的 Media Foundation 全貌地圖,放在 Media Foundation 入門 - 從 COM 的角度理解 API。
flowchart TB
accTitle: 從抽一張擴大到連續擷取的做法
accDescr: 只抽一張的話維持這次的構成即可,要從同一部影片抽多張時就改成不重建 Source Reader 而反覆 seek 與 ReadSample 的擴大做法的圖。
q3["要抽幾張"] -->|"只抽一張"| as1["維持這次的構成"]
q3 -->|"抽多張"| rp1["不重建 Reader,反覆 seek 與讀取"]
rp1 -.-> cost1["也要留意複製次數的成本"]
圖 14: 擴大成抽多張時,要改成不重建 Reader,反覆做 seek 與讀取。
7. 建置與執行的筆記
本文最後的程式碼,做成了 容易以單一 .cpp 加進 Visual Studio C++ 主控台應用程式的形式。環境本身的前提整理在 2.2。
7.1. 建置的筆記
列出先掌握就會比較輕鬆的幾點。
- 因為已經放了
#pragma comment(lib, ...),基本上不需要額外的連結器設定 - 因為用的是
wmain,命令列引數可以維持 Unicode 直接處理 - 為了讓預設的 Console App 範本裡有
pch.h或stdafx.h時也好貼上,程式碼開頭用__has_include把它們撈進來 - 即使如此,如果專案端強制使用自訂的先行編譯標頭檔,只要把這個
.cpp設成「不使用先行編譯標頭檔」就能通過 - 執行組態建議用 x64
7.2. 執行方式,以及成功時會出現什麼
用法是 ExtractFrameFromMp4.exe <input.mp4> <seconds> <output.png>。例如像 ExtractFrameFromMp4.exe C:\work\input.mp4 12.345 C:\work\frame.png 這樣執行。
成功時,wmain 的最後會往標準輸出印出下面 3 行。
Saved: C:\work\frame.png
Requested: 12.345 sec
Actual: (採用的影格的顯示時間) sec
Requested 就是傳進引數的秒數本身,Actual 則是實際採用的影格的 timestamp。因為會依 3.2 的規則從前後兩張影格裡挑比較接近的那一張,所以兩者的差會落在影格間隔的大約一半以內(29.97fps 的話,大致的參考上限是 17ms 左右)。如果這裡差到數百 ms 以上,就要懷疑 seek 之後只讀了一次,也就是 4.1 的前後比較沒有生效。
flowchart TB
accTitle: Requested 與 Actual 差距的看法
accDescr: Requested 與 Actual 的差落在影格間隔一半以內就算正常,差到數百毫秒以上就要懷疑 seek 之後的前後比較沒有生效的診斷流程的圖。
dfc["查看 Requested 與 Actual 的差"] -->|"在影格間隔的一半以內"| okd["前後比較有生效"]
dfc -->|"數百 ms 以上"| ngd["懷疑前後比較沒有生效"]
ngd -.-> ck2["檢查是不是 seek 之後只讀了一次"]
圖 15: 差距如果大幅超過影格間隔的一半,就要懷疑前後比較的實作。
驗證動作的目標是這 3 點。
- 結束代碼是 0,而且
Saved:那一行印出指定的輸出路徑 - 打開輸出的 PNG,該時間點的畫面會以正確方向(上下沒有顛倒)顯示
- PNG 的寬度與高度和原影片的解析度一致,背景沒有透出來(4.4 的 alpha 填值有生效)
失敗時會往標準錯誤輸出印出 Failed. HRESULT = 0x........。這個值依 2.1 的要領來讀。指定的秒數大於等於影片長度時,會是 0x80070057(E_INVALIDARG)。
flowchart TB
accTitle: 執行結果的確認流程
accDescr: 執行成功就確認 3 行輸出與 PNG 的方向、解析度、不透明,失敗就讀標準錯誤輸出的 HRESULT,指定秒數大於等於影片長度時會是 E_INVALIDARG 的確認流程的圖。
run1["執行"] -->|"成功"| ok3["確認 3 行輸出與 PNG"]
run1 -->|"失敗"| er2["讀標準錯誤的 HRESULT"]
er2 -.-> iv1["秒數大於等於影片長度就是 E_INVALIDARG"]
圖 16: 成功就確認輸出與 PNG 這 3 點,失敗就從 HRESULT 的值讀出原因。
8. 總結
用 Media Foundation 從 MP4 取出指定時間的靜態影像時,只盯著 SetCurrentPosition 和 ReadSample 是不夠的。實際上,
- seek 不是 exact
- 影格最好看著 timestamp 做前後比較
ReadSample就算成功也可能沒有樣本- 吸收
stride與影像方向之後再儲存 - 不要把
RGB32的第 4 個位元組斷定成 alpha
把這些都掌握住,就相當不容易出事。
flowchart TB
accTitle: 要掌握的 5 點
accDescr: seek 不是 exact、用 timestamp 做前後比較、確認成功時也可能沒有樣本、吸收 stride 與方向、不要斷定 RGB32 的第 4 個位元組,掌握這 5 點就不容易出事的圖。
k1["seek 不是 exact"] --> k2["用 timestamp 做前後比較"]
k2 --> k3["確認成功時也可能沒有樣本"]
k3 --> k4["吸收 stride 與方向"]
k4 --> k5["不要斷定第 4 個位元組"]
k5 --> safe2["不容易出事的實作"]
圖 17: 這 5 個掌握點都到位,指定時間的靜態影像擷取就相當不容易出事。
這次的範例是把範圍縮到 好好抽出一張 的最小構成。像產生縮圖、儲存監視錄影畫面的代表影格、輸出檢驗日誌的佐證資料這些場合,都可以直接搬過去用。
9. 參考資料
- 本文的範例程式碼整套: media-foundation-extract-still-image-from-mp4-at-specific-time - komurasoft-blog-samples (GitHub)
- Microsoft Learn: Using the Source Reader to Process Media Data
- Microsoft Learn:
IMFSourceReader::SetCurrentPosition - Microsoft Learn:
IMFSourceReader::ReadSample - Microsoft Learn:
IMFSourceReader::SetCurrentMediaType - Microsoft Learn:
IMF2DBuffer - Microsoft Learn:
IMF2DBuffer::Lock2D - Microsoft Learn: Uncompressed Video Buffers
- Microsoft Learn: Image Stride
- Microsoft Learn: MF_MT_FRAME_SIZE attribute
- Microsoft Learn: MF_MT_DEFAULT_STRIDE attribute
- Microsoft Learn: Native pixel formats overview (WIC)
- Microsoft Learn: Uncompressed RGB Video Subtypes
10. 可直接貼進 .cpp 的完整程式碼
下面這一整塊,就是設定成直接帶進 Visual Studio C++ 主控台應用程式專案的程式碼。命令列引數的順序是 input.mp4、seconds、output.png。因為做成單檔完成的構成,所以很好貼進專案。
因為是很長的一整塊,先把哪個函式對應到本文哪一節放在前面。不論是閱讀時,還是動不起來要追查時,從這張對照表切入都比較快。
| 程式碼中的函式與類別 | 對應的本文 | 角色,以及處理的陷阱 |
|---|---|---|
MediaFoundationScope |
5.1 | 把 CoInitializeEx 與 MFStartup 的初始化和收尾整合起來 |
CreateConfiguredSourceReader |
5.1 | 建立 Reader,只選影像串流,並要求 MFVideoFormat_RGB32 |
GetPresentationDuration |
6.(時間範圍) | 取得影片長度,確認是否滿足 0 <= target < duration |
SeekSourceReader |
4.1 / 5.2 | 用 SetCurrentPosition 做 seek。光靠這裡不會是 exact |
ReadNearestVideoSample |
3.2 / 4.1 / 4.2 / 5.2 | 比較 target 的前後挑出一張。flags 與 pSample 為 null 也在這裡處理 |
GetDefaultStride |
4.3 | 沒有 MF_MT_DEFAULT_STRIDE 時計算 stride 補上 |
BufferLock |
4.3 / 5.3 | 用 IMF2DBuffer::Lock2D 取得 scan line 0 與實際的 stride |
CopyContiguousBufferToTopDownBgra |
4.3 / 4.4 / 5.3 | 逐列重新排進 top-down 緩衝區,並把第 4 個位元組填成 0xFF |
CopySampleToTopDownBgra |
5.3 | 取出 frame size 與 stride,再呼叫上面的複製 |
SaveBgraToPng |
5.4 | 用 WIC 把 32bpp BGRA 寫出成 PNG |
ExtractFrameFromMp4ToPng |
5. 整體 | 依序呼叫上述函式的入口 |
TryParseSeconds / wmain |
7.2 | 解析引數,並顯示 Requested / Actual |
#define NOMINMAX
#if defined(_MSC_VER)
# if __has_include("pch.h")
# include "pch.h"
# elif __has_include("stdafx.h")
# include "stdafx.h"
# endif
#endif
#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mferror.h>
#include <mfobjects.h>
#include <propvarutil.h>
#include <wincodec.h>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cwchar>
#include <cmath>
#include <cstring>
#include <limits>
#include <vector>
#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfreadwrite.lib")
#pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "ole32.lib")
#pragma comment(lib, "propsys.lib")
#pragma comment(lib, "windowscodecs.lib")
template <class T>
void SafeRelease(T** pp)
{
if (pp != nullptr && *pp != nullptr)
{
(*pp)->Release();
*pp = nullptr;
}
}
class MediaFoundationScope
{
public:
MediaFoundationScope() : m_comInitialized(false), m_mfStarted(false)
{
}
HRESULT Initialize()
{
HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
if (hr == RPC_E_CHANGED_MODE)
{
return hr;
}
if (SUCCEEDED(hr))
{
m_comInitialized = true;
}
hr = MFStartup(MF_VERSION);
if (FAILED(hr))
{
if (m_comInitialized)
{
CoUninitialize();
m_comInitialized = false;
}
return hr;
}
m_mfStarted = true;
return S_OK;
}
~MediaFoundationScope()
{
if (m_mfStarted)
{
MFShutdown();
}
if (m_comInitialized)
{
CoUninitialize();
}
}
private:
bool m_comInitialized;
bool m_mfStarted;
};
HRESULT GetPresentationDuration(IMFSourceReader* pReader, LONGLONG* phnsDuration)
{
if (pReader == nullptr || phnsDuration == nullptr)
{
return E_POINTER;
}
PROPVARIANT var;
PropVariantInit(&var);
HRESULT hr = pReader->GetPresentationAttribute(
MF_SOURCE_READER_MEDIASOURCE,
MF_PD_DURATION,
&var);
if (SUCCEEDED(hr))
{
hr = PropVariantToInt64(var, phnsDuration);
}
PropVariantClear(&var);
return hr;
}
HRESULT GetDefaultStride(IMFMediaType* pType, LONG* plStride)
{
if (pType == nullptr || plStride == nullptr)
{
return E_POINTER;
}
LONG lStride = 0;
HRESULT hr = pType->GetUINT32(
MF_MT_DEFAULT_STRIDE,
reinterpret_cast<UINT32*>(&lStride));
if (FAILED(hr))
{
GUID subtype = GUID_NULL;
UINT32 width = 0;
UINT32 height = 0;
hr = pType->GetGUID(MF_MT_SUBTYPE, &subtype);
if (FAILED(hr))
{
return hr;
}
hr = MFGetAttributeSize(pType, MF_MT_FRAME_SIZE, &width, &height);
if (FAILED(hr))
{
return hr;
}
hr = MFGetStrideForBitmapInfoHeader(subtype.Data1, width, &lStride);
if (FAILED(hr))
{
return hr;
}
(void)pType->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(lStride));
}
*plStride = lStride;
return S_OK;
}
class BufferLock
{
public:
explicit BufferLock(IMFMediaBuffer* pBuffer)
: m_pBuffer(pBuffer),
m_p2DBuffer(nullptr),
m_locked(false)
{
if (m_pBuffer != nullptr)
{
m_pBuffer->AddRef();
(void)m_pBuffer->QueryInterface(IID_PPV_ARGS(&m_p2DBuffer));
}
}
~BufferLock()
{
UnlockBuffer();
SafeRelease(&m_p2DBuffer);
SafeRelease(&m_pBuffer);
}
HRESULT LockBuffer(
LONG defaultStride,
DWORD heightInPixels,
BYTE** ppScanLine0,
LONG* plStride)
{
if (ppScanLine0 == nullptr || plStride == nullptr)
{
return E_POINTER;
}
*ppScanLine0 = nullptr;
*plStride = 0;
HRESULT hr = S_OK;
if (m_p2DBuffer != nullptr)
{
hr = m_p2DBuffer->Lock2D(ppScanLine0, plStride);
}
else
{
BYTE* pData = nullptr;
hr = m_pBuffer->Lock(&pData, nullptr, nullptr);
if (SUCCEEDED(hr))
{
*plStride = defaultStride;
if (defaultStride < 0)
{
const size_t strideAbs = static_cast<size_t>(-defaultStride);
*ppScanLine0 = pData + strideAbs * (heightInPixels - 1);
}
else
{
*ppScanLine0 = pData;
}
}
}
m_locked = SUCCEEDED(hr);
return hr;
}
void UnlockBuffer()
{
if (!m_locked)
{
return;
}
if (m_p2DBuffer != nullptr)
{
(void)m_p2DBuffer->Unlock2D();
}
else if (m_pBuffer != nullptr)
{
(void)m_pBuffer->Unlock();
}
m_locked = false;
}
private:
IMFMediaBuffer* m_pBuffer;
IMF2DBuffer* m_p2DBuffer;
bool m_locked;
};
HRESULT CreateConfiguredSourceReader(PCWSTR inputPath, IMFSourceReader** ppReader)
{
if (inputPath == nullptr || ppReader == nullptr)
{
return E_POINTER;
}
*ppReader = nullptr;
IMFAttributes* pAttributes = nullptr;
IMFSourceReader* pReader = nullptr;
IMFMediaType* pRequestedType = nullptr;
HRESULT hr = MFCreateAttributes(&pAttributes, 1);
if (FAILED(hr))
{
goto done;
}
hr = pAttributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE);
if (FAILED(hr))
{
goto done;
}
hr = MFCreateSourceReaderFromURL(inputPath, pAttributes, &pReader);
if (FAILED(hr))
{
goto done;
}
hr = pReader->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
if (FAILED(hr))
{
goto done;
}
hr = pReader->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
if (FAILED(hr))
{
goto done;
}
hr = MFCreateMediaType(&pRequestedType);
if (FAILED(hr))
{
goto done;
}
hr = pRequestedType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
if (FAILED(hr))
{
goto done;
}
hr = pRequestedType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
if (FAILED(hr))
{
goto done;
}
hr = pReader->SetCurrentMediaType(
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
nullptr,
pRequestedType);
if (FAILED(hr))
{
goto done;
}
*ppReader = pReader;
pReader = nullptr;
done:
SafeRelease(&pRequestedType);
SafeRelease(&pReader);
SafeRelease(&pAttributes);
return hr;
}
HRESULT SeekSourceReader(IMFSourceReader* pReader, LONGLONG targetHns)
{
if (pReader == nullptr)
{
return E_POINTER;
}
PROPVARIANT var;
PropVariantInit(&var);
HRESULT hr = InitPropVariantFromInt64(targetHns, &var);
if (SUCCEEDED(hr))
{
hr = pReader->SetCurrentPosition(GUID_NULL, var);
}
PropVariantClear(&var);
return hr;
}
HRESULT ReadNearestVideoSample(
IMFSourceReader* pReader,
LONGLONG targetHns,
IMFSample** ppSample,
LONGLONG* pChosenTimestampHns)
{
if (pReader == nullptr || ppSample == nullptr)
{
return E_POINTER;
}
*ppSample = nullptr;
if (pChosenTimestampHns != nullptr)
{
*pChosenTimestampHns = 0;
}
IMFSample* pBefore = nullptr;
LONGLONG beforeTimestamp = 0;
bool hasBefore = false;
HRESULT hr = S_OK;
for (;;)
{
IMFSample* pCurrent = nullptr;
DWORD flags = 0;
LONGLONG currentTimestamp = 0;
LONGLONG diffBefore = 0;
LONGLONG diffCurrent = 0;
hr = pReader->ReadSample(
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
0,
nullptr,
&flags,
¤tTimestamp,
&pCurrent);
if (FAILED(hr))
{
SafeRelease(&pCurrent);
break;
}
if ((flags & MF_SOURCE_READERF_ENDOFSTREAM) != 0)
{
SafeRelease(&pCurrent);
if (hasBefore)
{
*ppSample = pBefore;
pBefore = nullptr;
if (pChosenTimestampHns != nullptr)
{
*pChosenTimestampHns = beforeTimestamp;
}
hr = S_OK;
}
else
{
hr = MF_E_END_OF_STREAM;
}
break;
}
if ((flags & MF_SOURCE_READERF_STREAMTICK) != 0)
{
SafeRelease(&pCurrent);
continue;
}
if (pCurrent == nullptr)
{
continue;
}
if (currentTimestamp < targetHns)
{
SafeRelease(&pBefore);
pBefore = pCurrent;
pCurrent = nullptr;
beforeTimestamp = currentTimestamp;
hasBefore = true;
continue;
}
if (hasBefore)
{
diffBefore = targetHns - beforeTimestamp;
diffCurrent = currentTimestamp - targetHns;
if (diffBefore <= diffCurrent)
{
*ppSample = pBefore;
pBefore = nullptr;
if (pChosenTimestampHns != nullptr)
{
*pChosenTimestampHns = beforeTimestamp;
}
SafeRelease(&pCurrent);
}
else
{
*ppSample = pCurrent;
pCurrent = nullptr;
if (pChosenTimestampHns != nullptr)
{
*pChosenTimestampHns = currentTimestamp;
}
}
}
else
{
*ppSample = pCurrent;
pCurrent = nullptr;
if (pChosenTimestampHns != nullptr)
{
*pChosenTimestampHns = currentTimestamp;
}
}
hr = S_OK;
break;
}
SafeRelease(&pBefore);
return hr;
}
HRESULT CopyContiguousBufferToTopDownBgra(
IMFMediaBuffer* pBuffer,
LONG defaultStride,
UINT32 width,
UINT32 height,
std::vector<BYTE>& pixels,
UINT32* pStride)
{
if (pBuffer == nullptr || pStride == nullptr)
{
return E_POINTER;
}
BufferLock lock(pBuffer);
BYTE* pScanLine0 = nullptr;
LONG actualStride = 0;
HRESULT hr = lock.LockBuffer(defaultStride, height, &pScanLine0, &actualStride);
if (FAILED(hr))
{
return hr;
}
if (width > (std::numeric_limits<UINT32>::max() / 4))
{
return E_INVALIDARG;
}
const UINT32 destStride = width * 4;
const LONG actualStrideAbs = (actualStride < 0) ? -actualStride : actualStride;
if (actualStrideAbs < static_cast<LONG>(destStride))
{
return E_UNEXPECTED;
}
pixels.resize(static_cast<size_t>(destStride) * height);
BYTE* pDestRow = pixels.data();
BYTE* pSrcRow = pScanLine0;
for (UINT32 y = 0; y < height; ++y)
{
std::memcpy(pDestRow, pSrcRow, destStride);
// MFVideoFormat_RGB32 的第 4 個 byte 不一定是 alpha,
// 所以在儲存成 PNG 之前固定為不透明。
for (UINT32 x = 0; x < width; ++x)
{
pDestRow[static_cast<size_t>(x) * 4 + 3] = 0xFF;
}
pDestRow += destStride;
pSrcRow += actualStride;
}
*pStride = destStride;
return S_OK;
}
HRESULT CopySampleToTopDownBgra(
IMFSample* pSample,
IMFMediaType* pCurrentType,
std::vector<BYTE>& pixels,
UINT32* pWidth,
UINT32* pHeight,
UINT32* pStride)
{
if (pSample == nullptr || pCurrentType == nullptr ||
pWidth == nullptr || pHeight == nullptr || pStride == nullptr)
{
return E_POINTER;
}
*pWidth = 0;
*pHeight = 0;
*pStride = 0;
IMFMediaBuffer* pBuffer = nullptr;
GUID subtype = GUID_NULL;
UINT32 width = 0;
UINT32 height = 0;
LONG defaultStride = 0;
HRESULT hr = pCurrentType->GetGUID(MF_MT_SUBTYPE, &subtype);
if (FAILED(hr))
{
goto done;
}
if (!IsEqualGUID(subtype, MFVideoFormat_RGB32))
{
hr = MF_E_INVALIDMEDIATYPE;
goto done;
}
hr = MFGetAttributeSize(pCurrentType, MF_MT_FRAME_SIZE, &width, &height);
if (FAILED(hr))
{
goto done;
}
if (width == 0 || height == 0)
{
hr = E_UNEXPECTED;
goto done;
}
hr = GetDefaultStride(pCurrentType, &defaultStride);
if (FAILED(hr))
{
goto done;
}
hr = pSample->ConvertToContiguousBuffer(&pBuffer);
if (FAILED(hr))
{
goto done;
}
hr = CopyContiguousBufferToTopDownBgra(
pBuffer,
defaultStride,
width,
height,
pixels,
pStride);
if (FAILED(hr))
{
goto done;
}
*pWidth = width;
*pHeight = height;
hr = S_OK;
done:
SafeRelease(&pBuffer);
return hr;
}
HRESULT SaveBgraToPng(
PCWSTR outputPath,
const BYTE* pixels,
UINT32 width,
UINT32 height,
UINT32 stride)
{
if (outputPath == nullptr || pixels == nullptr)
{
return E_POINTER;
}
if (width == 0 || height == 0 || stride < width * 4)
{
return E_INVALIDARG;
}
const size_t bufferSizeSizeT = static_cast<size_t>(stride) * height;
if (bufferSizeSizeT > static_cast<size_t>(std::numeric_limits<UINT>::max()))
{
return E_INVALIDARG;
}
const UINT bufferSize = static_cast<UINT>(bufferSizeSizeT);
IWICImagingFactory* pFactory = nullptr;
IWICStream* pStream = nullptr;
IWICBitmapEncoder* pEncoder = nullptr;
IWICBitmapFrameEncode* pFrame = nullptr;
IPropertyBag2* pProps = nullptr;
WICPixelFormatGUID pixelFormat = GUID_WICPixelFormat32bppBGRA;
HRESULT hr = CoCreateInstance(
CLSID_WICImagingFactory,
nullptr,
CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(&pFactory));
if (FAILED(hr))
{
goto done;
}
hr = pFactory->CreateStream(&pStream);
if (FAILED(hr))
{
goto done;
}
hr = pStream->InitializeFromFilename(outputPath, GENERIC_WRITE);
if (FAILED(hr))
{
goto done;
}
hr = pFactory->CreateEncoder(GUID_ContainerFormatPng, nullptr, &pEncoder);
if (FAILED(hr))
{
goto done;
}
hr = pEncoder->Initialize(pStream, WICBitmapEncoderNoCache);
if (FAILED(hr))
{
goto done;
}
hr = pEncoder->CreateNewFrame(&pFrame, &pProps);
if (FAILED(hr))
{
goto done;
}
hr = pFrame->Initialize(pProps);
if (FAILED(hr))
{
goto done;
}
hr = pFrame->SetSize(width, height);
if (FAILED(hr))
{
goto done;
}
hr = pFrame->SetPixelFormat(&pixelFormat);
if (FAILED(hr))
{
goto done;
}
if (!IsEqualGUID(pixelFormat, GUID_WICPixelFormat32bppBGRA))
{
hr = WINCODEC_ERR_UNSUPPORTEDPIXELFORMAT;
goto done;
}
hr = pFrame->WritePixels(
height,
stride,
bufferSize,
const_cast<BYTE*>(pixels));
if (FAILED(hr))
{
goto done;
}
hr = pFrame->Commit();
if (FAILED(hr))
{
goto done;
}
hr = pEncoder->Commit();
done:
SafeRelease(&pProps);
SafeRelease(&pFrame);
SafeRelease(&pEncoder);
SafeRelease(&pStream);
SafeRelease(&pFactory);
return hr;
}
HRESULT ExtractFrameFromMp4ToPng(
PCWSTR inputPath,
LONGLONG targetHns,
PCWSTR outputPath,
LONGLONG* pActualTimestampHns)
{
if (inputPath == nullptr || outputPath == nullptr)
{
return E_POINTER;
}
if (targetHns < 0)
{
return E_INVALIDARG;
}
MediaFoundationScope mf;
HRESULT hr = mf.Initialize();
if (FAILED(hr))
{
return hr;
}
IMFSourceReader* pReader = nullptr;
IMFMediaType* pCurrentType = nullptr;
IMFSample* pChosenSample = nullptr;
LONGLONG durationHns = 0;
UINT32 width = 0;
UINT32 height = 0;
UINT32 stride = 0;
std::vector<BYTE> pixels;
hr = CreateConfiguredSourceReader(inputPath, &pReader);
if (FAILED(hr))
{
goto done;
}
hr = pReader->GetCurrentMediaType(
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
&pCurrentType);
if (FAILED(hr))
{
goto done;
}
hr = GetPresentationDuration(pReader, &durationHns);
if (FAILED(hr))
{
goto done;
}
if (targetHns >= durationHns)
{
hr = E_INVALIDARG;
goto done;
}
hr = SeekSourceReader(pReader, targetHns);
if (FAILED(hr))
{
goto done;
}
hr = ReadNearestVideoSample(
pReader,
targetHns,
&pChosenSample,
pActualTimestampHns);
if (FAILED(hr))
{
goto done;
}
hr = CopySampleToTopDownBgra(
pChosenSample,
pCurrentType,
pixels,
&width,
&height,
&stride);
if (FAILED(hr))
{
goto done;
}
hr = SaveBgraToPng(outputPath, pixels.data(), width, height, stride);
done:
SafeRelease(&pChosenSample);
SafeRelease(&pCurrentType);
SafeRelease(&pReader);
return hr;
}
bool TryParseSeconds(PCWSTR text, LONGLONG* phns)
{
if (text == nullptr || phns == nullptr)
{
return false;
}
wchar_t* end = nullptr;
errno = 0;
const double seconds = std::wcstod(text, &end);
if (end == text || *end != L'\0' || errno != 0)
{
return false;
}
if (!std::isfinite(seconds) || seconds < 0.0)
{
return false;
}
const long double hns =
static_cast<long double>(seconds) * 10000000.0L;
if (hns < 0.0L ||
hns > static_cast<long double>(std::numeric_limits<LONGLONG>::max()))
{
return false;
}
*phns = static_cast<LONGLONG>(std::llround(hns));
return true;
}
double HnsToSeconds(LONGLONG hns)
{
return static_cast<double>(hns) / 10000000.0;
}
void PrintUsage()
{
std::fwprintf(stderr, L"Usage:\n");
std::fwprintf(stderr, L" ExtractFrameFromMp4.exe <input.mp4> <seconds> <output.png>\n");
std::fwprintf(stderr, L"\nExample:\n");
std::fwprintf(stderr, L" ExtractFrameFromMp4.exe input.mp4 12.345 output.png\n");
}
int wmain(int argc, wchar_t* argv[])
{
if (argc != 4)
{
PrintUsage();
return 1;
}
LONGLONG targetHns = 0;
if (!TryParseSeconds(argv[2], &targetHns))
{
std::fwprintf(stderr, L"Invalid seconds: %ls\n", argv[2]);
return 1;
}
LONGLONG actualHns = 0;
HRESULT hr = ExtractFrameFromMp4ToPng(
argv[1],
targetHns,
argv[3],
&actualHns);
if (FAILED(hr))
{
std::fwprintf(stderr, L"Failed. HRESULT = 0x%08lX\n", static_cast<unsigned long>(hr));
return 1;
}
std::wprintf(L"Saved: %ls\n", argv[3]);
std::wprintf(L"Requested: %.3f sec\n", HnsToSeconds(targetHns));
std::wprintf(L"Actual: %.3f sec\n", HnsToSeconds(actualHns));
return 0;
}
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
用 Media Foundation 把圖片與文字烙印進 MP4 影格的方法
本文梳理如何用 Media Foundation 把圖片與文字烙印到 MP4 影片的每一個影格,再產出一支新的 MP4,並以 Source Reader、繪製、色彩轉換、Sink Writer 的分工,以及單一檔案完結的 C++ 範例來說明。
在 Media Foundation 中把 YUV 轉換成 RGB 的方法
本文從 Source Reader 的自動轉換、NV12/YUY2 的自行轉換、stride 與色彩空間,梳理在 Media Foundation 中把 YUV 影格轉換成 RGB 的方法。
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、Source Reader、WIC 從影片取出靜態影像,接近 Windows 應用程式開發的實作主題。
技術諮詢 & 設計審查
如果想在實作前先梳理 seek 精度、緩衝區格式、stride、影像方向等問題,可以用技術諮詢與設計審查的形式,從釐清方向開始。
常見問題
整理諮詢這個主題時常見的問題。
- 要從 MP4 取出指定時間的靜態影像,該用什麼?
- 只抽一張的話,比起 Media Session,IMFSourceReader 當入口更直接了當。流程是用 MFCreateSourceReaderFromURL 開啟檔案,用 SetStreamSelection 只選影像串流,要求 MFVideoFormat_RGB32,用 SetCurrentPosition 做 seek,再用 ReadSample 取得影格,最後用 WIC 存成 PNG。不需要外部函式庫,只用 Windows 標準 API 就能完成。
- SetCurrentPosition 能不能正好移動到指定時間?
- 不能。IMFSourceReader::SetCurrentPosition 不保證 exact seeking,影片通常會落在指定位置稍前,尤其會偏向 key frame 那一側。必須在 seek 之後繼續 ReadSample,一邊看 timestamp,一邊比較 target 之前的最後一個樣本與 target 之後的第一個樣本,採用比較接近的那一個。seek 之後只讀一次就儲存的實作,在 GOP 長的影片上很容易偏掉。
- 為什麼存出來的 PNG 會變透明?
- 因為 MFVideoFormat_RGB32 的第 4 個位元組不一定是 alpha。Windows 的 32bit RGB 中,byte 0、1、2 是 B、G、R,byte 3 可能是 alpha,也可能是 ignore(並不是 ARGB32)。直接寫進 PNG 有時會變成透明影像,所以在儲存前把第 4 個位元組填成 0xFF 讓它不透明比較安全。
- 影像亂掉或上下顛倒的原因是什麼?
- 原因在於 stride 與上下方向的處理。影像緩衝區不一定是以 width×bytesPerPixel 一路緊密排列,每列結尾可能有 padding,RGB 系也可能是 bottom-up(stride 為負)。用 IMF2DBuffer::Lock2D 取得 scan line 0 的起始指標與實際的 stride,先重新排成 top-down 的連續 BGRA 緩衝區再交給 PNG,儲存端會變單純,也能避免影像亂掉。