איך לחלץ תמונת סטילס מ-MP4 בזמן נתון עם Media Foundation
· עודכן בתאריך: · Go Komura · Media Foundation, C++, פיתוח Windows, WIC
יש דרישה נפוצה למדי לחלץ מ-MP4 “תמונה אחת מהנקודה של 12.3 שניות”. יצירת thumbnail, לוג בדיקה, מסגרת נציגה מווידאו ניטור, תיעוד ללוג של מכשיר — כל אלה מקרים כאלה.
עם זאת, ב-Media Foundation זה קצת פחות פשוט ממה שנראה. קריאה בודדת ל-ReadSample אחרי SetCurrentPosition נראית כאילו מסיימת את העבודה, אבל בפועל מעורבים כאן key frame, timestamp, stride, כיוון האנכי של התמונה, והבייט הרביעי של RGB32. אם מתקדמים ברשלנות, קורים תקלות מעצבנות במידה שקטה — הזמן זז מעט, התמונה הפוכה, ה-PNG יוצא שקוף באופן משונה.
flowchart TB
accTitle: תקלות שקורות בהתקדמות רשלנית
accDescr: תרשים המראה שמימוש של seek וקריאה פעם אחת בלבד לפני שמירה מערב key frame, timestamp, stride וכיוון אנכי, והבייט הרביעי של RGB32, וגורם לזמן זז, תמונה הפוכה ו-PNG שקוף.
rough1["seek וקריאה פעם אחת לפני שמירה"] --> tz1["הזמן זז מעט"]
rough1 --> ud1["התמונה יוצאת הפוכה"]
rough1 --> tp1["ה-PNG יוצא שקוף באופן משונה"]
איור 1: מימוש שנראה כמסיים את העבודה במבט ראשון, מוביל לתקלות שקטות — זמן זז, תמונה הפוכה ו-PNG שקוף.
התמונה הכוללת של Media Foundation עצמה נדונה כבר במאמר מבוא ל-Media Foundation - הבנת ה-API מנקודת מבט של COM שכתבנו קודם. הפעם נרד רמה אחת ונתמקד רק בחילוץ תמונה אחת מתוך MP4.
במאמר הזה נסדר, באמצעות IMFSourceReader, את הדרך לחלץ תמונת סטילס אחת הקרובה ביותר לזמן נתון ולשמור אותה כ-PNG, כולל המלכודות שקל למעוד בהן בעבודה מעשית. ובנוסף, בסוף המאמר מונח קוד שלם בקובץ אחד, שקל להדביק ישירות ל-.cpp בפרויקט יישום קונסולה ב-C++ ב-Visual Studio. אין קטעי קוד מפוזרים במהלך המאמר — מספיק לקחת את הבלוק האחרון ולהריץ.
בנוסף, הקוד שמופיע במאמר הזה זמין גם כסט קוד לדוגמה שלם (יישום קונסולה ב-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. בדרך כלל הוא מתקרב לנקודה קצת לפני היעד, בעיקר לכיוון key frame, ולכן צריך להתקדם עםReadSampleולהשוות את מה שלפני ואחרי הזמן המבוקש -
ReadSampleעלול להצליח וגם להחזירpSample == nullptr. בודקים לא רקHRESULTאלא גםflagsו-pSample - כשמכוונים את סוג המדיה של הפלט ל-
MFVideoFormat_RGB32, קל יותר לשמור - אבל הבייט הרביעי של
RGB32לא בהכרח alpha, ולכן כתיבה ישירה ל-PNG עלולה לגרום לתמונה שקופה. בטוח יותר להכניס0xFFלפני השמירה כדי להפוך לאטום - טיפול רשלני ב-
strideהשורתי ובכיוון top-down / bottom-up גורם לעיוות בתמונה, ולכן ה-sample שהתקבל מסודר תחילה ל-BGRA רציף מסוג top-down לפני העברה ל-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, מקבלים תוצאה יציבה.
מפת הידע של המאמר
המאמר הזה מסביר כיצד לחלץ תמונת סטילס אחת, הקרובה ביותר לזמן מבוקש, מתוך קובץ MP4 באמצעות IMFSourceReader של Media Foundation. ה-seek דרך SetCurrentPosition אינו מדויק ונוטה בדרך כלל לכיוון פריים המפתח הקרוב, כך שבווידאו עם GOP ארוך הסטייה גדולה יותר, ולכן יש צורך לקרוא שוב ושוב עם ReadSample אחרי ה-seek ולהשוות את ה-timestamp שלפני ואחרי. גם כאשר ReadSample מצליח, לעיתים הדגימה עצמה null, ולכן נדרשת בדיקה. את הפריים שמתקבל בפורמט MFVideoFormat_RGB32 יש לעבד עם IMF2DBuffer::Lock2D כדי לטפל נכון ב-stride ובכיוון (top-down מול bottom-up) ולהמיר אותו ל-BGRA מסודר מלמעלה למטה; קיבוע הבייט הרביעי כ-alpha לערך 0xFF, לפני שמירה כ-PNG עם WIC, מונע תקלה שבה מתקבל PNG שקוף באופן לא מכוון.
flowchart LR
accTitle: מפת הידע של חילוץ תמונת סטילס מ-MP4 (Media Foundation)
accDescr: תרשים המראה כיצד seek באמצעות SetCurrentPosition ב-IMFSourceReader נוטה לכיוון פריים המפתח, את הקשר בין אורך ה-GOP להשוואת timestamp לפני ואחרי דרך ReadSample, את הטיפול ב-stride ובכיוון top-down/bottom-up דרך Lock2D, ואת קיבוע הבייט הרביעי של RGB32 כ-alpha לפני שמירה כ-PNG עם WIC.
video_frame_extraction["חילוץ תמונת סטילס מווידאו (מסגרת בזמן נתון)"]
imfsourcereader["IMFSourceReader(Source Reader)"]
media_foundation["Media Foundation"]
source_reader_seek["seek באמצעות SetCurrentPosition"]
readsample["IMFSourceReader::ReadSample"]
group_of_pictures["GOP(Group of Pictures)"]
key_frame["מסגרת מפתח (key frame)"]
video_stride["stride (מספר הבייטים בשורת תמונה)"]
imf2dbuffer_lock2d["IMF2DBuffer::Lock2D"]
top_down_bottom_up_orientation["top-down / bottom-up (כיוון השורות בתמונה)"]
mfvideoformat_rgb32["MFVideoFormat_RGB32"]
unintended_transparent_png["PNG שקוף שלא בכוונה"]
alpha_channel_fixup["קיבוע ערוץ ה-alpha ל-0xFF"]
windows_imaging_component["WIC(Windows Imaging Component)"]
null_sample_result["דגימת null מ-ReadSample"]
video_frame_extraction -->|"משתמש ב"| imfsourcereader
imfsourcereader -->|"מחייב"| media_foundation
imfsourcereader -->|"משתמש ב"| source_reader_seek
source_reader_seek -->|"מחייב"| readsample
group_of_pictures -.->|"עלול לגרום ל"| source_reader_seek
source_reader_seek -->|"משתמש ב"| key_frame
video_frame_extraction -->|"מחייב"| readsample
video_frame_extraction -->|"מחייב"| video_stride
video_stride -->|"משתמש ב"| imf2dbuffer_lock2d
video_stride -->|"מחייב"| top_down_bottom_up_orientation
mfvideoformat_rgb32 -.->|"עלול לגרום ל"| unintended_transparent_png
alpha_channel_fixup -->|"מונע"| unintended_transparent_png
video_frame_extraction -->|"משתמש ב"| windows_imaging_component
video_frame_extraction -->|"משתמש ב"| mfvideoformat_rgb32
readsample -->|"עלול לגרום ל"| null_sample_result
בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 15, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle
2. הנחות היסוד של המאמר הזה
2.1. קהל היעד וידע מקדים
- מניחים היכרות עם קריאה ל-API של COM ב-Windows דרך C++. די בכך שברור התהליך של אתחול עם
CoInitializeExושחרור מצביע ממשק עםRelease - ערך ההחזרה
HRESULTנבדק עם מאקרוSUCCEEDED/FAILED. במקרה כשלון, קוראים את הערך ההקסדצימלי כמו שהוא. ערך שמתחיל ב-0x8007מקורו בשגיאת Win32, כש-16 הביטים התחתונים הם קוד השגיאה של Win32 (0x80070057זהה לערך שלE_INVALIDARG). שגיאות ייחודיות ל-Media Foundation שמתחילות ב-MF_E_מוגדרות ב-mferror.h - אין צורך בהיכרות מוקדמת עם Media Foundation עצמו. התמונה הכוללת מנקודת מבט של COM מרוכזת ב-מבוא ל-Media Foundation - הבנת ה-API מנקודת מבט של COM
2.2. סביבת הפיתוח
| פריט | ההנחה הפעם |
|---|---|
| מערכת הפעלה | Windows 10 / Windows 11 |
| IDE | Visual Studio 2022 (עומס עבודה “פיתוח שולחני עם C++”) |
| SDK | Windows SDK שמגיע עם Visual Studio (כולל header-ים וספריות של Media Foundation ו-WIC) |
| פרויקט | תבנית “יישום קונסולה” של C++ |
| פלטפורמה | x64 |
| ספריות נוספות | אין. הגדרת קישור כמו mfplat.lib נעשית עם #pragma comment(lib, ...) בתוך הקוד |
פרטים על אופן ההדבקה וסביבת ה-precompiled header מרוכזים בפרק “7. הערות על build והרצה”.
2.3. הנחות הקלט והפלט
הנחות היסוד הפעם:
- הקלט הוא קובץ MP4 מקומי
- הפלט הרצוי הוא תמונת סטילס אחת
- לא “בדיוק בזמן הנתון”, אלא “המסגרת הקרובה ביותר לזמן הנתון”
- המימוש הוא
IMFSourceReaderבמצב סינכרוני - פורמט השמירה הוא PNG דרך WIC
- אין שימוש בספריות חיצוניות — הכול נעשה עם ה-API הסטנדרטי של Windows
- מניחים MP4 רגיל שהרזולוציה שלו לא משתנה באמצע
אם רוצים גם ניגון, סנכרון שמע, פס גלילה וממשק שילוב עם UI, יש תכנון אחר, אבל למטרה של חילוץ מסגרת אחת זה ברור למדי.
flowchart TB
accTitle: מבנה ההנחות של המאמר הזה
accDescr: תרשים המראה שקלט MP4 מקומי עובר ב-IMFSourceReader סינכרוני שמוציא את המסגרת הקרובה ביותר לזמן הנתון ושומר אותה כ-PNG עם WIC, כל זאת בלי ספריות חיצוניות.
mp1["MP4 מקומי"] --> rd1["IMFSourceReader במצב סינכרוני"]
rd1 --> nf1["המסגרת הקרובה ביותר לזמן הנתון"]
nf1 --> png1["שמירת PNG עם WIC"]
rd1 -.-> std1["הכול עם ה-API הסטנדרטי של Windows"]
איור 3: מהקלט ועד השמירה עובר דרך הרכב Source Reader סינכרוני, בלי ספריות חיצוניות.
3. הטבלה שכדאי לראות ראשונה
3.1. זרימת התהליך
| מה עושים | ה-API שמשתמשים בו | התפקיד |
|---|---|---|
| פתיחת MP4 | MFCreateSourceReaderFromURL |
יצירת מקור מדיה מתוך קובץ |
| בחירת הווידאו בלבד | SetStreamSelection |
מניעת קריאת השמע |
| המרה ל-RGB32 | SetCurrentMediaType + MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING |
קבלת מסגרת uncompressed שקל לשמור |
| מעבר לזמן הנתון | SetCurrentPosition |
seek ביחידות של 100ns |
| קריאת מסגרת | ReadSample |
קבלת sample מפוענח אחד בכל פעם |
| השוואת הקודם / הבא | timestamp של ה-sample | קביעת המסגרת הקרובה ביותר לזמן הנתון |
| שמירה כ-PNG | WIC | כתיבה לקובץ תמונה |
3.2. כלל ההחלטה שלנו הפעם
גם אם אומרים “תמונת הסטילס של הזמן הנתון”, וידאו הוא לא כמות רציפה אלא מסגרות בדידות, ולכן במימוש נוח לקבוע מראש איזה כלל בוחר את המסגרת האחת.
הפעם נשתמש בכלל הבא:
- מתקדמים עם
ReadSampleאחרי ה-seek - שומרים את ה-sample האחרון עם
timestamp < target - כשמגיע ה-sample הראשון עם
timestamp >= target, משווים את ההפרש בין ה-sample הקודם וה-sample הנוכחי - בוחרים את הקרוב יותר ליעד
כך קל יותר לקבל את המסגרת הקרובה ביותר ליעד, ולא “המסגרת הראשונה אחרי היעד”.
flowchart TB
accTitle: כלל ההחלטה לבחירת המסגרת הקרובה ביותר
accDescr: תרשים המראה שמתקדמים עם ReadSample אחרי seek, שומרים את ה-sample האחרון שלפני היעד, וכשמגיע ה-sample הראשון אחרי היעד משווים את שני ההפרשים ובוחרים את הקרוב יותר.
adv1["התקדמות עם ReadSample אחרי seek"] --> bf1["שמירת ה-sample האחרון שלפני היעד"]
bf1 --> af1["הגעת ה-sample הראשון אחרי היעד"]
af1 --> df1["השוואת שני ההפרשים מהיעד"]
df1 --> pk1["בחירת הקרוב יותר"]
איור 4: השוואה בין שני מועמדים לפני ואחרי, לפני הבחירה, מבטיחה שמתקבלת המסגרת הקרובה ביותר, ולא רק “הראשונה אחרי היעד”.
3.3. תמונת התהליך
מתחילת התהליך ועד סופו, בערך: input.mp4 -> יצירת Source Reader -> בקשת RGB32 -> ביצוע seek -> חזרה על ReadSample -> השוואה לפני ואחרי היעד -> סידור מחדש ל-top-down BGRA -> שמירת PNG עם WIC.
זה נראה פשוט, אבל ב-4 מקומות מתחבאות מלכודות קטנות — דיוק ה-seek, sample שהוא null, stride, וטיפול בבייט הרביעי. אם לא נתקעים באחת מהן, המימוש עצמו נופל למקומו בטבעיות.
flowchart TB
accTitle: ארבע המלכודות בתהליך
accDescr: תרשים המראה שהתהליך נראה פשוט אך יש בו ארבע מלכודות קטנות - דיוק ה-seek, sample שהוא null, stride, והבייט הרביעי של RGB32 - שאם לא נתקעים בהן המימוש נופל למקומו בטבעיות.
lk1["תהליך שנראה פשוט"] --> t1["דיוק ה-seek"]
lk1 --> t2["sample שהוא null"]
lk1 --> t3["stride וכיוון"]
t3 -.-> t4["הטיפול בבייט הרביעי"]
t1 --> okf["בלי להיתקע — נופל למקומו בטבעיות"]
t2 --> okf
t3 --> okf
איור 5: בזרימה שנראית פשוטה יש ארבע מלכודות, ואם שמים לב רק אליהן המימוש עצמו פשוט.
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, שמציין שיש פער (הפסקת נתונים) בזרם. בקריאה שבה הדגל הזה עולה, לא מתקבלת מסגרת, וקוראים שוב |
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.
מסוכן לבדוק רק HRESULT ולגשת ישירות ל-pSample. בטוח יותר לבדוק שלושה דברים יחד: HRESULT, flags ו-pSample.
flowchart TB
accTitle: בדיקה משולשת של תוצאת ReadSample
accDescr: תרשים המראה של-ReadSample עלול להחזיר S_OK אך sample שהוא NULL, ולכן בודקים יחד HRESULT, flags ו-pSample, ומטפלים בדגלי סוף זרם או פער בזרם.
rs1["התוצאה של ReadSample"] --> h1["בדיקת HRESULT"]
rs1 --> f1["בדיקת flags"]
rs1 --> s1["בדיקת pSample"]
f1 -.-> gap1["ייתכן דגל סוף זרם או פער"]
s1 -.-> nl1["גם עם S_OK ייתכן NULL"]
איור 7: לא מסתפקים בקוד הצלחה — בודקים יחד HRESULT, flags ו-pSample.
4.3. טיפול רשלני ב-stride ובכיוון האנכי מעוות את התמונה
מאגר התמונה לא בהכרח דחוס ברצף לפי width * bytesPerPixel. לפעמים יש padding בסוף שורה, ותבניות RGB יכולות להיות bottom-up. גם ב-Image Stride וגם ב-Uncompressed Video Buffers של Microsoft Learn זה כתוב במפורש למדי.
שתי הנקודות שחשובות במיוחד:
-
IMF2DBuffer::Lock2Dמחזיר את המצביע לתחילת scan line 0 ואת ה-stride בפועל - בתמונה מסוג bottom-up, ה-stride עלול להיות שלילי
הפעם נאמץ את הגישה של ה-helper ב-Microsoft Learn, ונסדר מחדש למאגר BGRA רציף מסוג top-down לפני העברה ל-PNG. ככל שמסדרים את זה מראש, צד השמירה נעשה פשוט הרבה יותר.
flowchart TB
accTitle: קליטת ה-stride והכיוון
accDescr: תרשים המראה שמקבלים עם Lock2D של IMF2DBuffer את המצביע לתחילת scan line 0 ואת ה-stride בפועל, כולל מקרה של bottom-up עם stride שלילי, ומסדרים מחדש למאגר BGRA רציף מסוג top-down לפני העברה ל-PNG.
l2d["קבלה עם Lock2D"] --> sl0["מצביע לתחילת scan line 0"]
l2d --> ast1["ה-stride בפועל"]
ast1 -.-> neg1["ב-bottom-up עלול להיות שלילי"]
sl0 --> pack1["סידור מחדש ל-BGRA רציף מסוג top-down"]
ast1 --> pack1
pack1 --> sim1["צד השמירה נעשה פשוט"]
איור 8: קולטים את ה-stride בפועל ואת הכיוון עם Lock2D, מסדרים ל-BGRA רציף מסוג top-down, ורק אז מעבירים לשמירה.
4.4. לא לקבוע מראש שהבייט הרביעי של MFVideoFormat_RGB32 הוא alpha
בניגוד לתחושה שהשם נותן, MFVideoFormat_RGB32 אינו “RGBA נקי” שאפשר להעביר ישירות ל-PNG. ב-RGB בגודל 32 סיביות ב-Windows, בייטים 0, 1, 2 הם B, G, R, ובייט 3 עשוי להיות alpha או להתעלמות. הנקודה החשובה היא שזה לא ARGB32.
אם מניחים שזה GUID_WICPixelFormat32bppBGRA ושומרים ישירות, ייתכן שהבייט הרביעי יכיל 0 והתמונה תיראה שקופה באופן משונה. הפעם נאמץ מדיניות של מילוי ה-alpha ב-0xFF לפני השמירה, כדי שתהיה אטומה לגמרי.
flowchart TB
accTitle: הטיפול בבייט הרביעי של RGB32
accDescr: תרשים המראה שב-MFVideoFormat_RGB32 בייטים 0 עד 2 הם B, G, R ואילו הבייט הרביעי לא בהכרח alpha, ולכן כתיבה ישירה ל-PNG עלולה להיות שקופה, ולכן ממלאים 0xFF לפני השמירה כדי לקבל אטימות.
rgb1["מסגרת מסוג MFVideoFormat_RGB32"] --> b4["הבייט הרביעי לא בהכרח alpha"]
b4 -->|"שמירה ישירה"| tp2["עלול לצאת PNG שקוף"]
b4 -->|"מילוי 0xFF לפני שמירה"| op1["PNG אטום לגמרי"]
איור 9: לא קובעים מראש את הבייט הרביעי — ממלאים אותו ב-0xFF לפני השמירה כדי לקבע אטימות.
5. זרימת המימוש
5.1. יצירת Source Reader במצב סינכרוני
הפעם מספיק לקבל תמונה אחת, ולכן נבחר ReadSample סינכרוני במקום callback אסינכרוני. במצב סינכרוני, ReadSample חוסם עד ל-sample הבא, אבל לחילוץ תמונת סטילס בודדת המימוש נשאר פשוט למדי.
בעת יצירת ה-Reader עושים ארבעה דברים:
-
MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING = TRUE - כיבוי כל ה-stream-ים תחילה
- הפעלת
MF_SOURCE_READER_FIRST_VIDEO_STREAMבלבד - הגדרת סוג הפלט ל-
MFMediaType_Video/MFVideoFormat_RGB32
כך נוח יותר לכתוב את המשך הקוד בהנחה ש”מתקבלת מסגרת בפורמט RGB32”.
flowchart TB
accTitle: ארבעת השלבים ביצירת ה-Reader
accDescr: תרשים המראה שביצירת ה-Reader מפעילים עיבוד וידאו, מכבים תחילה את כל ה-stream-ים, מפעילים רק את stream הווידאו הראשון, ומגדירים את סוג הפלט ל-RGB32.
c1["הפעלת עיבוד וידאו"] --> c2["כיבוי כל ה-stream-ים"]
c2 --> c3["הפעלת stream הווידאו הראשון בלבד"]
c3 --> c4["הגדרת סוג הפלט ל-RGB32"]
c4 -.-> rdy1["המשך הקוד יכול להניח RGB32"]
איור 10: אחרי ארבעת השלבים ביצירת ה-Reader, המשך הקוד מקבל רק מסגרות RGB32.
5.2. השוואת timestamp לפני ואחרי ה-seek
אחרי SetCurrentPosition, לא שומרים מיד. תוך כדי קריאת sample עם ReadSample, משווים בין המסגרת האחרונה שלפני היעד לבין המסגרת הראשונה שאחרי היעד.
המאמץ הקטן הזה סופג במידה רבה את הגסות של ה-seek.
sequenceDiagram
accTitle: דו-שיח ההשוואה לפני ואחרי ה-seek
accDescr: תרשים המראה שהיישום מבצע seek עם SetCurrentPosition, קורא שוב ושוב עם ReadSample תוך בדיקת timestamp, ומרגע חציית היעד משווה בין המסגרות שלפני ואחרי ובוחר את הקרובה יותר.
participant A as היישום
participant R as Source Reader
A->>R: SetCurrentPosition (target)
loop עד חציית ה-target
A->>R: ReadSample
R-->>A: sample ו-timestamp
end
A->>A: השוואת המסגרות שלפני ואחרי ובחירת הקרובה יותר
איור 11: לא שומרים מיד אחרי ה-seek, אלא ממשיכים לקרוא עד חציית היעד, ואז משווים.
5.3. סידור מ-sample ל-BGRA מסוג top-down
לא כותבים את ה-sample שהתקבל ישירות ל-PNG, אלא מסדרים אותו קודם למאגר BGRA מסוג top-down.
- הפיכה למאגר יחיד עם
ConvertToContiguousBuffer - קבלת scan line 0 וה-stride בפועל עם ה-helper
BufferLock - העתקה שורה-שורה למאגר top-down
- הגדרת alpha ל-
0xFF
כך צד השמירה יכול להתייחס לזה כאל “תמונת BGRA פשוטה בעומק 32 סיביות”.
flowchart TB
accTitle: שלבי הסידור מ-sample ל-BGRA
accDescr: תרשים המראה שהופכים למאגר יחיד עם ConvertToContiguousBuffer, מקבלים עם BufferLock את scan line 0 ואת ה-stride בפועל, מעתיקים שורה-שורה למאגר top-down וקובעים alpha ל-0xFF.
cv1["ConvertToContiguousBuffer"] --> bl1["קבלת scan line 0 וה-stride עם BufferLock"]
bl1 --> rc1["העתקה שורה-שורה ל-top-down"]
rc1 --> al1["קביעת alpha ל-0xFF"]
al1 --> out1["תמונת BGRA פשוטה בעומק 32 סיביות"]
איור 12: אחרי ארבעת שלבי הסידור, צד השמירה יכול להתייחס לכך כאל תמונת BGRA פשוטה בעומק 32 סיביות.
5.4. שמירת PNG מבוצעת על ידי WIC
השמירה מבוצעת עם IWICBitmapEncoder / IWICBitmapFrameEncode של WIC. Media Foundation מקבל את המסגרת, ו-WIC הופך אותה לתמונה — זו חלוקת התפקידים. הכול מתבצע רק עם ה-API הסטנדרטי של Windows.
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 של ה-sample | בדיקת HRESULT, flags ו-pSample יחד |
null dereference בסוף הזרם או בפער |
| stride | קליטת ה-stride בפועל והכיוון האנכי | עיוות בתמונה, היפוך אנכי |
| הבייט הרביעי של RGB32 | קביעת alpha ל-0xFF |
PNG שקוף |
| טווח הזמן | שמירה על 0 <= target < duration |
התנהגות בלתי צפויה ליד הסוף |
| חילוץ רציף | חזרה על seek בלי יצירה מחדש של ה-Reader | איטיות מיותרת |
| מספר ההעתקות | להתחשב בעלות של ConvertToContiguousBuffer בעיבוד מסיבי |
שימוש מיותר ב-CPU וברוחב פס זיכרון |
| שינוי פורמט | וידאו מיוחד שהרזולוציה שלו משתנה באמצע דורש תכנון נפרד | הנחות רוחב-גובה נשברות |
מתוך אלה, רק שתי השורות “חילוץ רציף” ו”מספר ההעתקות” הן פריטים שמשמעותיים לא בחילוץ תמונה בודדת, אלא כשמרחיבים לחילוץ עשרות תמונות מאותו וידאו. הדוגמה הפעם מסתיימת בחילוץ תמונה אחת, ולכן כשמחלצים כמה תמונות, יש לעבור מיצירה מחדש של Source Reader לחזרה על SetCurrentPosition ו-ReadSample. המפה הכוללת של Media Foundation, כולל חלופות אחרות ל-Source Reader (כמו Media Session), נמצאת ב-מבוא ל-Media Foundation - הבנת ה-API מנקודת מבט של COM.
flowchart TB
accTitle: מעבר מחילוץ תמונה בודדת לחילוץ רציף
accDescr: תרשים המראה שאם רוצים תמונה אחת בלבד ההרכב הנוכחי מספיק, ואילו לחילוץ כמה תמונות מאותו וידאו עוברים לחזרה על seek ו-ReadSample בלי יצירה מחדש של ה-Reader.
q3["כמה תמונות רוצים לחלץ"] -->|"אחת בלבד"| as1["ההרכב הנוכחי מספיק"]
q3 -->|"כמה תמונות"| rp1["חזרה על seek וקריאה בלי יצירה מחדש של ה-Reader"]
rp1 -.-> cost1["גם עלות ההעתקה חשובה"]
איור 14: כשמרחיבים לחילוץ כמה תמונות, עוברים לחזרה על seek וקריאה בלי ליצור מחדש את ה-Reader.
7. הערות על build והרצה
הקוד בסוף המאמר בנוי בצורה שקל להוסיף כקובץ .cpp יחיד ליישום קונסולה ב-C++ ב-Visual Studio. הנחות היסוד של הסביבה עצמה מרוכזות בפרק 2.2.
7.1. הערות ל-build
כמה נקודות ששווה לדעת מראש:
- הוכנס
#pragma comment(lib, ...), ולכן בדרך כלל לא נדרשת הגדרת מקשר נוספת - משתמשים ב-
wmain, כך שארגומנטים משורת הפקודה מטופלים כ-Unicode לכל אורכם - כדי שיהיה קל להדביק גם אם בתבנית ברירת המחדל של Console App יש
pch.hאוstdafx.h, בראש הקוד נעשה שימוש ב-__has_includeכדי לזהות אותם - ואם בכל זאת הפרויקט כופה precompiled header ייחודי, מספיק להגדיר ל-
.cppהזה בלבד “לא להשתמש ב-precompiled header” כדי שזה יעבור - מומלצת תצורת הרצה x64
7.2. איך מריצים, ומה מתקבל בהצלחה
השימוש הוא ExtractFrameFromMp4.exe <input.mp4> <seconds> <output.png>. לדוגמה, מריצים כך: ExtractFrameFromMp4.exe C:\work\input.mp4 12.345 C:\work\frame.png.
בהצלחה, בסוף wmain מודפסות שלוש השורות הבאות לפלט הסטנדרטי:
Saved: C:\work\frame.png
Requested: 12.345 sec
Actual: (זמן התצוגה של המסגרת שנבחרה) sec
Requested הוא בדיוק מספר השניות שהועבר בארגומנט, ו-Actual הוא ה-timestamp בפועל של המסגרת שנבחרה. לפי הכלל בסעיף 3.2, בוחרים את הקרוב יותר מבין המסגרות שלפני ואחרי, ולכן ההפרש ביניהם נשאר בערך בתוך חצי ממרווח המסגרות (ב-29.97fps, זה בערך 17ms כגבול עליון). אם ההפרש הזה עולה על כמה מאות אלפיות שנייה, כדאי לחשוד שקוראים רק פעם אחת אחרי ה-seek, כלומר שההשוואה מפרק 4.1 לא פועלת.
flowchart TB
accTitle: איך קוראים את ההפרש בין Requested ל-Actual
accDescr: תרשים המראה שאם ההפרש בין Requested ל-Actual בתוך חצי ממרווח המסגרות זה תקין, ואם הוא כמה מאות אלפיות שנייה או יותר כדאי לחשוד שההשוואה שלפני ואחרי לא פועלת.
dfc["בדיקת ההפרש בין Requested ל-Actual"] -->|"בתוך חצי ממרווח המסגרות"| okd["ההשוואה פועלת"]
dfc -->|"כמה מאות אלפיות שנייה או יותר"| ngd["חשד שההשוואה לא פועלת"]
ngd -.-> ck2["לבדוק אם קוראים רק פעם אחת אחרי ה-seek"]
איור 15: אם ההפרש עולה משמעותית על חצי ממרווח המסגרות, כדאי לחשוד במימוש ההשוואה שלפני ואחרי.
מטרות בדיקת ההרצה הן שלוש:
- קוד היציאה הוא 0, ובשורת
Saved:מופיע נתיב הפלט שהוגדר - כשפותחים את ה-PNG שנוצר, הסצנה של אותו זמן מוצגת בכיוון הנכון (לא הפוך)
- רוחב וגובה ה-PNG תואמים לרזולוציית הווידאו המקורי, והרקע לא שקוף (כלומר מילוי ה-alpha מפרק 4.4 פעל)
במקרה כשלון, מודפס לפלט השגיאה הסטנדרטי Failed. HRESULT = 0x......... את הערך קוראים לפי השיטה מפרק 2.1. אם מספר השניות שהוזן גדול או שווה לאורך הווידאו, יתקבל 0x80070057 (E_INVALIDARG).
flowchart TB
accTitle: זרימת בדיקת תוצאת ההרצה
accDescr: תרשים המראה שאם ההרצה הצליחה בודקים את שלוש שורות הפלט ואת התמונה, ואם נכשלה קוראים את HRESULT מפלט השגיאה, ומספר שניות שגדול או שווה לאורך הווידאו נותן E_INVALIDARG.
run1["הרצה"] -->|"הצלחה"| ok3["בדיקת שלוש שורות הפלט וה-PNG"]
run1 -->|"כשלון"| er2["קריאת HRESULT מפלט השגיאה"]
er2 -.-> iv1["מספר שניות שגדול או שווה לאורך הווידאו נותן E_INVALIDARG"]
איור 16: בהצלחה בודקים את שלושת הפרטים בפלט וב-PNG, ובכשלון קוראים את הסיבה מערך ה-HRESULT.
8. סיכום
בחילוץ תמונת סטילס מ-MP4 בזמן נתון עם Media Foundation, לא מספיק להתייחס רק ל-SetCurrentPosition ול-ReadSample. בפועל:
- ה-seek אינו exact
- כדאי לבדוק את ה-timestamp של המסגרת ולהשוות בין לפני ואחרי
- גם כשה-
ReadSampleמצליח, ייתכן שאין sample - קולטים את ה-
strideואת כיוון התמונה לפני השמירה - לא קובעים מראש שהבייט הרביעי של
RGB32הוא alpha
אם שומרים על הנקודות האלה, הרבה פחות סיכוי לתקלות.
flowchart TB
accTitle: חמש הנקודות שכדאי לשמור עליהן
accDescr: תרשים המראה שחמש הנקודות - ה-seek אינו exact, השוואת timestamp לפני ואחרי, בדיקת sample שהוא null גם בהצלחה, קליטת stride וכיוון, ואי-קביעה מראש של הבייט הרביעי - מפחיתות תקלות.
k1["ה-seek אינו exact"] --> k2["השוואת timestamp לפני ואחרי"]
k2 --> k3["בדיקת sample ריק גם בהצלחה"]
k3 --> k4["קליטת stride וכיוון"]
k4 --> k5["אי-קביעה מראש של הבייט הרביעי"]
k5 --> safe2["מימוש עם פחות תקלות"]
איור 17: כשחמש הנקודות מכוסות יחד, חילוץ תמונת סטילס בזמן נתון הרבה פחות נוטה לתקלות.
הדוגמה הפעם היא הרכב מינימלי שממוקד בחילוץ תקין של תמונה אחת. הצורה מתאימה ישירות לשימושים כמו יצירת thumbnail, שמירת מסגרת נציגה מווידאו ניטור, ותיעוד ללוג בדיקה.
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
הבלוק הבא, בשלמותו, הוא הקוד שמיועד להעברה ישירה לפרויקט יישום קונסולה ב-C++ ב-Visual Studio. סדר הארגומנטים משורת הפקודה הוא input.mp4, seconds, output.png. ההרכב הוא קובץ בודד שלם, כך שקל להדביק אותו לפרויקט.
מכיוון שזה בלוק ארוך אחד, נציג קודם התאמה בין הפונקציות לבין המקומות המתאימים במאמר. גם בקריאה וגם בחקירה כשמשהו לא עובד, נוח להתחיל מהטבלה הזו.
| פונקציה / מחלקה בקוד | המקום המתאים במאמר | תפקיד, והמלכודת שהיא מטפלת בה |
|---|---|---|
MediaFoundationScope |
5.1 | מרכזת את האתחול והניקוי של CoInitializeEx ו-MFStartup |
CreateConfiguredSourceReader |
5.1 | יוצרת את ה-Reader, בוחרת רק את stream הווידאו, ומבקשת MFVideoFormat_RGB32 |
GetPresentationDuration |
6. (טווח הזמן) | מקבלת את אורך הווידאו, ובודקת שמתקיים 0 <= target < duration |
SeekSourceReader |
4.1 / 5.2 | מבצעת seek עם SetCurrentPosition. לבד זה לא exact |
ReadNearestVideoSample |
3.2 / 4.1 / 4.2 / 5.2 | משווה בין לפני ואחרי היעד ובוחרת מסגרת אחת. גם מטפלת כאן ב-flags וב-null של pSample |
GetDefaultStride |
4.3 | מחשבת ומשלימה stride כשאין MF_MT_DEFAULT_STRIDE |
BufferLock |
4.3 / 5.3 | מקבלת עם IMF2DBuffer::Lock2D את scan line 0 ואת ה-stride בפועל |
CopyContiguousBufferToTopDownBgra |
4.3 / 4.4 / 5.3 | מסדרת שורה-שורה למאגר top-down, וממלאת את הבייט הרביעי ב-0xFF |
CopySampleToTopDownBgra |
5.3 | מוציאה את גודל המסגרת וה-stride וקוראת להעתקה שלעיל |
SaveBgraToPng |
5.4 | כותבת עם WIC תמונת BGRA בעומק 32 סיביות כ-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 לא בהכרח 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;
}
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
איך ממירים YUV ל-RGB עם Media Foundation
מסדרים איך ממירים מסגרת YUV ל-RGB עם Media Foundation, דרך ההמרה האוטומטית של Source Reader וההמרה העצמית של NV12/YUY2, ה-stride ומרחב הצבע.
מבוא ל-Media Foundation — מבינים את ה-API מנקודת המבט של COM
המאמר מסביר מה זה Media Foundation, יחד עם המונחים הבסיסיים של ה-API למדיה ב-Windows כמו COM, HRESULT, IMFSourceReader ו-MFT, בסדר שכדא...
צריבת תמונה וטקסט על MP4 עם Media Foundation
המאמר מסדר את הגישה לצריבת תמונה וטקסט על כל מסגרת בסרטון MP4 ויצירת MP4 חדש עם Media Foundation - חלוקת התפקידים בין Source Reader, ציור...
איך קוראים ל-DLL של C# Native AOT מ-C/C++
פרסום ספריית מחלקות C# כ-DLL נייטיבי באמצעות Native AOT, וקריאה לנקודות הכניסה מסוג UnmanagedCallersOnly מ-C/C++ — סקירה לפי מקום השימוש...
רשימת בדיקה לטיפול בטוח בתהליכי ילד ביישום Windows
כדי לטפל בבטחה בתהליכי ילד ביישום Windows, תכנון הבעלות על עץ התהליכים ונוהל הסיום חשוב יותר מבחירת ה-API להפעלה. המאמר מסדר את Job Objec...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
מדובר בחילוץ תמונת סטילס מווידאו באמצעות Media Foundation, Source Reader ו-WIC, ולכן זה נושא קרוב לנושאי המימוש של פיתוח יישומי Windows.
ייעוץ טכני וסקירת תכנון
אם רוצים לסדר מראש את דיוק ה-seek, פורמט המאגר, ה-stride וכיוון התמונה עוד לפני המימוש, אפשר להתחיל מסידור הגישה במסגרת ייעוץ טכני וסקירת תכנון.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- מה כדאי להשתמש בו כדי לחלץ תמונת סטילס מ-MP4 בזמן נתון?
- אם רוצים לחלץ תמונה אחת בלבד, IMFSourceReader הוא נקודת כניסה טבעית יותר מ-Media Session. פותחים את הקובץ עם MFCreateSourceReaderFromURL, בוחרים רק את הווידאו עם SetStreamSelection, מבקשים MFVideoFormat_RGB32, מבצעים seek עם SetCurrentPosition, מקבלים מסגרת עם ReadSample, ושומרים כ-PNG עם WIC. הכול מתבצע רק עם ה-API הסטנדרטי של Windows, בלי ספריות חיצוניות.
- האם SetCurrentPosition מזיז בדיוק לזמן הנתון?
- לא. IMFSourceReader::SetCurrentPosition לא מבטיח exact seeking, ובווידאו הוא בדרך כלל מתקרב לנקודה קצת לפני היעד, בעיקר לכיוון key frame. צריך לממש התקדמות עם ReadSample אחרי ה-seek, לבדוק את ה-timestamp, להשוות את ה-sample האחרון שלפני היעד עם ה-sample הראשון שאחרי היעד ולבחור את הקרוב יותר. מימוש שקורא רק פעם אחת אחרי seek ושומר, ייסחף בקלות בווידאו עם GOP ארוך.
- למה ה-PNG שנשמר יוצא שקוף?
- כי הבייט הרביעי של MFVideoFormat_RGB32 לא בהכרח alpha. ב-RGB בגודל 32 סיביות ב-Windows, בייטים 0, 1, 2 הם B, G, R, ובייט 3 עשוי להיות alpha או להתעלמות (זה לא ARGB32). אם כותבים ל-PNG כמו שהוא, התמונה עלולה לצאת שקופה, ולכן בטוח יותר להכניס 0xFF לבייט הרביעי לפני השמירה כדי להפוך אותה לאטומה לגמרי.
- מה גורם לתמונה להיראות מעוותת או הפוכה?
- הטיפול ב-stride ובכיוון האנכי. מאגר התמונה לא בהכרח דחוס ברצף לפי width × בייטים לפיקסל — לפעמים יש padding בסוף כל שורה, ותבניות RGB יכולות להיות bottom-up (עם stride שלילי). קבלת המצביע לתחילת scan line 0 וה-stride בפועל עם IMF2DBuffer::Lock2D, וסידור מחדש למאגר BGRA רציף מסוג top-down לפני העברה ל-PNG, מפשטים את צד השמירה ומונעים עיוותים.