צריבת תמונה וטקסט על MP4 עם Media Foundation

· עודכן בתאריך: · · Media Foundation, ‏C++, פיתוח Windows, GDI+, Direct2D, DirectWrite, H.264

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

אבל כשמתחילים לגעת ב-Media Foundation, מופיעים IMFSourceReader,‏ IMFSample,‏ IMFMediaBuffer,‏ IMFTransform,‏ IMFSinkWriter, ופתאום נעשה לא ברור איפה בדיוק מציבים את הטקסט או ה-PNG.

במאמר הזה, נסדר קודם את התמונה הכללית Source Reader -> ציור -> המרת צבע -> Sink Writer, ואז נביא דוגמה מלאה בקובץ אחד שאפשר להדביק ישירות לאפליקציית console ב-‏C++ ב-Visual Studio. הדוגמה קוראת MP4 שצוין, מציירת על כל מסגרת את התמונה שצוינה ואת HelloWorld, ויוצרת MP4 בפלט.

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

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

איור 1: הדוגמה הראשונה מוגבלת להרכב מינימלי שרק הנושא של הצריבה עובר בו.

הקוד שמופיע במאמר הזה זמין כערכת דוגמה מלאה (‏.cpp בקובץ אחד והרכב בנייה עם CMake) שפרסמנו ב-GitHub.

media-foundation-overlay-image-text-on-mp4-frames - komurasoft-blog-samples (GitHub)

קהל היעד והסביבה הנדרשת

המאמר נכתב עבור מפתחים ברמת ביניים שכותבים לראשונה עיבוד וידאו ב-‏C++ ל-Windows. ההנחה היא שכבר נתקלתם ביסודות COM (‏ComPtr,‏ HRESULT, ספירת הפניות), ושזה השלב הראשון שלכם עם Media Foundation.

הסביבה הנדרשת להרצת הדוגמה היא:

פריט הנחת יסוד
מערכת הפעלה Windows 10 / 11
סביבת פיתוח אפליקציית console ב-‏C++ עם Visual Studio 2022
הרכב בנייה x64
header מוקדם-קומפילציה ה-.cpp הזה מוגדר בלי header כזה
וידאו לקלט MP4 רגיל. רוחב וגובה חייבים להיות זוגיים (מכיוון ש-NV12 הוא 4:2:0)
פלט MP4 שהוא רק וידאו. אין אודיו

מונחים שכדאי להבין מראש

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

מונח משמעות
remux בניית מחדש רק של המכל (container), כשהנתונים הדחוסים בתוכו נשארים כמות שהם. מכיוון שאין קידוד מחדש, איכות התמונה והצליל לא נפגמת, וגם העיבוד קל
topology גרף שמייצג ב-Media Foundation “מאיזה רכיב לאיזה רכיב זורם המידע”. מקביל לתרשים הרכב שמחבר מקור, המרה ויעד
custom MFT ‏Media Foundation Transform שכותבים בעצמכם. אם ממשים IMFTransform, אפשר לשלב אפקט כרכיב בתוך צינור העיבוד (pipeline) של Media Foundation
stride מספר הבייטים שתופסת שורה אחת של תמונה בזיכרון. לא בהכרח שווה ל-רוחב × 4, ולפעמים יש רווח בסוף השורה

1. קודם כל - המסקנה

  • הצורה הבסיסית להכנסת תמונה או טקסט לכל מסגרת ב-MP4 היא פענוח עם Source Reader -> הרכבה על מסגרת לא דחוסה -> המרת צבע במידת הצורך -> קידוד מחדש עם Sink Writer.
  • עצם פעולת הצבת התמונה או הטקסט אינה עבודה של Media Foundation. ישיר יותר לחשוב עליה במונחי API-ים לציור כמו GDI+,‏ Direct2D,‏ DirectWrite,‏ WIC.
  • כשחוזרים ל-MP4 (‏H.264), לרוב נדרש שלב המרה בין RGB32 / ARGB32 שקל לצייר עליו, לבין NV12 / I420 / YUY2 שהמקודד מקבל בקלות.
  • כדי להריץ דוגמה ראשונה, ההרכב Source Reader -> RGB32 -> ציור עם GDI+ -> NV12 -> Sink Writer ברור יחסית.
  • אם רוצים לתת עדיפות למהירות וליכולת הרחבה, יש פוטנציאל בנטייה להרכב D3D11 / DXGI surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer.

מפת הידע של המאמר

המאמר מסדר את ההרכב לצריבת תמונה וטקסט על כל מסגרת ב-MP4 עם Media Foundation, ויצירת MP4 חדש. העיבוד מפענח מסגרת RGB32 עם IMFSourceReader, בדוגמה המלאה בקובץ אחד GDI+ מרכיבה תמונה וטקסט, ומכיוון שמקודד H.264 לרוב מניח קלט מסוג YUV כמו NV12, ממירים מ-BGRA ל-NV12 ואז IMFSinkWriter כותב כ-H.264 ל-MP4. את ההבדל ב-stride ובכיוון נספגים עם IMF2DBuffer::Lock2D ומנרמלים ל-BGRA מסוג top-down, ו-ReadSample דורש בדיקה של שלושה דברים - HRESULT,‏ flags ו-sample. לקראת ייצור, ההרחבות המוצגות בהדרגה הן העברת המרת הצבע ל-Video Processor MFT, החלפת הציור ל-Direct2D/DirectWrite, והוצאה ל-custom MFT אם נדרש שימוש חוזר, כאשר remux של אודיו ממוקם כשלב שמוסיפים אחרי שמייצבים קודם את צריבת הווידאו.

מפת הידע של צריבת תמונה וטקסט על MP4 עם Media Foundationתרשים שמראה את פענוח ה-RGB32 עם IMFSourceReader ואת ההרכבה עם GDI+, שהמרה בין משפחת RGB ל-NV12 נדרשת כקלט למקודד H.264, את הכתיבה עם IMFSinkWriter, ספיגת ה-stride וכיוון top-down/bottom-up, ההרחבה ל-Direct2D/DirectWrite,‏ Video Processor MFT ו-custom MFT, ומיקום remux של אודיו כשלב מאוחר.משתמש במשתמש במענה מומלץ למחייבמשתמש במחייבמשתמש באינו מתיישב עםמחייבמחייבמשתמש במענה מומלץ למחייבמשתמש במחייבמשתמש בעלול לגרום לצריך לקדום למענה מומלץ למחייבצריבת תמונה או טקסט על מסגרות הווידאוIMFSinkWriter(Sink Writer)IMFSourceReader(Source Reader)GDI+Direct2D / DirectWrite‏stride (מספר הבייטים בשורת תמונה)IMF2DBuffer::Lock2D‏top-down / bottom-up (כיוון השורות בתמונה)MFVideoFormat_RGB32H.264 Video Encoder(Media Foundation)פורמט הפיקסלים NV12המרת צבע מ-BGRA ל-NV12Video Processor MFTIMFSourceReader::ReadSampleדגימת null מ-ReadSampleremux של האודיוcustom MFTMedia Foundation

בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 20, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle

2. למה הבעיה הזו קצת מסובכת

“להכניס טקסט לווידאו” הוא בעצם ערבוב של ארבעה נושאים שונים.

  1. נושא המכל (container) והקודקmp4 הוא מכל, לא המסגרת עצמה. התוכן בדרך כלל נתונים דחוסים בפורמט H.264 או H.265.

  2. נושא הפענוח / הקידוד כל עוד הנתונים דחוסים, אי אפשר להציב עליהם טקסט או PNG ישירות עם API רגיל לציור דו-ממדי. קודם צריך להחזיר אותם למסגרת לא דחוסה.

  3. נושא הציור טקסט, לוגו, הרכבה שקופה של PNG, ציור טקסט עם anti-aliasing - אלה לא תפקידי הגוף המרכזי של Media Foundation. זו עבודה של GDI+ או Direct2D / DirectWrite / WIC.

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

אם מסכמים בשורה אחת בגסות: הדרך הכי קלה לסדר את המחשבה היא לא “מכניסים טקסט עם Media Foundation”, אלא “מסובבים מסגרות עם Media Foundation, מציבים עליהן עם API ציור, ומכניסים המרת צבע נדרשת לפני הקידוד”.

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

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

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

כיוון הרכב מתאים למקרה נקודה לתשומת לב
קודם להריץ נכון Source Reader -> RGB32 -> הרכבה -> NV12 -> Sink Writer עיבוד אצווה, כלי פנים-ארגוני, מימוש ראשוני נוטה להגדיל העתקות והמרות בצד ה-CPU
להעלות מהירות D3D11 / DXGI surface -> Direct2D / DirectWrite -> Video Processor MFT -> Sink Writer סרטון ארוך, רזולוציה גבוהה, עיבוד בכמות גדולה ניהול D3D11 ו-DXGI גדל
להפוך לרכיב לשימוש חוזר ממשים כ-MFT מותאם אישית ומשלבים ב-topology אפקט שמשמש כמה אפליקציות, כשרוצים לשלב לתוך צינור MF דרגת הקושי במימוש, רישום וניפוי באגים עולה

הדוגמה במאמר הזה מצומצמת להרכב “קודם להריץ נכון” שבשורה העליונה.

3.1 תמונת התהליך

תמונת התהליך המלאהתרשים שמראה שהקלט עובר דרך Source Reader למסגרת לא דחוסה RGB32, GDI+ מציירת תמונה ו-HelloWorld, מתבצעת המרת BGRA ל-NV12, ו-IMFSinkWriter כותב את הפלט, כשמקביל לכך דגימת האודיו מועתקת כמות שהיא או מקודדת מחדש.input.mp4IMFSourceReaderמסגרת לא דחוסה(RGB32)GDI+ מציירת תמונה + HelloWorldהמרת BGRA -> NV12IMFSinkWriteroutput.mp4דגימת אודיומעתיקים כמות שהיא, או מקודדים מחדש

איור 3: מוציאים עם Source Reader, מציירים עם GDI+, ממירים ל-NV12, וכותבים חזרה עם Sink Writer.

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

4. איך מחלקים את החשיבה על ה-pipeline

4.1 מקבלים את הקלט עם IMFSourceReader

אם הקלט הוא נתיב קובץ, מובן להשתמש ב-MFCreateSourceReaderFromURL; אם מדובר בנתוני וידאו בזיכרון, ברור להכין IMFByteStream ולהשתמש ב-MFCreateSourceReaderFromByteStream.

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

  • אם רוצים לפשט את המימוש - RGB32 או ARGB32
  • אם רוצים לתת עדיפות ליעילות הקידוד - YUV כמו NV12

עם זאת, מכיוון ש-הרכבת טקסט ו-PNG הרבה יותר קלה למחשבה במשפחת RGB, נוח בהתחלה לקבל ב-RGB32 / ARGB32.

הבחירה הראשונית באיזה פורמט לקבלתרשים שמראה שאם רוצים לפשט את המימוש בוחרים RGB32 או ARGB32, ואם רוצים עדיפות ליעילות קידוד בוחרים YUV כמו NV12, אבל מכיוון שהרכבת טקסט ו-PNG קלה יותר במשפחת RGB, נוח להתחיל שם.פשטות המימושיעילות הקידודמה נותנים לו עדיפותמקבלים ב-RGB32 / ARGB32מקבלים ב-YUV כמו NV12הרכבה קלה יותר במשפחת RGB

איור 4: כשמתלבטים, מתחילים לקבל במשפחת RGB מתוך עדיפות לקלות הציור.

אם מפעילים את MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, ה-Source Reader יבצע עבורכם המרת YUV -> RGB32 וגם ביטול interlace. זה נוח בשלב שבו “רוצים קודם כל להוציא ולעבד מסגרות”, אבל בסרטונים ארוכים או ברזולוציה גבוהה זה נוטה להיות כבד, ולכן אם צריך מהירות בייצור, יש ערך לבדוק מחדש את ההרכב בהמשך.

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

איור 5: לדגל הנוח שמקל על ההוצאה יש מחיר מבחינת מהירות.

4.2 הרכבת התמונה והטקסט - GDI+ או Direct2D / DirectWrite

מוציאים buffer מתוך IMFSample שהתקבל מ-Media Foundation, ומציבים עליו את תמונת הלוגו או את הטקסט.

הדוגמה הזו נותנת עדיפות ל-הדבקה מלאה בקובץ אחד, ולכן משתמשת ב-GDI+ לציור.

  • אפשר לטעון תמונה
  • אפשר לצייר טקסט
  • ההכנה המוקדמת הנדרשת יחסית מועטה
  • קל להכניס לתוך .cpp אחד של אפליקציית console

מצד שני, בשימושים של סרטונים ארוכים או עיבוד 4K בכמות גדולה, ל-D3D11 + Direct2D + DirectWrite יש פוטנציאל גדול יותר. טבעי להתחיל עם GDI+ במימוש הראשון, ולעבור ל-Direct2D / DirectWrite בשלב שבו רוצים לדחוס מהירות.

4.3 לא בהכרח אפשר לכתוב RGB32 ישירות ל-H.264

זו הנקודה שהכי קל להיתקע בה.

בחזרה ל-MP4 (‏H.264), מקודדי H.264 של Microsoft לרוב מניחים קלט מסוג YUV כמו I420 / IYUV / NV12 / YUY2 / YV12. כלומר, לא בהכרח אפשר פשוט להרכיב ב-RGB32 / ARGB32 שקל לצייר עליו, ולזרוק ישירות ל-IMFSinkWriter.

לכן, במימוש נדרשת אחת מהאפשרויות הבאות.

  • להכניס Video Processor MFT ולהמיר RGB32 / ARGB32 -> NV12
  • להכניס המרה עצמאית RGB -> NV12

הדוגמה הזו נותנת עדיפות ל-השלמה בקובץ אחד, ולכן משתמשת באפשרות השנייה - המרה עצמאית. בייצור, גם ההרכב שמכניס Video Processor MFT שיכול לטפל בו-זמנית בהמרת מרחב צבע, שינוי גודל וביטול interlace הוא אפשרות חזקה.

שתי הדרכים לחבר RGB ל-NV12תרשים שמראה שאחרי הרכבה במשפחת RGB שקלה לציור, נדרשת אחת מהאפשרויות - להכניס Video Processor MFT ולהמיר, או לבצע המרה עצמאית ל-NV12, כאשר הדוגמה הזו בוחרת בהמרה העצמאית מתוך עדיפות להשלמה בקובץ אחד.ההרכבה במשפחת RGB הסתיימההמרה עם Video Processor MFTהמרה עצמאית ל-NV12הדוגמה נותנת עדיפות להשלמה בקובץ אחדהרכב חזק בייצור

איור 6: שלב ההמרה נדרש בכל מקרה, ורק בוחרים למי להטיל אותו.

4.4 כותבים את הפלט עם IMFSinkWriter

לפלט וידאו, IMFSinkWriter נוח.

הגישה פשוטה:

  • טיפוס זרם הפלט - הפורמט שרוצים לכתוב לקובץ לדוגמה: MFVideoFormat_H264
  • טיפוס זרם הקלט - הפורמט שהאפליקציה מעבירה ל-Sink Writer לדוגמה: MFVideoFormat_NV12

ומגדירים אותם בנפרד.

כלומר, מנקודת המבט של Sink Writer:

  • צד האפליקציה מעביר מסגרות לא דחוסות מסוג NV12
  • Sink Writer מקודד אותן ל-H.264 וכותב ל-MP4

זהו היחס.

חלוקת התפקידים בין טיפוסי הקלט והפלט של Sink Writerתרשים שמראה של-Sink Writer מגדירים בנפרד את טיפוס זרם הקלט שהאפליקציה מעבירה - NV12, ואת טיפוס זרם הפלט שרוצים לכתוב לקובץ - H.264, כאשר Sink Writer מטיל את הקידוד וכותב ל-MP4.האפליקציה מעבירה מסגרת NV12Sink Writerמקודד ל-H.264כותב ל-MP4מגדירים בנפרד טיפוס קלט וטיפוס פלט

איור 7: להגדיר בנפרד את הפורמט שמעבירים לבין הפורמט שנכתב הוא דרך העבודה של Sink Writer.

4.5 בהתחלה נוח לחשוב על האודיו בנפרד

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

בפועל, ההרכב:

  • זרם הווידאו בלבד עובר Source Reader -> הרכבה -> Sink Writer
  • זרם האודיו נשאר compressed ועובר remux

נוח.

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

לחשוב בנפרד על וידאו ואודיותרשים שמראה שבפועל נוח שזרם הווידאו בלבד עובר מ-Source Reader דרך הרכבה ל-Sink Writer, וזרם האודיו נשאר דחוס ועובר remux, כאשר הדוגמה הזו לצורך מיקוד לא מטפלת באודיו.זרם הווידאוהרכבה ואז ל-Sink Writerזרם האודיוremux כשהוא נשאר דחוסלא מטופל בדוגמה הזו

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

5. ההנחות והשימוש בדוגמה הזו

הנחות היסוד של הקוד הזה הן:

  • Windows 10 / 11
  • אפליקציית console ב-‏C++ עם Visual Studio 2022
  • בנייה עם x64
  • קובץ ה-.cpp הזה לא משתמש ב-header מוקדם-קומפילציה
  • רוחב וגובה הווידאו בקלט זוגיים
  • הקלט הוא קובץ וידאו MP4 רגיל
  • הפלט הוא MP4 שהוא רק וידאו
  • התמונה בפורמט כמו PNG /‏ JPEG /‏ BMP /‏ GIF, שגם GDI+ יכולה לקרוא

מכיוון ש-NV12 הוא 4:2:0, רוחב וגובה חייבים להיות זוגיים. לכן, בדוגמה הזו, אם התנאי לא מתקיים, יש שגיאה מפורשת.

ההנחה שרוחב וגובה זוגייםתרשים שמראה שמכיוון ש-NV12 הוא בדגימה 4:2:0, רוחב וגובה הווידאו בקלט חייבים להיות זוגיים, ובדוגמה הזו אם התנאי לא מתקיים, זה נעצר בשגיאה מפורשת.זוגיכולל ערך אי-זוגיNV12 הוא 4:2:0רוחב וגובה חייבים להיות זוגייםהקלט זוגי?ממשיכים בעיבודנעצרים בשגיאה מפורשת

איור 9: עדיף שקלט שלא עומד בתנאי ייעצר בכניסה, ולא שיתפרק בשקט.

5.1 השימוש

  1. יוצרים Console App ב-Visual Studio
  2. מדביקים את ה-.cpp הזה כמות שהוא
  3. הופכים את ה-header המוקדם-קומפילציה של ה-.cpp הזה ל”לא בשימוש”
  4. בונים עם x64
  5. מריצים כך:
OverlayMp4.exe input.mp4 overlay.png output.mp4
  • input.mp4 הסרטון המקורי
  • overlay.png התמונה שרוצים להרכיב
  • output.mp4 יעד הפלט

הטקסט קבוע בקבוע kOverlayText בראש הקוד כ-HelloWorld. ניתן לשנות את המיקום והגודל דרך הקבועים בקוד. שיפור שמאפשר להעביר את הטקסט כארגומנט שורת פקודה מובא בסעיף 9.5.

5.2 מוודאים שזה עבד נכון

“הסתיים בלי שגיאה” ו-“נצרב נכון” הם שני דברים שונים. אם בודקים בסדר את ארבעת אלה, אפשר לגלות את רוב הכשלים.

  1. בודקים את מספר המסגרות בסיום. הדוגמה הזו, כשהיא מסיימת, מדפיסה Done. frames= יחד עם מספר המסגרות שנכתבו. אם יש פער גדול ממספר המסגרות הכולל של הסרטון בקלט, אז מקום כלשהו בלולאת ReadSample מפיל מסגרות
  2. משווים את המידע הבסיסי של קובץ הפלט לקלט. אם לוחצים ימני על ה-MP4 בפלט ב-Explorer ופותחים מאפיינים > פרטים, מקבלים משך, רוחב מסגרת, גובה מסגרת וקצב מסגרות. אם המשך לא תואם לקלט, יש לחשוד בטיפול ב-timestamp (‏7.4)
  3. בודקים חזותית שלושה מקומות - התחלה, אמצע וסוף. אם בודקים רק את המסגרת הראשונה ומרגישים בטוחים, קל לפספס תקלה שבה ה-overlay נעלם באמצע. חיתוך תמונת סטילס מאותו זמן משני הקבצים, קלט ופלט, והצבתם זה לצד זה, בטוח יותר, והתהליך מפורט ב”איך לחתוך תמונת סטילס מזמן מסוים ב-MP4 עם Media Foundation
  4. בודקים שהצבע לא מוזר. אם צבע העור או השמיים נראים לא טבעיים, יש חשד שבחירת המקדמים ב-BgraToNv12 (‏BT.601 מול BT.709) לא תואמת לקלט

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

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

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

6. הקוד המלא בקובץ אחד שאפשר להדביק ישירות ל-.cpp

6.1 מפת הקוד

נתחיל במפה. הקוד ארוך, אבל מה שבאמת חשוב לקרוא הוא רק CopySampleToTopDownBgra,‏ DrawOverlay,‏ BgraToNv12, ולולאת wmain. השאר הוא אתחול וניקוי.

פונקציה / מחלקה תפקיד הסבר מפורט
ScopedMf /‏ ScopedGdiplus מצמידה עם RAII את האתחול והסיום של MFStartup ושל GDI+
ConfigureSourceReader מגדירה את פלט ה-Source Reader ל-RGB32, ומוציאה רוחב, גובה, fps ו-frame duration ברירת מחדל 4.1
GetDefaultStride מחשבת stride ברירת מחדל מתוך media type 7.2
BufferLock נועלת את ה-buffer עם IMF2DBuffer אם קיים, אחרת עם IMFMediaBuffer 7.2
CopySampleToTopDownBgra סופגת stride וכיוון עליון-תחתון ומנרמלת ל-BGRA מסוג top-down 7.2
DrawOverlay מציירת עם GDI+ תמונה וטקסט. זהו “שלב הציור” היחיד 4.2 /‏ 7.1
BgraToNv12 ממירה את ה-BGRA שהסתיים לצייר ל-NV12 4.3 /‏ 7.1
CreateNv12Sample עוטפת buffer מסוג NV12 בתוך IMFSample, ומוסיפה timestamp ו-duration 7.4
ChooseBitrate קובעת bitrate לפלט מתוך מידע הקלט
CreateSinkWriter מגדירה את טיפוס ה-H.264 בצד הפלט ואת טיפוס ה-NV12 שהצד שלנו מעביר 4.4
לולאת while בתוך wmain מסובבת מסגרת אחר מסגרת תוך בדיקת HRESULT /‏ flags /‏ sample של ReadSample 7.3 /‏ 7.4

אם משווים לתמונת התהליך בפרק 3, CopySampleToTopDownBgra מתאים ל”מסגרת לא דחוסה”, DrawOverlay ל”ציור עם GDI+”, ו-BgraToNv12 ל”המרת BGRA ל-NV12”.

שלוש הפונקציות המרכזיות שכדאי לקרואתרשים שמראה שמתוך הקוד הארוך, מה שבאמת חשוב לקרוא הוא שלוש הפונקציות CopySampleToTopDownBgra,‏ DrawOverlay ו-BgraToNv12, יחד עם לולאת wmain, כשכל אחת מתאימה לנרמול למסגרת לא דחוסה, לציור, ולהמרת NV12.לולאת wmain מסובבת מסגרת אחר מסגרתCopySampleToTopDownBgraDrawOverlayBgraToNv12נרמול למסגרת לא דחוסהציור תמונה וטקסטהכנה לצורך קידוד

איור 11: השאר הוא אתחול וניקוי, והמרכז הוא רק שלוש הפונקציות והלולאה הזו.

6.2 הקוד המלא

#define NOMINMAX
#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mferror.h>
#include <gdiplus.h>
#include <wrl/client.h>

#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cwchar>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfreadwrite.lib")
#pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "mf.lib")
#pragma comment(lib, "gdiplus.lib")

using Microsoft::WRL::ComPtr;

namespace
{
    const wchar_t* kOverlayText = L"HelloWorld";
    const float kMarginRatio = 0.03f;
    const float kImageMaxWidthRatio = 0.20f;
    const float kImageMaxHeightRatio = 0.20f;
    const float kMinFontPx = 24.0f;

    std::string HrToHex(HRESULT hr)
    {
        char buf[32]{};
        std::snprintf(buf, sizeof(buf), "0x%08X", static_cast<unsigned int>(hr));
        return std::string(buf);
    }

    void ThrowIfFailed(HRESULT hr, const char* message)
    {
        if (FAILED(hr))
        {
            throw std::runtime_error(std::string(message) + " failed. HRESULT=" + HrToHex(hr));
        }
    }

    void ThrowIfGdiplusError(Gdiplus::Status status, const char* message)
    {
        if (status != Gdiplus::Ok)
        {
            char buf[128]{};
            std::snprintf(buf, sizeof(buf), "%s failed. GDI+ status=%d", message, static_cast<int>(status));
            throw std::runtime_error(buf);
        }
    }

    BYTE ClampToByte(int value)
    {
        if (value < 0) return 0;
        if (value > 255) return 255;
        return static_cast<BYTE>(value);
    }

    class ScopedGdiplus
    {
    public:
        ScopedGdiplus()
        {
            Gdiplus::GdiplusStartupInput input;
            ThrowIfGdiplusError(Gdiplus::GdiplusStartup(&token_, &input, nullptr), "GdiplusStartup");
        }

        ~ScopedGdiplus()
        {
            if (token_ != 0)
            {
                Gdiplus::GdiplusShutdown(token_);
            }
        }

    private:
        ULONG_PTR token_ = 0;
    };

    class ScopedMf
    {
    public:
        ScopedMf()
        {
            ThrowIfFailed(CoInitializeEx(nullptr, COINIT_MULTITHREADED), "CoInitializeEx");
            comInitialized_ = true;

            ThrowIfFailed(MFStartup(MF_VERSION), "MFStartup");
            mfStarted_ = true;
        }

        ~ScopedMf()
        {
            if (mfStarted_)
            {
                MFShutdown();
            }

            if (comInitialized_)
            {
                CoUninitialize();
            }
        }

    private:
        bool comInitialized_ = false;
        bool mfStarted_ = false;
    };

    class BufferLock
    {
    public:
        explicit BufferLock(IMFMediaBuffer* buffer)
            : buffer_(buffer)
        {
            if (!buffer_)
            {
                throw std::runtime_error("BufferLock received a null buffer.");
            }

            buffer_.As(&buffer2D_);
        }

        HRESULT LockBuffer(LONG defaultStride, DWORD heightInPixels, BYTE** scanline0, LONG* actualStride)
        {
            if (scanline0 == nullptr || actualStride == nullptr)
            {
                return E_POINTER;
            }

            HRESULT hr = S_OK;

            if (buffer2D_)
            {
                hr = buffer2D_->Lock2D(scanline0, actualStride);
            }
            else
            {
                BYTE* data = nullptr;
                hr = buffer_->Lock(&data, nullptr, nullptr);
                if (SUCCEEDED(hr))
                {
                    *actualStride = defaultStride;
                    if (defaultStride < 0)
                    {
                        *scanline0 = data + (static_cast<LONG>(heightInPixels) - 1) * std::abs(defaultStride);
                    }
                    else
                    {
                        *scanline0 = data;
                    }
                }
            }

            locked_ = SUCCEEDED(hr);
            return hr;
        }

        ~BufferLock()
        {
            if (!locked_)
            {
                return;
            }

            if (buffer2D_)
            {
                buffer2D_->Unlock2D();
            }
            else
            {
                buffer_->Unlock();
            }
        }

    private:
        ComPtr<IMFMediaBuffer> buffer_;
        ComPtr<IMF2DBuffer> buffer2D_;
        bool locked_ = false;
    };

    struct VideoFormatInfo
    {
        UINT32 width = 0;
        UINT32 height = 0;
        UINT32 fpsNum = 0;
        UINT32 fpsDen = 0;
        UINT32 parNum = 1;
        UINT32 parDen = 1;
        LONG sourceStride = 0;
        LONGLONG defaultFrameDuration = 0;
        UINT32 bitrate = 0;
    };

    LONG GetDefaultStride(IMFMediaType* type)
    {
        LONG stride = 0;

        HRESULT hr = type->GetUINT32(MF_MT_DEFAULT_STRIDE, reinterpret_cast<UINT32*>(&stride));
        if (SUCCEEDED(hr))
        {
            return stride;
        }

        GUID subtype = GUID_NULL;
        UINT32 width = 0;
        UINT32 height = 0;

        ThrowIfFailed(type->GetGUID(MF_MT_SUBTYPE, &subtype), "GetGUID(MF_MT_SUBTYPE)");
        ThrowIfFailed(MFGetAttributeSize(type, MF_MT_FRAME_SIZE, &width, &height), "MFGetAttributeSize(MF_MT_FRAME_SIZE)");
        ThrowIfFailed(MFGetStrideForBitmapInfoHeader(subtype.Data1, width, &stride), "MFGetStrideForBitmapInfoHeader");
        ThrowIfFailed(type->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(stride)), "SetUINT32(MF_MT_DEFAULT_STRIDE)");

        return stride;
    }

    UINT32 ChooseBitrate(IMFMediaType* nativeType, UINT32 width, UINT32 height, UINT32 fpsNum, UINT32 fpsDen)
    {
        UINT32 srcBitrate = 0;
        if (SUCCEEDED(nativeType->GetUINT32(MF_MT_AVG_BITRATE, &srcBitrate)) && srcBitrate > 0)
        {
            return srcBitrate;
        }

        const double fps = static_cast<double>(fpsNum) / static_cast<double>(fpsDen);
        double estimated = static_cast<double>(width) * static_cast<double>(height) * fps * 0.07;

        if (estimated < 1500000.0)
        {
            estimated = 1500000.0;
        }

        if (estimated > 25000000.0)
        {
            estimated = 25000000.0;
        }

        return static_cast<UINT32>(estimated);
    }

    VideoFormatInfo ConfigureSourceReader(IMFSourceReader* reader)
    {
        ThrowIfFailed(reader->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE), "SetStreamSelection(all,false)");
        ThrowIfFailed(reader->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE), "SetStreamSelection(video,true)");

        ComPtr<IMFMediaType> nativeType;
        ThrowIfFailed(reader->GetNativeMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, 0, &nativeType), "GetNativeMediaType(video)");

        ComPtr<IMFMediaType> requestedType;
        ThrowIfFailed(MFCreateMediaType(&requestedType), "MFCreateMediaType(video requested)");
        ThrowIfFailed(requestedType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "SetGUID(video requested major)");
        ThrowIfFailed(requestedType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32), "SetGUID(video requested subtype RGB32)");
        ThrowIfFailed(reader->SetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, nullptr, requestedType.Get()), "SetCurrentMediaType(video RGB32)");

        ComPtr<IMFMediaType> currentType;
        ThrowIfFailed(reader->GetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, &currentType), "GetCurrentMediaType(video)");

        VideoFormatInfo info;
        ThrowIfFailed(MFGetAttributeSize(currentType.Get(), MF_MT_FRAME_SIZE, &info.width, &info.height), "Get video frame size");

        HRESULT hr = MFGetAttributeRatio(currentType.Get(), MF_MT_FRAME_RATE, &info.fpsNum, &info.fpsDen);
        if (FAILED(hr))
        {
            ThrowIfFailed(MFGetAttributeRatio(nativeType.Get(), MF_MT_FRAME_RATE, &info.fpsNum, &info.fpsDen), "Get video frame rate");
        }

        if (info.fpsNum == 0 || info.fpsDen == 0)
        {
            throw std::runtime_error("Video frame rate is zero.");
        }

        hr = MFGetAttributeRatio(currentType.Get(), MF_MT_PIXEL_ASPECT_RATIO, &info.parNum, &info.parDen);
        if (FAILED(hr) || info.parNum == 0 || info.parDen == 0)
        {
            info.parNum = 1;
            info.parDen = 1;
        }

        info.sourceStride = GetDefaultStride(currentType.Get());
        info.defaultFrameDuration = (10000000LL * info.fpsDen) / info.fpsNum;
        if (info.defaultFrameDuration <= 0)
        {
            throw std::runtime_error("Calculated frame duration is invalid.");
        }

        info.bitrate = ChooseBitrate(nativeType.Get(), info.width, info.height, info.fpsNum, info.fpsDen);
        return info;
    }

    ComPtr<IMFSinkWriter> CreateSinkWriter(const std::wstring& outputPath, const VideoFormatInfo& videoInfo, DWORD* streamIndex)
    {
        if (streamIndex == nullptr)
        {
            throw std::runtime_error("streamIndex is null.");
        }

        ComPtr<IMFAttributes> attributes;
        ThrowIfFailed(MFCreateAttributes(&attributes, 1), "MFCreateAttributes(sink)");
        ThrowIfFailed(attributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE), "SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS)");

        ComPtr<IMFSinkWriter> writer;
        ThrowIfFailed(MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, attributes.Get(), &writer), "MFCreateSinkWriterFromURL");

        ComPtr<IMFMediaType> outputType;
        ThrowIfFailed(MFCreateMediaType(&outputType), "MFCreateMediaType(video output)");
        ThrowIfFailed(outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "SetGUID(output major)");
        ThrowIfFailed(outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264), "SetGUID(output subtype H264)");
        ThrowIfFailed(outputType->SetUINT32(MF_MT_AVG_BITRATE, videoInfo.bitrate), "SetUINT32(output bitrate)");
        ThrowIfFailed(outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive), "SetUINT32(output interlace)");
        ThrowIfFailed(MFSetAttributeSize(outputType.Get(), MF_MT_FRAME_SIZE, videoInfo.width, videoInfo.height), "MFSetAttributeSize(output frame size)");
        ThrowIfFailed(MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, videoInfo.fpsNum, videoInfo.fpsDen), "MFSetAttributeRatio(output fps)");
        ThrowIfFailed(MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, videoInfo.parNum, videoInfo.parDen), "MFSetAttributeRatio(output PAR)");
        ThrowIfFailed(writer->AddStream(outputType.Get(), streamIndex), "AddStream(video)");

        ComPtr<IMFMediaType> inputType;
        ThrowIfFailed(MFCreateMediaType(&inputType), "MFCreateMediaType(video input)");
        ThrowIfFailed(inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "SetGUID(input major)");
        ThrowIfFailed(inputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_NV12), "SetGUID(input subtype NV12)");
        ThrowIfFailed(inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive), "SetUINT32(input interlace)");
        ThrowIfFailed(MFSetAttributeSize(inputType.Get(), MF_MT_FRAME_SIZE, videoInfo.width, videoInfo.height), "MFSetAttributeSize(input frame size)");
        ThrowIfFailed(MFSetAttributeRatio(inputType.Get(), MF_MT_FRAME_RATE, videoInfo.fpsNum, videoInfo.fpsDen), "MFSetAttributeRatio(input fps)");
        ThrowIfFailed(MFSetAttributeRatio(inputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, videoInfo.parNum, videoInfo.parDen), "MFSetAttributeRatio(input PAR)");
        ThrowIfFailed(writer->SetInputMediaType(*streamIndex, inputType.Get(), nullptr), "SetInputMediaType(video)");

        ThrowIfFailed(writer->BeginWriting(), "BeginWriting");
        return writer;
    }

    void CopySampleToTopDownBgra(IMFSample* sample, const VideoFormatInfo& videoInfo, std::vector<BYTE>& bgra)
    {
        ComPtr<IMFMediaBuffer> buffer;
        ThrowIfFailed(sample->ConvertToContiguousBuffer(&buffer), "ConvertToContiguousBuffer");

        BufferLock lock(buffer.Get());

        BYTE* scanline0 = nullptr;
        LONG actualStride = 0;
        ThrowIfFailed(lock.LockBuffer(videoInfo.sourceStride, videoInfo.height, &scanline0, &actualStride), "LockBuffer");

        const size_t dstStride = static_cast<size_t>(videoInfo.width) * 4;
        bgra.resize(dstStride * videoInfo.height);

        for (UINT32 y = 0; y < videoInfo.height; ++y)
        {
            const BYTE* srcRow = scanline0 + static_cast<LONG>(y) * actualStride;
            BYTE* dstRow = bgra.data() + static_cast<size_t>(y) * dstStride;
            std::memcpy(dstRow, srcRow, dstStride);

            for (UINT32 x = 0; x < videoInfo.width; ++x)
            {
                dstRow[static_cast<size_t>(x) * 4 + 3] = 0xFF;
            }
        }
    }

    void DrawOverlay(std::vector<BYTE>& bgra, UINT32 width, UINT32 height, Gdiplus::Image& overlayImage)
    {
        const INT stride = static_cast<INT>(width * 4);

        Gdiplus::Bitmap frameBitmap(
            static_cast<INT>(width),
            static_cast<INT>(height),
            stride,
            PixelFormat32bppPARGB,
            bgra.data());
        ThrowIfGdiplusError(frameBitmap.GetLastStatus(), "Create frame bitmap");

        Gdiplus::Graphics graphics(&frameBitmap);
        ThrowIfGdiplusError(graphics.GetLastStatus(), "Create graphics");

        graphics.SetCompositingMode(Gdiplus::CompositingModeSourceOver);
        graphics.SetCompositingQuality(Gdiplus::CompositingQualityHighQuality);
        graphics.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic);
        graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
        graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit);

        const Gdiplus::REAL margin = std::max<Gdiplus::REAL>(16.0f, static_cast<Gdiplus::REAL>(height) * kMarginRatio);
        const Gdiplus::REAL maxImageW = static_cast<Gdiplus::REAL>(width) * kImageMaxWidthRatio;
        const Gdiplus::REAL maxImageH = static_cast<Gdiplus::REAL>(height) * kImageMaxHeightRatio;

        const Gdiplus::REAL srcW = static_cast<Gdiplus::REAL>(overlayImage.GetWidth());
        const Gdiplus::REAL srcH = static_cast<Gdiplus::REAL>(overlayImage.GetHeight());
        if (srcW <= 0.0f || srcH <= 0.0f)
        {
            throw std::runtime_error("Overlay image has invalid size.");
        }

        const Gdiplus::REAL imageScale =
            std::min<Gdiplus::REAL>(1.0f, std::min(maxImageW / srcW, maxImageH / srcH));

        const Gdiplus::REAL drawW = srcW * imageScale;
        const Gdiplus::REAL drawH = srcH * imageScale;

        Gdiplus::RectF imageRect(margin, margin, drawW, drawH);
        Gdiplus::SolidBrush imagePlate(Gdiplus::Color(96, 0, 0, 0));
        graphics.FillRectangle(
            &imagePlate,
            imageRect.X - 8.0f,
            imageRect.Y - 8.0f,
            imageRect.Width + 16.0f,
            imageRect.Height + 16.0f);

        graphics.DrawImage(&overlayImage, imageRect);

        const Gdiplus::REAL fontPx =
            std::max<Gdiplus::REAL>(kMinFontPx, static_cast<Gdiplus::REAL>(height) * 0.06f);

        Gdiplus::Font font(L"Segoe UI", fontPx, Gdiplus::FontStyleBold, Gdiplus::UnitPixel);
        ThrowIfGdiplusError(font.GetLastStatus(), "Create font");

        Gdiplus::StringFormat stringFormat;
        stringFormat.SetAlignment(Gdiplus::StringAlignmentNear);
        stringFormat.SetLineAlignment(Gdiplus::StringAlignmentNear);

        Gdiplus::RectF measureLayout(
            margin,
            static_cast<Gdiplus::REAL>(height) - margin - fontPx * 2.0f,
            static_cast<Gdiplus::REAL>(width) - margin * 2.0f,
            fontPx * 2.0f);

        Gdiplus::RectF measured;
        graphics.MeasureString(kOverlayText, -1, &font, measureLayout, &stringFormat, &measured);

        Gdiplus::RectF textBg(
            measured.X - 12.0f,
            measured.Y - 8.0f,
            measured.Width + 24.0f,
            measured.Height + 16.0f);

        Gdiplus::SolidBrush textPlate(Gdiplus::Color(128, 0, 0, 0));
        graphics.FillRectangle(&textPlate, textBg);

        Gdiplus::SolidBrush shadowBrush(Gdiplus::Color(220, 0, 0, 0));
        Gdiplus::RectF shadowLayout = measureLayout;
        shadowLayout.X += 2.0f;
        shadowLayout.Y += 2.0f;
        graphics.DrawString(kOverlayText, -1, &font, shadowLayout, &stringFormat, &shadowBrush);

        Gdiplus::SolidBrush textBrush(Gdiplus::Color(235, 255, 255, 255));
        graphics.DrawString(kOverlayText, -1, &font, measureLayout, &stringFormat, &textBrush);
    }

    void BgraToNv12(const BYTE* bgra, UINT32 width, UINT32 height, BYTE* nv12)
    {
        const bool useBt709 = (width > 1024 || height > 576);

        const int yR = useBt709 ? 47 : 66;
        const int yG = useBt709 ? 157 : 129;
        const int yB = useBt709 ? 16 : 25;

        const int uR = useBt709 ? -26 : -38;
        const int uG = useBt709 ? -87 : -74;
        const int uB = 112;

        const int vR = 112;
        const int vG = useBt709 ? -102 : -94;
        const int vB = useBt709 ? -10 : -18;

        BYTE* yPlane = nv12;
        BYTE* uvPlane = nv12 + static_cast<size_t>(width) * height;

        const size_t srcStride = static_cast<size_t>(width) * 4;

        for (UINT32 y = 0; y < height; ++y)
        {
            const BYTE* srcRow = bgra + static_cast<size_t>(y) * srcStride;
            BYTE* dstY = yPlane + static_cast<size_t>(y) * width;

            for (UINT32 x = 0; x < width; ++x)
            {
                const BYTE b = srcRow[x * 4 + 0];
                const BYTE g = srcRow[x * 4 + 1];
                const BYTE r = srcRow[x * 4 + 2];

                const int Y = ((yR * r + yG * g + yB * b + 128) >> 8) + 16;
                dstY[x] = ClampToByte(Y);
            }
        }

        for (UINT32 y = 0; y < height; y += 2)
        {
            const BYTE* row0 = bgra + static_cast<size_t>(y) * srcStride;
            const BYTE* row1 = bgra + static_cast<size_t>(y + 1) * srcStride;
            BYTE* dstUV = uvPlane + static_cast<size_t>(y / 2) * width;

            for (UINT32 x = 0; x < width; x += 2)
            {
                int b = 0;
                int g = 0;
                int r = 0;

                for (UINT32 dy = 0; dy < 2; ++dy)
                {
                    const BYTE* row = (dy == 0) ? row0 : row1;
                    for (UINT32 dx = 0; dx < 2; ++dx)
                    {
                        const UINT32 ix = x + dx;
                        b += row[ix * 4 + 0];
                        g += row[ix * 4 + 1];
                        r += row[ix * 4 + 2];
                    }
                }

                b = (b + 2) / 4;
                g = (g + 2) / 4;
                r = (r + 2) / 4;

                const int U = ((uR * r + uG * g + uB * b + 128) >> 8) + 128;
                const int V = ((vR * r + vG * g + vB * b + 128) >> 8) + 128;

                dstUV[x + 0] = ClampToByte(U);
                dstUV[x + 1] = ClampToByte(V);
            }
        }
    }

    ComPtr<IMFSample> CreateNv12Sample(
        const std::vector<BYTE>& bgra,
        const VideoFormatInfo& videoInfo,
        LONGLONG sampleTime,
        LONGLONG sampleDuration)
    {
        const DWORD bufferSize =
            static_cast<DWORD>(videoInfo.width * videoInfo.height * 3 / 2);

        ComPtr<IMFMediaBuffer> buffer;
        ThrowIfFailed(MFCreateMemoryBuffer(bufferSize, &buffer), "MFCreateMemoryBuffer");

        BYTE* dst = nullptr;
        DWORD maxLength = 0;
        DWORD currentLength = 0;
        ThrowIfFailed(buffer->Lock(&dst, &maxLength, &currentLength), "Lock(NV12 buffer)");

        try
        {
            BgraToNv12(bgra.data(), videoInfo.width, videoInfo.height, dst);
        }
        catch (...)
        {
            buffer->Unlock();
            throw;
        }

        ThrowIfFailed(buffer->Unlock(), "Unlock(NV12 buffer)");
        ThrowIfFailed(buffer->SetCurrentLength(bufferSize), "SetCurrentLength(NV12 buffer)");

        ComPtr<IMFSample> sample;
        ThrowIfFailed(MFCreateSample(&sample), "MFCreateSample");
        ThrowIfFailed(sample->AddBuffer(buffer.Get()), "AddBuffer(output sample)");
        ThrowIfFailed(sample->SetSampleTime(sampleTime), "SetSampleTime");
        ThrowIfFailed(sample->SetSampleDuration(sampleDuration), "SetSampleDuration");

        return sample;
    }
}

int wmain(int argc, wchar_t* argv[])
{
    if (argc != 4)
    {
        std::wcerr << L"Usage: OverlayMp4.exe <input.mp4> <overlayImage.png> <output.mp4>" << std::endl;
        return 1;
    }

    const std::wstring inputPath = argv[1];
    const std::wstring imagePath = argv[2];
    const std::wstring outputPath = argv[3];

    try
    {
        if (_wcsicmp(inputPath.c_str(), outputPath.c_str()) == 0)
        {
            throw std::runtime_error("Input and output paths must be different.");
        }

        ScopedMf mf;
        ScopedGdiplus gdiplus;

        ComPtr<IMFAttributes> readerAttributes;
        ThrowIfFailed(MFCreateAttributes(&readerAttributes, 1), "MFCreateAttributes(reader)");
        ThrowIfFailed(
            readerAttributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE),
            "SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING)");

        ComPtr<IMFSourceReader> reader;
        ThrowIfFailed(
            MFCreateSourceReaderFromURL(inputPath.c_str(), readerAttributes.Get(), &reader),
            "MFCreateSourceReaderFromURL");

        VideoFormatInfo videoInfo = ConfigureSourceReader(reader.Get());

        if ((videoInfo.width % 2) != 0 || (videoInfo.height % 2) != 0)
        {
            throw std::runtime_error(
                "This sample requires even video width and height because NV12 is 4:2:0.");
        }

        Gdiplus::Image overlayImage(imagePath.c_str());
        ThrowIfGdiplusError(overlayImage.GetLastStatus(), "Load overlay image");

        DWORD videoStreamIndex = 0;
        ComPtr<IMFSinkWriter> writer =
            CreateSinkWriter(outputPath, videoInfo, &videoStreamIndex);

        std::vector<BYTE> bgra;
        LONGLONG firstTimestamp = -1;
        unsigned long long frameCount = 0;

        while (true)
        {
            DWORD flags = 0;
            LONGLONG timestamp = 0;
            ComPtr<IMFSample> inputSample;

            ThrowIfFailed(
                reader->ReadSample(
                    MF_SOURCE_READER_FIRST_VIDEO_STREAM,
                    0,
                    nullptr,
                    &flags,
                    &timestamp,
                    &inputSample),
                "ReadSample(video)");

            if ((flags & MF_SOURCE_READERF_CURRENTMEDIATYPECHANGED) != 0)
            {
                throw std::runtime_error("Dynamic video format change is not supported in this sample.");
            }

            if ((flags & MF_SOURCE_READERF_NATIVEMEDIATYPECHANGED) != 0)
            {
                throw std::runtime_error("Native video format change is not supported in this sample.");
            }

            if ((flags & MF_SOURCE_READERF_STREAMTICK) != 0)
            {
                if (firstTimestamp < 0)
                {
                    firstTimestamp = timestamp;
                }

                ThrowIfFailed(
                    writer->SendStreamTick(videoStreamIndex, timestamp - firstTimestamp),
                    "SendStreamTick");
            }

            if (inputSample)
            {
                if (firstTimestamp < 0)
                {
                    firstTimestamp = timestamp;
                }

                LONGLONG duration = 0;
                if (FAILED(inputSample->GetSampleDuration(&duration)) || duration <= 0)
                {
                    duration = videoInfo.defaultFrameDuration;
                }

                CopySampleToTopDownBgra(inputSample.Get(), videoInfo, bgra);
                DrawOverlay(bgra, videoInfo.width, videoInfo.height, overlayImage);

                ComPtr<IMFSample> outputSample =
                    CreateNv12Sample(bgra, videoInfo, timestamp - firstTimestamp, duration);

                ThrowIfFailed(
                    writer->WriteSample(videoStreamIndex, outputSample.Get()),
                    "WriteSample(video)");

                ++frameCount;
            }

            if ((flags & MF_SOURCE_READERF_ENDOFSTREAM) != 0)
            {
                break;
            }
        }

        ThrowIfFailed(writer->Finalize(), "Finalize");

        std::wcout
            << L"Done. frames=" << frameCount
            << L", output=" << outputPath
            << std::endl;

        return 0;
    }
    catch (const std::exception& ex)
    {
        std::cerr << ex.what() << std::endl;
        return 1;
    }
}

7. נקודות שכדאי לזכור כשקוראים את המימוש הזה

7.1 הפורמט שקל לצייר עליו והפורמט שקל למקודד לקבל הם שונים

בדוגמה הזו, הזרימה היא:

  • פלט Source Reader: RGB32
  • ציור: GDI+
  • קלט Sink Writer: NV12

הסיבה פשוטה: אם רוצים להציב טקסט או PNG, משפחת RGB קלה יותר לטיפול, ואם רוצים להעביר לקידוד H.264, NV12 קל יותר לטיפול.

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

7.2 קודם סופגים stride וכיוון עליון-תחתון, ורק אז מציירים

מסגרות וידאו לא בהכרח סדורות בזיכרון כפי שהן נראות.

  • לפעמים ה-stride לא זהה ל-רוחב × 4
  • לפעמים כיוון השורות הפוך
  • ל-IMF2DBuffer ו-IMFMediaBuffer יש טיפול שונה במקצת

לכן, בקוד הזה, מנרמלים תחילה ל-buffer מסוג BGRA שהוא top-down, ורק אז מציירים. אם מסדרים את זה מראש, קוד הציור עצמו הרבה יותר פשוט.

למה מנרמלים לפני הציורתרשים שמראה שפערים כמו אי-התאמת stride לרוחב כפול 4, כיוון שורות הפוך, וטיפול שונה בין IMF2DBuffer ל-IMFMediaBuffer, נספגים מראש דרך נרמול ל-buffer מסוג BGRA שהוא top-down, ורק אז מציירים.ה-stride לא תואםנרמול ל-BGRA מסוג top-downלפעמים כיוון השורות הפוךהטיפול משתנה לפי סוג ה-bufferקוד הציור נעשה פשוט

איור 12: אם סופגים את הפערים בזיכרון במקום אחד, צד הציור לא צריך לדעת עליהם כלום.

7.3 ב-ReadSample בודקים לא רק HRESULT, אלא גם flags ו-sample

ReadSample עלול להחזיר S_OK וגם sample == nullptr. הדוגמה האופיינית:

  • MF_SOURCE_READERF_STREAMTICK
  • MF_SOURCE_READERF_ENDOFSTREAM
  • אירועי stream אחרים

לכן, בלולאה יש צורך לבדוק יחד את HRESULT,‏ flags, ו-inputSample. בפרט, אם מפספסים את STREAMTICK ו-ENDOFSTREAM, קל שעיבוד ציר הזמן בהמשך יתקלקל.

3 הדברים שבודקים ב-ReadSampleתרשים שמראה ש-ReadSample יכול להחזיר S_OK גם כש-sample הוא nullptr, כאשר STREAMTICK ו-ENDOFSTREAM הם דוגמאות אופייניות, ולכן בלולאה בודקים יחד את HRESULT,‏ flags ואת sample.ReadSample מחזירבודקים HRESULTבודקים flagsבודקים האם קיים sampleגם ב-S_OK ייתכן nullptr

איור 13: לא שופטים לפי ערך מוחזר אחד - מטפלים במסגרת עם שלושה משתנים יחד.

7.4 בטוח יותר להעביר הלאה את timestamp ו-duration של הקלט

חותמת הזמן ביחידות של 100 ננושנייה. בנוסף, את ה-duration צריך להוציא בנפרד מתוך IMFSample.

יותר בטוח להעביר ככל האפשר את ה-timestamp / duration של ה-sample בקלט, מאשר לחשב חיבור קבוע מראש בהנחת fps קבוע. גם בדוגמה הזו, נופלים אל fallback לערך ברירת מחדל שמחושב מ-fps רק כשלא ניתן להשיג duration.

הטיפול ב-timestamp וב-durationתרשים שמראה שבמקום לחשב חיבור קבוע מראש בהנחת fps קבוע, מעבירים ככל האפשר את ה-timestamp וה-duration של ה-sample בקלט, ורק אם ה-duration לא מתקבל נופלים לערך ברירת מחדל שמחושב מ-fps.התקבללא התקבללוקחים מתוך sample הקלטהתקבל duration?מעבירים כמות שהואמשתמשים בערך ברירת מחדל מ-fpsפחות נשבר מחיבור קבוע מראש

איור 14: את הזמן לא בונים בעצמכם - הגישה הבסיסית היא להעביר אותו מהקלט.

7.5 ‏GDI+ קל להטמעה, אבל בסרטון ארוך או רזולוציה גבוהה יש שלב הבא

GDI+ מתאים מאוד לדוגמה שמושלמת בקובץ אחד, אבל בשימושים של סרטונים ארוכים או עיבוד 4K בכמות גדולה, ל-D3D11 + Direct2D + DirectWrite יש לפעמים יתרון.

  • קודם מריצים את הכול עם GDI+
  • אחר כך, כשצריך, מחליפים ל-Direct2D / DirectWrite
  • מעבירים את המרת הצבע ל-Video Processor MFT או לצד ה-GPU

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

התקדמות שלב-אחר-שלב ב-API הציורתרשים שמראה שקודם מריצים את הכול עם GDI+, ואז מחליפים ל-Direct2D ו-DirectWrite כשצריך, ומעבירים את המרת הצבע ל-Video Processor MFT או לצד ה-GPU, כשזו התקדמות שלא שוברת את התכנון.קודם מריצים את הכול עם GDI+מחליפים ל-Direct2D כשצריךמעבירים המרת צבע ל-MFT או GPUהשלב הבא הוא סרטון ארוך או 4K בכמות גדולה

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

7.6 הדוגמה הזו מצומצמת רק לווידאו

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

בפועל, כשלב הבא כדאי להרחיב להרכב:

  • רק הווידאו עובר Source Reader -> הרכבה -> Sink Writer
  • האודיו נשאר compressed ועובר remux

והרכב כזה נוח.

8. אם “נתוני הווידאו שקיבלתם” הם לא קובץ אלא רצף בייטים של MP4 בזיכרון

הקוד הנוכחי משתמש ב-MFCreateSourceReaderFromURL, ולכן הקלט הוא נתיב קובץ.

עם זאת, אם הדרישה היא “רוצים לעשות את אותו הדבר על רצף בייטים של mp4 שהתקבל דרך API”, הגישה לא משתנה. מה שמשתנה הוא רק הכניסה.

  • מכינים IStream או stream עצמאי
  • מעבירים אותו כ-IMFByteStream ל-Source Reader
  • מכאן והלאה, אותו RGB32 -> ציור -> NV12 -> Sink Writer

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

כשהקלט הוא רצף בייטים, משנים רק את הכניסהתרשים שמראה שאם הקלט הוא נתיב קובץ משתמשים ב-MFCreateSourceReaderFromURL, ואם הוא רצף בייטים של MP4 בזיכרון מכינים IMFByteStream ומעבירים ל-Source Reader, כשהזרימה מ-RGB32 ואילך זהה.נתיב קובץSource Readerרצף בייטים בזיכרוןהופכים ל-IMFByteStreamההמשך זהה

איור 16: גם אם צורת שמירת הנתונים משתנה, רק שלב הכניסה משתנה.

9. אם רוצים להרחיב לקראת ייצור

9.1 מוסיפים remux של אודיו

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

‏Sink Writer תומך במפורש בשילוב שבו קלט דחוס נכתב לפלט באותו פורמט, לצורך remux בלי קידוד מחדש. שלושת המקומות שמוסיפים:

  1. קבלת זרם האודיו כשהוא נשאר דחוס. מפעילים עם reader->SetStreamSelection(MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE), ומעבירים את הטיפוס שהתקבל מ-GetNativeMediaType ישירות ל-SetCurrentMediaType. כשלא רוצים שיפוענח, מציינים את הטיפוס ה-native - זו דרך העבודה בצד ה-Source Reader
  2. הגדרת אותו טיפוס גם כקלט וגם כפלט ב-Sink Writer. מעבירים את אותו media type ל-writer->AddStream(audioType.Get(), &audioStreamIndex) וגם ל-writer->SetInputMediaType(audioStreamIndex, audioType.Get(), nullptr)
  3. הפיכת בסיס ה-timestamp לזהה בין וידאו לאודיו. את ה-firstTimestamp שהקוד בסעיף 6.2 משתמש בו עבור הווידאו, מחסירים באותו אופן גם מדגימות האודיו. אם מפרידים לבסיסים שונים, הצליל והתמונה יהיו לא מסונכרנים

גם את הקריאה ל-ReadSample, שמציינת כרגע רק וידאו, משנים לשימוש ב-MF_SOURCE_READER_ANY_STREAM, ומפרידים לפי stream index שמוחזר.

יש לציין ש-Sink Writer, אלא אם המקודד מספק זאת, לא מבצע resampling של אודיו או שינוי גודל/קצב מסגרות של וידאו. אם פורמט האודיו בקלט לא יכול להתקבל על ידי ה-sink של MP4, במקום remux יידרש קידוד מחדש.

3 המקומות שמוסיפים עבור remux של אודיותרשים שמראה שכדי לשמר את האודיו כמות שהוא, מוסיפים שלושה דברים - קבלת זרם האודיו כשהוא דחוס, הגדרת אותו טיפוס בקלט ובפלט של Sink Writer, והפיכת בסיס ה-timestamp לזהה בין וידאו לאודיו.מקבלים את האודיו כשהוא דחוסמגדירים אותו טיפוס בקלט ובפלטבסיס timestamp זהה לווידאובסיס שונה = חוסר סנכרון בצליל

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

9.2 מכניסים Video Processor MFT

הדוגמה הזו נותנת עדיפות להשלמה בקובץ אחד, ולכן ממירה עצמאית BGRA -> NV12, אבל בייצור גם ההרכב שמכניס Video Processor MFT הוא אפשרות חזקה למדי.

אם משתמשים ב-Video Processor MFT, קל יותר לטפל בו-זמנית ב:

  • המרת מרחב צבע
  • שינוי גודל
  • ביטול interlace
  • המרת קצב מסגרות

9.3 מחליפים את GDI+ ב-Direct2D / DirectWrite

overlay כמו תמונת לוגו, כתוביות, חותמת זמן, לרוב מספיק ל-GDI+, אבל אם רוצים לדחוס ביצועים, Direct2D / DirectWrite יעילים יותר.

בפרט, אם יש תנאים כמו:

  • רזולוציה גבוהה
  • סרטון ארוך
  • כמות גדולה של סרטונים
  • רוצים בעתיד לנטות למסלול GPU

אז ההרכב שמשתמש ב-D3D11 / DXGI surface נכנס לתמונה.

9.4 בוחנים custom MFT כשזה הופך ל”אפקט וידאו לשימוש חוזר”

ב-Media Foundation אפשר לממש אפקטים בתור IMFTransform. לכן, אם רוצים להשתמש חוזר באותו עיבוד overlay בכמה אפליקציות או pipeline, custom MFT הוא אפשרות מסודרת.

עם זאת, כדוגמה ראשונה,

  • צריך לעמוד בחוזה IMFTransform
  • ניהול media type בקלט ובפלט גדל
  • דרגת הקושי ברישום ובניפוי באגים עולה

ולכן, בפועל, בדרך כלל נוח יותר קודם להריץ נכון עם Source Reader + הרכבה + Sink Writer, ולהוציא כ-MFT רק כשעולה הצורך.

מתי בוחנים custom MFTתרשים שמראה שקודם מריצים נכון עם Source Reader, הרכבה ו-Sink Writer, ורק כשעולה הרצון להשתמש חוזר באותו עיבוד overlay בכמה אפליקציות או pipeline, מוציאים אותו כ-custom MFT.רוצים בכמה אפליקציותמספיק עם הרכב אחדקודם מריצים נכון עם ההרכב הנוכחיעלה רצון לשימוש חוזר?מוציאים כ-custom MFTנשארים עם ההרכב הנוכחידרגת הקושי במימוש, רישום וניפוי עולה

איור 18: הפיכה לרכיב היא מסודרת, אבל אין צורך למהר לפני שעולה הצורך.

9.5 הופכים את הטקסט לארגומנט ומאפשרים ציור יפני

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

השינוי כולל 4 מקומות.

1. מוסיפים ל-namespace האנונימי את שם הגופן שרוצים להשתמש בו. ה-kOverlayText הקיים נשאר כערך ברירת מחדל.

    const wchar_t* kOverlayText = L"HelloWorld";          // קיים. משמש כברירת מחדל כשהארגומנט לא מועבר
    const wchar_t* kFontFamilyName = L"Yu Gothic UI";     // גופן שמציג יפנית
    const wchar_t* kFallbackFontFamilyName = L"Segoe UI"; // עבור סביבה שבה הגופן הקודם לא קיים

2. מוסיפים ל-DrawOverlay אפשרות להעביר את מחרוזת הציור.

    void DrawOverlay(
        std::vector<BYTE>& bgra,
        UINT32 width,
        UINT32 height,
        Gdiplus::Image& overlayImage,
        const std::wstring& overlayText)      // נוסף

3. בתוך DrawOverlay, מחליפים את יצירת הגופן ואת ההפניה למחרוזת. את השורה של Gdiplus::Font font(L"Segoe UI", ...) המקורית מחליפים בזה.

        // אם הגופן שצוין לא מותקן, נופלים לגופן ברירת המחדל
        Gdiplus::FontFamily preferred(kFontFamilyName);
        Gdiplus::FontFamily fallback(kFallbackFontFamilyName);
        const Gdiplus::FontFamily& family = preferred.IsAvailable() ? preferred : fallback;
        if (!family.IsAvailable())
        {
            throw std::runtime_error("Neither the preferred nor the fallback font family is installed.");
        }

        Gdiplus::Font font(&family, fontPx, Gdiplus::FontStyleBold, Gdiplus::UnitPixel);
        ThrowIfGdiplusError(font.GetLastStatus(), "Create font");

לאחר מכן, מחליפים את kOverlayText שמועבר ל-MeasureString ולשני מקומות של DrawString כולם ל-overlayText.c_str(). אם לא מתקנים בכל שלושת המקומות, הצל בלבד יישאר עם הטקסט הישן.

4. ב-wmain, מקבלים את הארגומנט ומעבירים אותו ל-DrawOverlay.

    if (argc < 4 || argc > 5)
    {
        std::wcerr
            << L"Usage: OverlayMp4.exe <input.mp4> <overlayImage.png> <output.mp4> [text]"
            << std::endl;
        return 1;
    }

    const std::wstring inputPath = argv[1];
    const std::wstring imagePath = argv[2];
    const std::wstring outputPath = argv[3];
    const std::wstring overlayText = (argc == 5) ? std::wstring(argv[4]) : std::wstring(kOverlayText);

ואז, את הקריאה בתוך הלולאה משנים לזה.

                DrawOverlay(bgra, videoInfo.width, videoInfo.height, overlayImage, overlayText);

יש שתי הערות לשים לב אליהן כשעובדים עם יפנית.

  • אם כותבים יפנית כליטרל ישירות ב-.cpp, צריך לשמור את הקובץ כ-UTF-8 with BOM, או לבנות עם /utf-8 ב-MSVC. אם מפספסים את זה, מתקבל מוג’יבאקה. אם מעבירים דרך ארגומנט שורת פקודה, wmain מקבל אותו כ-UTF-16, ואז הבעיה הזו לא קורית
  • הגופן לא בהכרח מותקן בסביבה. כמו בקוד למעלה, חובה להכין fallback. אם בסביבה בלי הגופן זה שקט עובר לגופן אחר, קשה לעקוב אחרי הסיבה לפער בעימוד
השינויים הנדרשים לציור יפניתתרשים שמראה שכדי להפוך את הטקסט לארגומנט ולצייר יפנית, משנים ארבעה מקומות - הוספת קבוע שם הגופן, הוספת ארגומנט ל-DrawOverlay, החלפה ליצירת גופן עם fallback, וקבלת הארגומנט ב-wmain - ויש לשים לב למוג'יבאקה ולהיעדר גופן.מוסיפים קבוע לשם הגופןמוסיפים ארגומנט ל-DrawOverlayיצירת גופן עם fallbackמקבלים ומעבירים ארגומנט ב-wmainגופן שלא קיים נופל לברירת מחדל

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

10. סיכום

כשצריבים תמונה או טקסט על כל מסגרת בסרטון MP4 עם Media Foundation, הפירוק לארבעת האלה משפר את הבהירות.

  • מוציאים: IMFSourceReader
  • מציירים: GDI+ או Direct2D / DirectWrite
  • ממירים לפורמט שקל למקודד לקבל: NV12 וכדומה
  • כותבים בחזרה: IMFSinkWriter

ואם רוצים “דוגמה שכולה מודבקת ל-.cpp אחד ופשוט רצה”, ההרכב שהוצג כאן -

Source Reader -> RGB32 -> GDI+ עם תמונה + HelloWorld -> BGRA ל-NV12 -> Sink Writer

הוא די ישיר.

אם רוצים להרחיב הלאה לקראת ייצור, הסדר הזה קשה יותר להישבר.

  1. מוסיפים remux של אודיו
  2. מחליפים את GDI+ ב-Direct2D / DirectWrite
  3. מעבירים את המרת NV12 ל-Video Processor MFT או לצד ה-GPU
  4. עבור סרטון ארוך ורזולוציה גבוהה, עוברים להרכב מבוסס D3D11 surface
  5. אם דרוש שימוש חוזר, מוציאים ל-custom MFT

אם מנסים לדחוס הכול בבת אחת, COM, stride, מרחב צבע וניהול surface מסתערים בבת אחת. בהתחלה עדיף לחלק לשלבים ולהריץ, ולחזק בהמשך רק את המקומות הנדרשים - כך גם התכנון וגם ניפוי הבאגים הרבה יותר קלים.

הסדר להרחבה לקראת ייצורתרשים שמראה שמוסיפים remux של אודיו, מחליפים GDI+ ב-Direct2D ו-DirectWrite, מעבירים את המרת NV12 ל-Video Processor MFT או ל-GPU, עוברים ל-D3D11 surface עבור סרטון ארוך ורזולוציה גבוהה, ומוציאים ל-custom MFT אם דרוש שימוש חוזר, ושסדר זה קשה יותר להישבר.מוסיפים remux של אודיומחליפים ציור ל-Direct2Dמעבירים המרה ל-MFT או GPUעוברים להרכב D3D11 surfaceאם דרוש, מוציאים ל-custom MFT

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

11. מאמרים קשורים

12. מקורות

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

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

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

שאלות נפוצות

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

מה הזרימה הבסיסית לצריבת תמונה וטקסט על כל מסגרת ב-MP4 עם Media Foundation?
הצורה הבסיסית היא 'פענוח עם Source Reader -> הרכבה על מסגרת לא דחוסה -> המרת צבע במידת הצורך -> קידוד מחדש עם Sink Writer'. עצם פעולת הצבת התמונה או הטקסט אינה עבודה של Media Foundation, אלא של API-ים לציור כמו GDI+,‏ Direct2D / DirectWrite או WIC. כדי להריץ דוגמה ראשונה, ההרכב Source Reader -> RGB32 -> ציור עם GDI+ -> NV12 -> Sink Writer ברור יחסית; אם רוצים לתת עדיפות למהירות וליכולת הרחבה, כדאי לנטות להרכב שמשתמש ב-D3D11 / DXGI surface ו-Direct2D / DirectWrite.
אפשר להעביר מסגרת RGB32 ישירות לקידוד H.264?
לא בהכרח. מקודדי H.264 של Microsoft מניחים לרוב קלט מסוג YUV כמו I420/IYUV/NV12/YUY2/YV12, ולכן אחרי הרכבה ב-RGB32/ARGB32 שקל לצייר עליו, נדרש לרוב שלב המרה. או שמכניסים Video Processor MFT כדי להמיר RGB32 ל-NV12, או שמבצעים המרה עצמאית מ-RGB ל-NV12. בנוסף, מכיוון ש-NV12 הוא 4:2:0, נדרש שרוחב וגובה המסגרת יהיו זוגיים.
לציור ה-overlay, כדאי להשתמש ב-GDI+ או ב-Direct2D?
למימוש הראשון, GDI+ מתאים לדוגמה בקובץ אחד כי הוא תומך בטעינת תמונות ובציור טקסט עם הכנה מוקדמת מועטה. מצד שני, בשימושים של סרטונים ארוכים, 4K, או עיבוד בכמות גדולה, ההרכב D3D11+Direct2D+DirectWrite עשוי להיות יתרון מבחינת ביצועים. שיטת עבודה שלב-אחר-שלב שלא שוברת את התכנון: קודם מריצים את הכול עם GDI+, ואז בשלב שבו רוצים לדחוס מהירות עוברים ל-Direct2D/DirectWrite, ומעבירים את המרת הצבע ל-Video Processor MFT או לצד ה-GPU.
מה כדאי לשים לב אליו כשמשתמשים ב-ReadSample של IMFSourceReader?
ReadSample יכול להחזיר S_OK אבל עדיין sample יהיה nullptr. הדוגמה האופיינית היא אירועי stream כמו MF_SOURCE_READERF_STREAMTICK או MF_SOURCE_READERF_ENDOFSTREAM. לכן בלולאה צריך לבדוק יחד את שלושת הדברים - HRESULT,‏ flags ו-sample. בנוסף, חותמת הזמן ביחידות של 100 ננושנייה, ועדיף להעביר הלאה ככל האפשר את ה-timestamp וה-duration של ה-sample שהתקבל בקלט, במקום לחשב duration קבוע לפי fps.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג