איך לחלץ תמונת סטילס מ-MP4 בזמן נתון עם Media Foundation

· עודכן בתאריך: · · Media Foundation, C++, פיתוח Windows, WIC

יש דרישה נפוצה למדי לחלץ מ-MP4 “תמונה אחת מהנקודה של 12.3 שניות”. יצירת thumbnail, לוג בדיקה, מסגרת נציגה מווידאו ניטור, תיעוד ללוג של מכשיר — כל אלה מקרים כאלה.

עם זאת, ב-Media Foundation זה קצת פחות פשוט ממה שנראה. קריאה בודדת ל-ReadSample אחרי SetCurrentPosition נראית כאילו מסיימת את העבודה, אבל בפועל מעורבים כאן key frame,‏ timestamp,‏ stride, כיוון האנכי של התמונה, והבייט הרביעי של RGB32. אם מתקדמים ברשלנות, קורים תקלות מעצבנות במידה שקטה — הזמן זז מעט, התמונה הפוכה, ה-PNG יוצא שקוף באופן משונה.

תקלות שקורות בהתקדמות רשלניתתרשים המראה שמימוש של seek וקריאה פעם אחת בלבד לפני שמירה מערב key frame, timestamp, stride וכיוון אנכי, והבייט הרביעי של RGB32, וגורם לזמן זז, תמונה הפוכה ו-PNG שקוף.seek וקריאה פעם אחת לפני שמירההזמן זז מעטהתמונה יוצאת הפוכהה-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 יציב הרבה יותר.

הזרימה הרשלנית מול הזרימה היציבהתרשים המראה שזרימה של seek וקריאה פעם אחת לפני שמירה רשלנית, ואילו זרימה שבה אחרי seek משווים לפי timestamp, מעתיקים תוך התחשבות ב-stride ואז שומרים PNG, יציבה הרבה יותר.קריאה פעם אחת ושמירה — רשלניseekהשוואה לפני ואחרי לפי timestampהעתקה תוך התחשבות ב-strideשמירת PNGגורם לזזה ולעיוות

איור 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 שקוף באופן לא מכוון.

מפת הידע של חילוץ תמונת סטילס מ-MP4 (Media Foundation)תרשים המראה כיצד seek באמצעות SetCurrentPosition ב-IMFSourceReader נוטה לכיוון פריים המפתח, את הקשר בין אורך ה-GOP להשוואת timestamp לפני ואחרי דרך ReadSample, את הטיפול ב-stride ובכיוון top-down/bottom-up דרך Lock2D, ואת קיבוע הבייט הרביעי של RGB32 כ-alpha לפני שמירה כ-PNG עם WIC.משתמש במחייבמשתמש במחייבעלול לגרום למשתמש במחייבמחייבמשתמש במחייבעלול לגרום למונעמשתמש במשתמש בעלול לגרום לחילוץ תמונת סטילס מווידאו (מסגרת בזמן נתון)IMFSourceReader(Source Reader)Media Foundation‏seek באמצעות SetCurrentPositionIMFSourceReader::ReadSampleGOP(Group of Pictures)מסגרת מפתח (key frame)‏stride (מספר הבייטים בשורת תמונה)IMF2DBuffer::Lock2D‏top-down / bottom-up (כיוון השורות בתמונה)MFVideoFormat_RGB32‏PNG שקוף שלא בכוונהקיבוע ערוץ ה-alpha ל-0xFFWIC(Windows Imaging Component)דגימת null מ-ReadSample

בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 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, יש תכנון אחר, אבל למטרה של חילוץ מסגרת אחת זה ברור למדי.

מבנה ההנחות של המאמר הזהתרשים המראה שקלט MP4 מקומי עובר ב-IMFSourceReader סינכרוני שמוציא את המסגרת הקרובה ביותר לזמן הנתון ושומר אותה כ-PNG עם WIC, כל זאת בלי ספריות חיצוניות.MP4 מקומיIMFSourceReader במצב סינכרוניהמסגרת הקרובה ביותר לזמן הנתוןשמירת PNG עם WICהכול עם ה-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 הנוכחי
  • בוחרים את הקרוב יותר ליעד

כך קל יותר לקבל את המסגרת הקרובה ביותר ליעד, ולא “המסגרת הראשונה אחרי היעד”.

כלל ההחלטה לבחירת המסגרת הקרובה ביותרתרשים המראה שמתקדמים עם ReadSample אחרי seek, שומרים את ה-sample האחרון שלפני היעד, וכשמגיע ה-sample הראשון אחרי היעד משווים את שני ההפרשים ובוחרים את הקרוב יותר.התקדמות עם ReadSample אחרי seekשמירת ה-sample האחרון שלפני היעדהגעת ה-sample הראשון אחרי היעדהשוואת שני ההפרשים מהיעדבחירת הקרוב יותר

איור 4: השוואה בין שני מועמדים לפני ואחרי, לפני הבחירה, מבטיחה שמתקבלת המסגרת הקרובה ביותר, ולא רק “הראשונה אחרי היעד”.

3.3. תמונת התהליך

מתחילת התהליך ועד סופו, בערך: input.mp4 -> יצירת Source Reader -> בקשת RGB32 -> ביצוע seek -> חזרה על ReadSample -> השוואה לפני ואחרי היעד -> סידור מחדש ל-top-down BGRA -> שמירת PNG עם WIC.

זה נראה פשוט, אבל ב-4 מקומות מתחבאות מלכודות קטנות — דיוק ה-seek,‏ sample שהוא null,‏ stride, וטיפול בבייט הרביעי. אם לא נתקעים באחת מהן, המימוש עצמו נופל למקומו בטבעיות.

ארבע המלכודות בתהליךתרשים המראה שהתהליך נראה פשוט אך יש בו ארבע מלכודות קטנות - דיוק ה-seek, sample שהוא null, stride, והבייט הרביעי של RGB32 - שאם לא נתקעים בהן המימוש נופל למקומו בטבעיות.תהליך שנראה פשוטדיוק ה-seeksample שהוא nullstride וכיווןהטיפול בבייט הרביעיבלי להיתקע — נופל למקומו בטבעיות

איור 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 שניות לפני הזמן שביקשנו.

המנגנון שגורם לזזה בקריאה בודדת אחרי seekתרשים המראה ש-SetCurrentPosition מתקרב לנקודה קצת לפני היעד, בעיקר לכיוון key frame, ולכן קריאה בודדת מיד אחר כך עלולה לשמור מסגרת רחוקה מאוד מהזמן הנתון בווידאו עם GOP ארוך.SetCurrentPosition (target)נחיתה קצת לפני ה-key frameReadSample פעם אחת בלבדשמירת מסגרת קרובה מדי לאחורככל ש-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.

בדיקה משולשת של תוצאת ReadSampleתרשים המראה של-ReadSample עלול להחזיר S_OK אך sample שהוא NULL, ולכן בודקים יחד HRESULT, flags ו-pSample, ומטפלים בדגלי סוף זרם או פער בזרם.התוצאה של ReadSampleבדיקת HRESULTבדיקת flagsבדיקת pSampleייתכן דגל סוף זרם או פערגם עם 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. ככל שמסדרים את זה מראש, צד השמירה נעשה פשוט הרבה יותר.

קליטת ה-stride והכיווןתרשים המראה שמקבלים עם Lock2D של IMF2DBuffer את המצביע לתחילת scan line 0 ואת ה-stride בפועל, כולל מקרה של bottom-up עם stride שלילי, ומסדרים מחדש למאגר BGRA רציף מסוג top-down לפני העברה ל-PNG.קבלה עם Lock2Dמצביע לתחילת scan line 0ה-stride בפועלב-bottom-up עלול להיות שליליסידור מחדש ל-BGRA רציף מסוג top-downצד השמירה נעשה פשוט

איור 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 לפני השמירה, כדי שתהיה אטומה לגמרי.

הטיפול בבייט הרביעי של RGB32תרשים המראה שב-MFVideoFormat_RGB32 בייטים 0 עד 2 הם B, G, R ואילו הבייט הרביעי לא בהכרח alpha, ולכן כתיבה ישירה ל-PNG עלולה להיות שקופה, ולכן ממלאים 0xFF לפני השמירה כדי לקבל אטימות.שמירה ישירהמילוי 0xFF לפני שמירהמסגרת מסוג MFVideoFormat_RGB32הבייט הרביעי לא בהכרח alphaעלול לצאת PNG שקוף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”.

ארבעת השלבים ביצירת ה-Readerתרשים המראה שביצירת ה-Reader מפעילים עיבוד וידאו, מכבים תחילה את כל ה-stream-ים, מפעילים רק את stream הווידאו הראשון, ומגדירים את סוג הפלט ל-RGB32.הפעלת עיבוד וידאוכיבוי כל ה-stream-יםהפעלת stream הווידאו הראשון בלבדהגדרת סוג הפלט ל-RGB32המשך הקוד יכול להניח RGB32

איור 10: אחרי ארבעת השלבים ביצירת ה-Reader, המשך הקוד מקבל רק מסגרות RGB32.

5.2. השוואת timestamp לפני ואחרי ה-seek

אחרי SetCurrentPosition, לא שומרים מיד. תוך כדי קריאת sample עם ReadSample, משווים בין המסגרת האחרונה שלפני היעד לבין המסגרת הראשונה שאחרי היעד.

המאמץ הקטן הזה סופג במידה רבה את הגסות של ה-seek.

דו-שיח ההשוואה לפני ואחרי ה-seekתרשים המראה שהיישום מבצע seek עם SetCurrentPosition, קורא שוב ושוב עם ReadSample תוך בדיקת timestamp, ומרגע חציית היעד משווה בין המסגרות שלפני ואחרי ובוחר את הקרובה יותר.Source ReaderהיישוםSource Readerהיישוםloop[עד חציית ה-target]SetCurrentPosition (target)ReadSamplesample ו-timestampהשוואת המסגרות שלפני ואחרי ובחירת הקרובה יותר

איור 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 סיביות”.

שלבי הסידור מ-sample ל-BGRAתרשים המראה שהופכים למאגר יחיד עם ConvertToContiguousBuffer, מקבלים עם BufferLock את scan line 0 ואת ה-stride בפועל, מעתיקים שורה-שורה למאגר top-down וקובעים alpha ל-0xFF.ConvertToContiguousBufferקבלת scan line 0 וה-stride עם BufferLockהעתקה שורה-שורה ל-top-downקביעת alpha ל-0xFFתמונת BGRA פשוטה בעומק 32 סיביות

איור 12: אחרי ארבעת שלבי הסידור, צד השמירה יכול להתייחס לכך כאל תמונת BGRA פשוטה בעומק 32 סיביות.

5.4. שמירת PNG מבוצעת על ידי WIC

השמירה מבוצעת עם IWICBitmapEncoder /‏ IWICBitmapFrameEncode של WIC. Media Foundation מקבל את המסגרת, ו-WIC הופך אותה לתמונה — זו חלוקת התפקידים. הכול מתבצע רק עם ה-API הסטנדרטי של Windows.

חלוקת התפקידים בין Media Foundation ל-WICתרשים המראה ש-Media Foundation אחראי לקבלת המסגרת ו-WIC אחראי להפיכתה לתמונה ולכתיבתה כ-PNG.מקבל את המסגרתWIC הופך לתמונהMedia Foundationמסגרת BGRAקובץ 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.

מעבר מחילוץ תמונה בודדת לחילוץ רציףתרשים המראה שאם רוצים תמונה אחת בלבד ההרכב הנוכחי מספיק, ואילו לחילוץ כמה תמונות מאותו וידאו עוברים לחזרה על seek ו-ReadSample בלי יצירה מחדש של ה-Reader.אחת בלבדכמה תמונותכמה תמונות רוצים לחלץההרכב הנוכחי מספיקחזרה על seek וקריאה בלי יצירה מחדש של ה-Readerגם עלות ההעתקה חשובה

איור 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 לא פועלת.

איך קוראים את ההפרש בין Requested ל-Actualתרשים המראה שאם ההפרש בין Requested ל-Actual בתוך חצי ממרווח המסגרות זה תקין, ואם הוא כמה מאות אלפיות שנייה או יותר כדאי לחשוד שההשוואה שלפני ואחרי לא פועלת.בתוך חצי ממרווח המסגרותכמה מאות אלפיות שנייה או יותרבדיקת ההפרש בין Requested ל-Actualההשוואה פועלתחשד שההשוואה לא פועלתלבדוק אם קוראים רק פעם אחת אחרי ה-seek

איור 15: אם ההפרש עולה משמעותית על חצי ממרווח המסגרות, כדאי לחשוד במימוש ההשוואה שלפני ואחרי.

מטרות בדיקת ההרצה הן שלוש:

  • קוד היציאה הוא 0, ובשורת Saved: מופיע נתיב הפלט שהוגדר
  • כשפותחים את ה-PNG שנוצר, הסצנה של אותו זמן מוצגת בכיוון הנכון (לא הפוך)
  • רוחב וגובה ה-PNG תואמים לרזולוציית הווידאו המקורי, והרקע לא שקוף (כלומר מילוי ה-alpha מפרק 4.4 פעל)

במקרה כשלון, מודפס לפלט השגיאה הסטנדרטי Failed. HRESULT = 0x......... את הערך קוראים לפי השיטה מפרק 2.1. אם מספר השניות שהוזן גדול או שווה לאורך הווידאו, יתקבל 0x80070057 (‏E_INVALIDARG).

זרימת בדיקת תוצאת ההרצהתרשים המראה שאם ההרצה הצליחה בודקים את שלוש שורות הפלט ואת התמונה, ואם נכשלה קוראים את HRESULT מפלט השגיאה, ומספר שניות שגדול או שווה לאורך הווידאו נותן E_INVALIDARG.הצלחהכשלוןהרצהבדיקת שלוש שורות הפלט וה-PNGקריאת HRESULT מפלט השגיאהמספר שניות שגדול או שווה לאורך הווידאו נותן E_INVALIDARG

איור 16: בהצלחה בודקים את שלושת הפרטים בפלט וב-PNG, ובכשלון קוראים את הסיבה מערך ה-HRESULT.

8. סיכום

בחילוץ תמונת סטילס מ-MP4 בזמן נתון עם Media Foundation, לא מספיק להתייחס רק ל-SetCurrentPosition ול-ReadSample. בפועל:

  • ה-seek אינו exact
  • כדאי לבדוק את ה-timestamp של המסגרת ולהשוות בין לפני ואחרי
  • גם כשה-ReadSample מצליח, ייתכן שאין sample
  • קולטים את ה-stride ואת כיוון התמונה לפני השמירה
  • לא קובעים מראש שהבייט הרביעי של RGB32 הוא alpha

אם שומרים על הנקודות האלה, הרבה פחות סיכוי לתקלות.

חמש הנקודות שכדאי לשמור עליהןתרשים המראה שחמש הנקודות - ה-seek אינו exact, השוואת timestamp לפני ואחרי, בדיקת sample שהוא null גם בהצלחה, קליטת stride וכיוון, ואי-קביעה מראש של הבייט הרביעי - מפחיתות תקלות.ה-seek אינו exactהשוואת timestamp לפני ואחריבדיקת sample ריק גם בהצלחהקליטת stride וכיווןאי-קביעה מראש של הבייט הרביעימימוש עם פחות תקלות

איור 17: כשחמש הנקודות מכוסות יחד, חילוץ תמונת סטילס בזמן נתון הרבה פחות נוטה לתקלות.

הדוגמה הפעם היא הרכב מינימלי שממוקד בחילוץ תקין של תמונה אחת. הצורה מתאימה ישירות לשימושים כמו יצירת thumbnail, שמירת מסגרת נציגה מווידאו ניטור, ותיעוד ללוג בדיקה.

9. מקורות

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,
            &currentTimestamp,
            &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;
}

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

מה כדאי להשתמש בו כדי לחלץ תמונת סטילס מ-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, מפשטים את צד השמירה ומונעים עיוותים.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג