איך לשלוף still מ-MP4 בזמן נתון עם Media Foundation

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

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 15 Mar 2026)
פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173456)

מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.

Go Komura (2026). איך לשלוף still מ-MP4 בזמן נתון עם Media Foundation. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173456 https://comcomponent.com/he/blog/media-foundation-extract-still-image-from-mp4-at-specific-time/

DOI (הגרסה האחרונה)
10.5281/zenodo.22173456
DOI (הגרסה הזו)
10.5281/zenodo.22173457

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

ב-Media Foundation זה פחות ישר ממה שנראה. נראה כאילו אחרי SetCurrentPosition קוראים ReadSample פעם אחת וזהו, אבל בפועל נכנסים לתמונה 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, נעבור על איך לשלוף still אחד, הקרוב ביותר לזמן שביקשת, ולשמור אותו כ-PNG, כולל המלכודות שקל ליפול בהן בפרודקשן. בסוף יש קוד שלם בקובץ אחד, שקל להדביק ל-.cpp של פרויקט C++ Console App ב-Visual Studio. אין שברי קוד לאורך המאמר — מספיק לקחת את הבלוק האחרון.

הקוד במאמר הזה מפורסם גם כסט דוגמה שלם (C++ console app בקובץ אחד) ב-GitHub.

media-foundation-extract-still-image-from-mp4-at-specific-time - komurasoft-blog-samples (GitHub)

1. קודם המסקנה

בקצרה:

  • לשלוף תמונה אחת מ-MP4, הפעם Source Reader הוא entry point נוח יותר מ-Media Session
  • IMFSourceReader::SetCurrentPosition לא מבטיח exact seek. בדרך כלל הוא נוחת קצת לפני היעד, בעיקר לכיוון key frame, אז אחרי זה ממשיכים עם ReadSample ומשווים לפני ואחרי הזמן שביקשת
  • ReadSample יכול להצליח ועדיין להחזיר pSample == nullptr. בודקים לא רק HRESULT, גם flags ו-pSample
  • כשמכוונים את output media type ל-MFVideoFormat_RGB32, השמירה נוחה יותר
  • אבל הבייט הרביעי של RGB32 הוא לא בהכרח alpha. כתיבה ישירה ל-PNG יכולה לתת תמונה שקופה. לפני השמירה שמים 0xFF ועושים opaque
  • אם מתעלמים מ-stride לכל שורה ומכיוון top-down / bottom-up, התמונה נשברת. את ה-sample שהתקבל אורזים קודם ל-BGRA רציף top-down, ורק אז מעבירים ל-PNG

כלומר, seek -> קריאה אחת -> שמירה זה פשטני מדי. seek -> השוואה לפי timestamp -> copy תוך התחשבות ב-stride -> שמירת PNG יציב בהרבה.

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

איור 2: לא שומרים מיד אחרי ה-seek. מוסיפים השוואה ו-copy שמתחשב ב-stride, והתוצאה יציבה.

ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 15, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle

2. הנחות של המאמר

2.1. קהל יעד וידע מקדים

  • מניחים שכבר קראתם COM API ב-Windows מ-C++. מספיק להכיר את הזרימה: אתחול עם CoInitializeEx, ושחרור מצביע interface עם 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. סביבת פיתוח

פריט ההנחה הפעם
OS Windows 10 / Windows 11
IDE Visual Studio 2022 (workload “Desktop development with C++”)
SDK Windows SDK שמגיע עם Visual Studio (כולל headers וספריות של Media Foundation ו-WIC)
פרויקט template “Console App” של C++
פלטפורמה x64
ספריות נוספות אין. קישור כמו mfplat.lib נעשה עם #pragma comment(lib, ...) בתוך הקוד

איך מדביקים, ומה לעשות עם precompiled headers, מרוכז בפרק “7. הערות build והרצה”.

2.3. הנחות קלט ופלט

ההנחות הפעם:

  • הקלט הוא קובץ MP4 מקומי
  • הפלט הרצוי הוא still אחד
  • לא “בדיוק בזמן שביקשת”, אלא “ה-frame הקרוב ביותר לזמן שביקשת”
  • המימוש הוא IMFSourceReader במצב synchronous
  • פורמט השמירה הוא PNG דרך WIC
  • בלי ספריות חיצוניות — הכול על Windows API סטנדרטי
  • מניחים MP4 רגיל שהרזולוציה לא משתנה באמצע

אם צריך גם playback, סנכרון אודיו, seek bar וחיבור ל-UI, יש עיצוב אחר. למטרה של לשלוף frame אחד הזרימה הזו ברורה למדי.

מבנה ההנחות במאמרתרשים שמראה ש-MP4 מקומי נכנס ל-IMFSourceReader synchronous, ששולף את ה-frame הקרוב ביותר לזמן שביקשת ושומר PNG עם WIC, בלי ספריות חיצוניות.MP4 מקומיIMFSourceReader במצב synchronousה-frame הקרוב ביותר לזמן שביקשתשמירת PNG עם WICהכול על Windows API סטנדרטי

איור 3: מהקלט עד השמירה רצים על Source Reader synchronous, בלי ספריות חיצוניות.

3. טבלת סיכום שכדאי לראות קודם

3.1. זרימת העיבוד

מה עושים ה-API התפקיד
פתיחת MP4 MFCreateSourceReaderFromURL בניית media source מקובץ
בחירת וידאו בלבד SetStreamSelection לא לקרוא אודיו
המרה ל-RGB32 SetCurrentMediaType + MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING לקבל uncompressed frame שנוח לשמור
מעבר לזמן שביקשת SetCurrentPosition seek ביחידות של 100ns
קריאת frame ReadSample לקבל sample מפוענח אחד בכל פעם
השוואת לפני / אחרי timestamp של ה-sample לבחור את ה-frame הקרוב ביותר לזמן שביקשת
שמירה כ-PNG WIC כתיבה לקובץ תמונה

3.2. כלל הבחירה הפעם

גם כשאומרים “ה-still של הזמן שביקשת”, וידאו הוא לא רצף רציף אלא frames בדידים. במימוש נוח לקבוע מראש לפי איזה כלל בוחרים את התמונה האחת.

הכלל הפעם:

  • אחרי ה-seek ממשיכים עם ReadSample
  • שומרים את ה-sample האחרון ש-timestamp < target
  • כשמגיע ה-sample הראשון עם timestamp >= target, משווים את ההפרש של ה-sample הקודם ושל הנוכחי
  • בוחרים את הקרוב יותר ליעד

כך מקבלים את ה-frame הקרוב ביותר ליעד, לא “הראשון אחרי היעד”.

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

איור 4: משווים שני מועמדים, לפני ואחרי, ורק אז בוחרים — כך מקבלים את הקרוב ביותר, לא רק את הראשון אחרי היעד.

3.3. תמונת הזרימה

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

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

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

איור 5: בזרימה שנראית פשוטה יש ארבע מלכודות. שמים לב רק אליהן, והקוד עצמו נשאר פשוט.

4. מלכודות שכדאי לסגור מראש

לפני שממשיכים, מילון קצר למונחים בפרק הזה.

מונח משמעות
key frame frame שאפשר לפענח לבד, בלי להסתמך על frames אחרים. ב-H.264 זה למשל תמונת IDR
GOP (Group of Pictures) קבוצת ה-frames מ-key frame אחד עד רגע לפני ה-key frame הבא. ככל שה-GOP ארוך יותר, המרווח בין key frames גדול יותר, ואחרי seek קל יותר לנחות רחוק מהזמן שביקשת
stride כמה bytes תופסת שורה אחת ב-buffer של התמונה. לא בהכרח שווה ל-width × bytesPerPixel — לפעמים יש padding בסוף השורה
top-down / bottom-up האם תחילת ה-buffer היא השורה העליונה או התחתונה. bottom-up מיוצג עם stride שלילי
MF_SOURCE_READERF_STREAMTICK אחד מה-flags ש-ReadSample מחזיר. מציין פער (gap) ב-stream. בקריאה שבה ה-flag הזה דולק לא מתקבל frame, וקוראים שוב

4.1. SetCurrentPosition הוא לא exact seek

כפי שכתוב גם ב-Microsoft Learn על IMFSourceReader::SetCurrentPosition, זה לא מבטיח exact seeking. בווידאו הוא בדרך כלל נוחת קצת לפני היעד, בעיקר לכיוון key frame. בנוסף, מניחים שאחרי זה ממשיכים עם ReadSample עד לנקודה שביקשת.

לכן מימוש מהסוג הבא מסוכן למדי:

  • SetCurrentPosition(target)
  • ReadSample פעם אחת
  • שומרים את אותו frame

על וידאו עם GOP ארוך זה זז. אם המרווח בין key frames הוא 2 שניות, ה-frame שנשמר יכול להיות עד כמעט 2 שניות לפני הזמן שביקשת.

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

איור 6: ה-seek נוחת לכיוון key frame, אז קריאה אחת יכולה לשמור תמונה מוקדמת באורך ה-GOP.

4.2. ReadSample יכול להצליח ועדיין להחזיר pSample == nullptr

ReadSample יכול להחזיר S_OK וגם ppSample שהוא NULL. בסוף ה-stream חוזר flag כמו MF_SOURCE_READERF_ENDOFSTREAM, ובפער ב-stream — MF_SOURCE_READERF_STREAMTICK.

מסוכן לבדוק רק HRESULT ולגשת ישר ל-pSample. בטוח יותר לבדוק שלושה דברים יחד: HRESULT, flags ו-pSample.

בדיקה משולשת של תוצאת ReadSampleתרשים שמראה ש-ReadSample יכול להחזיר S_OK עם sample שהוא NULL, ולכן בודקים יחד HRESULT, flags ו-pSample, ומטפלים ב-flags של סוף stream או gap.התוצאה של ReadSampleבודקים HRESULTבודקים flagsבודקים pSampleיכול להיות flag של סוף stream או gapגם עם S_OK יכול להיות NULL

איור 7: לא מסתפקים בקוד הצלחה — בודקים יחד HRESULT, flags ו-pSample.

4.3. אם מתעלמים מ-stride ומכיוון השורות, התמונה נשברת

buffer של תמונה לא תמיד רציף לפי 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, ואורזים מחדש ל-buffer BGRA רציף top-down לפני שמעבירים ל-PNG. כשסוגרים את זה מראש, צד השמירה נהיה פשוט בהרבה.

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

איור 8: עם Lock2D לוקחים את ה-stride האמיתי ואת הכיוון, אורזים ל-BGRA רציף top-down, ורק אז שומרים.

4.4. לא להניח שהבייט הרביעי של MFVideoFormat_RGB32 הוא alpha

בניגוד למה שהשם מרמז, MFVideoFormat_RGB32 הוא לא “RGBA נקי” שאפשר להעביר ישר ל-PNG. ב-32-bit RGB של Windows, bytes 0, 1, 2 הם B, G, R, ו-byte 3 יכול להיות alpha או ignore. הנקודה החשובה: זה לא ARGB32.

אם מניחים שזה GUID_WICPixelFormat32bppBGRA ושומרים כמו שהוא, הבייט הרביעי יכול להיות 0 והתמונה יוצאת שקופה בלי סיבה. הפעם המדיניות היא למלא alpha ב-0xFF לפני השמירה, כדי שהתמונה תהיה fully opaque.

הטיפול בבייט הרביעי של RGB32תרשים שמראה שב-MFVideoFormat_RGB32 bytes 0 עד 2 הם B, G, R והבייט הרביעי הוא לא בהכרח alpha, ולכן כתיבה ישירה ל-PNG יכולה לצאת שקופה, וממלאים 0xFF לפני השמירה כדי לקבל opaque.שמירה ישירהממלאים 0xFF ואז שומריםframe בפורמט MFVideoFormat_RGB32הבייט הרביעי הוא לא בהכרח alphaיכול לצאת PNG שקוףPNG fully opaque

איור 9: לא מקבעים מראש את הבייט הרביעי — ממלאים 0xFF לפני השמירה וסוגרים opaque.

5. זרימת המימוש

5.1. בניית Source Reader במצב synchronous

הפעם מספיק תמונה אחת, אז בוחרים ReadSample synchronous במקום async callback. במצב synchronous, ReadSample חוסם עד ה-sample הבא, אבל לשליפת still בודד המימוש נשאר פשוט למדי.

ביצירת ה-Reader עושים ארבעה דברים:

  • MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING = TRUE
  • כיבוי כל ה-streams קודם
  • הפעלת MF_SOURCE_READER_FIRST_VIDEO_STREAM בלבד
  • הגדרת output type ל-MFMediaType_Video / MFVideoFormat_RGB32

אחרי זה נוח לכתוב את המשך הקוד בהנחה ש”מגיע frame בפורמט RGB32”.

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

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

5.2. אחרי seek ממשיכים לפי timestamp

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

הצעד הקטן הזה סופג חלק גדול מחוסר הדיוק של ה-seek.

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

איור 11: לא שומרים מיד אחרי ה-seek. ממשיכים לקרוא עד שחוצים את היעד, ואז משווים.

5.3. מ-sample ל-BGRA top-down

לא כותבים את ה-sample שהתקבל ישר ל-PNG. קודם אורזים אותו ל-buffer BGRA top-down.

  • הופכים ל-buffer אחד עם ConvertToContiguousBuffer
  • מקבלים scan line 0 ואת ה-stride האמיתי עם ה-helper BufferLock
  • מעתיקים שורה-שורה ל-buffer top-down
  • שמים alpha ל-0xFF

אחרי זה צד השמירה יכול להתייחס לזה כאל “תמונת 32bpp BGRA רגילה”.

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

איור 12: אחרי ארבעת שלבי האריזה, צד השמירה יכול להתייחס לזה כאל תמונת 32bpp BGRA רגילה.

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

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

חלוקת העבודה בין Media Foundation ל-WICתרשים שמראה ש-Media Foundation אחראי לשליפת ה-frame ו-WIC אחראי להפוך אותו לתמונה ולכתוב PNG.שולף את ה-frameWIC הופך לתמונהMedia Foundationframe BGRAקובץ PNG

איור 13: שליפת frame ב-Media Foundation, הפיכה לתמונה ב-WIC — הכול על API סטנדרטי בלבד.

6. checklist למימוש

פריט מה בודקים מה קורה אם מפספסים
דיוק ה-seek לא לבחור לפי קריאה אחת מיד אחרי SetCurrentPosition שומרים frame רחוק מאוד לפני הזמן שביקשת
NULL של ה-sample בודקים HRESULT, flags ו-pSample יחד null dereference בסוף ה-stream או ב-gap
stride לוקחים את ה-stride האמיתי ואת כיוון השורות התמונה נשברת, יוצאת הפוכה
הבייט הרביעי של RGB32 שמים alpha ל-0xFF PNG שקוף
טווח הזמן שומרים על 0 <= target < duration התנהגות לא צפויה ליד הסוף
שליפה רצופה חוזרים על seek בלי לבנות מחדש את ה-Reader איטיות מיותרת
מספר ה-copies בעיבוד כבד מתחשבים בעלות של ConvertToContiguousBuffer בזבוז CPU ו-memory bandwidth
שינוי פורמט וידאו מיוחד שהרזולוציה משתנה באמצע דורש עיצוב נפרד הנחות width/height נשברות

מתוך אלה, רק שתי השורות “שליפה רצופה” ו”מספר ה-copies” רלוונטיות לא לשליפת תמונה אחת, אלא כשמרחיבים לעשרות תמונות מאותו וידאו. הדוגמה הפעם נגמרת בשליפת תמונה אחת, אז כששולפים כמה תמונות עוברים מבנייה מחדש של Source Reader לחזרה על SetCurrentPosition ו-ReadSample. המפה הכללית של Media Foundation, כולל חלופות ל-Source Reader (כמו Media Session), נמצאת ב-מבוא ל-Media Foundation - הבנת ה-API מנקודת מבט של COM.

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

איור 14: כשמרחיבים לכמה תמונות, חוזרים על seek וקריאה בלי לבנות מחדש את ה-Reader.

7. הערות build והרצה

הקוד בסוף המאמר בנוי כך שקל להוסיף כקובץ .cpp אחד לפרויקט C++ Console App ב-Visual Studio. הנחות הסביבה עצמה מרוכזות בפרק 2.2.

7.1. הערות build

כמה נקודות שכדאי לדעת מראש:

  • יש #pragma comment(lib, ...), אז בדרך כלל לא צריך הגדרות linker נוספות
  • משתמשים ב-wmain, כך ש-command-line arguments מטופלים כ-Unicode לכל האורך
  • כדי שיהיה קל להדביק גם אם ב-template ברירת המחדל של Console App יש pch.h או stdafx.h, בראש הקוד יש __has_include שמזהה אותם
  • ואם בכל זאת הפרויקט כופה precompiled header משלו, מספיק להגדיר ל-.cpp הזה בלבד “Not Using Precompiled Headers” כדי שזה יעבור
  • מומלץ להריץ ב-x64

7.2. איך מריצים, ומה יוצא בהצלחה

השימוש: ExtractFrameFromMp4.exe <input.mp4> <seconds> <output.png>. לדוגמה: ExtractFrameFromMp4.exe C:\work\input.mp4 12.345 C:\work\frame.png.

בהצלחה, בסוף wmain מודפסות שלוש השורות הבאות ל-stdout:

Saved: C:\work\frame.png
Requested: 12.345 sec
Actual: (זמן התצוגה של ה-frame שנבחר) sec

Requested הוא בדיוק מספר השניות שהועבר בארגומנט, ו-Actual הוא ה-timestamp בפועל של ה-frame שנבחר. לפי הכלל בסעיף 3.2 בוחרים את הקרוב יותר מבין ה-frames שלפני ואחרי, אז ההפרש נשאר בערך בתוך חצי ממרווח ה-frames (ב-29.97fps, בערך 17ms כגבול עליון). אם ההפרש עולה על כמה מאות ms, כדאי לחשוד שקוראים רק פעם אחת אחרי ה-seek, כלומר שההשוואה מפרק 4.1 לא עובדת.

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

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

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

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

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

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

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

8. סיכום

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

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

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

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

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

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

9. מקורות

10. הקוד המלא להדבקה ב-.cpp

הבלוק הבא, בשלמותו, הוא הקוד שמיועד להעברה ישירה לפרויקט C++ Console App ב-Visual Studio. סדר ה-command-line arguments הוא input.mp4, seconds, output.png. ההרכב הוא קובץ אחד שלם, כך שקל להדביק אותו לפרויקט.

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

פונקציה / מחלקה בקוד המקום במאמר תפקיד, והמלכודת שהיא מטפלת בה
MediaFoundationScope 5.1 מרכזת אתחול וניקוי של CoInitializeEx ו-MFStartup
CreateConfiguredSourceReader 5.1 בונה את ה-Reader, בוחרת רק video 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 משווה לפני ואחרי היעד ובוחרת frame אחד. גם מטפלת כאן ב-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 מעתיקה שורה-שורה ל-buffer top-down, וממלאת את הבייט הרביעי ב-0xFF
CopySampleToTopDownBgra 5.3 מוציאה frame size ו-stride וקוראת ל-copy שלעיל
SaveBgraToPng 5.4 כותבת עם WIC תמונת 32bpp BGRA כ-PNG
ExtractFrameFromMp4ToPng 5. כולו entry point שקורא לפונקציות שלעיל בסדר
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 מקבעים אותו ל-opaque.
        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;
}

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

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

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

שאלות נפוצות

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

במה משתמשים כדי לשלוף still מ-MP4 בזמן נתון?
אם צריך תמונה אחת, IMFSourceReader הוא entry point נוח יותר מ-Media Session. פותחים את הקובץ עם MFCreateSourceReaderFromURL, בוחרים רק וידאו עם SetStreamSelection, מבקשים MFVideoFormat_RGB32, עושים seek עם SetCurrentPosition, קוראים frame עם ReadSample, ושומרים PNG עם WIC. הכול על Windows API סטנדרטי, בלי ספריות חיצוניות.
האם SetCurrentPosition מזיז בדיוק לזמן שביקשת?
לא. IMFSourceReader::SetCurrentPosition לא מבטיח exact seeking. בווידאו הוא בדרך כלל נוחת קצת לפני היעד, בעיקר לכיוון key frame. אחרי ה-seek ממשיכים עם ReadSample, בודקים timestamp, משווים את ה-sample האחרון שלפני היעד עם הראשון שאחריו, ובוחרים את הקרוב יותר. מימוש שקורא פעם אחת אחרי seek ושומר ייסחף בקלות על וידאו עם GOP ארוך.
למה ה-PNG שנשמר יוצא שקוף?
כי הבייט הרביעי של MFVideoFormat_RGB32 הוא לא בהכרח alpha. ב-32-bit RGB של Windows, bytes 0, 1, 2 הם B, G, R, ו-byte 3 יכול להיות alpha או ignore (זה לא ARGB32). כתיבה ישירה ל-PNG יכולה לתת תמונה שקופה, אז לפני השמירה שמים 0xFF בבייט הרביעי ועושים את התמונה opaque.
מה גורם לתמונה לצאת שבורה או הפוכה?
stride וכיוון השורות. buffer של תמונה לא תמיד רציף לפי width × bytesPerPixel — לפעמים יש padding בסוף שורה, ופורמטי RGB יכולים להיות bottom-up (stride שלילי). עם IMF2DBuffer::Lock2D מקבלים מצביע לתחילת scan line 0 ואת ה-stride האמיתי, ואורזים מחדש ל-buffer BGRA רציף top-down לפני ה-PNG. צד השמירה נהיה פשוט, והעיוות נמנע.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג