צריבת תמונה וטקסט על MP4 עם Media Foundation
· עודכן בתאריך: · Go Komura · 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 של אודיו לתוך אותה דוגמה, אבל מכיוון שנושא המאמר הוא “צריבת תמונה וטקסט על כל מסגרת”, נצמצם קודם רק לזה.
flowchart TB
accTitle: היקף הצמצום של הדוגמה הזו
accDescr: תרשים שמראה שהדוגמה במאמר הזה בנויה כך שהיא נותנת עדיפות להרצה כמו שהיא, ולכן מקודדת מחדש רק את הווידאו ומצמצמת לצריבה על כל מסגרת, כאשר remux של אודיו נדחה להרחבה מאוחרת יותר.
fo1["עדיפות ראשונה - להדביק ולהריץ"] --> fo2["מקודדים מחדש רק את הווידאו"]
fo2 --> fo3["מצמצמים לצריבה על כל מסגרת"]
fo1 -.-> fo4["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 של אודיו ממוקם כשלב שמוסיפים אחרי שמייצבים קודם את צריבת הווידאו.
flowchart LR
accTitle: מפת הידע של צריבת תמונה וטקסט על MP4 עם Media Foundation
accDescr: תרשים שמראה את פענוח ה-RGB32 עם IMFSourceReader ואת ההרכבה עם GDI+, שהמרה בין משפחת RGB ל-NV12 נדרשת כקלט למקודד H.264, את הכתיבה עם IMFSinkWriter, ספיגת ה-stride וכיוון top-down/bottom-up, ההרחבה ל-Direct2D/DirectWrite, Video Processor MFT ו-custom MFT, ומיקום remux של אודיו כשלב מאוחר.
video_overlay_compositing["צריבת תמונה או טקסט על מסגרות הווידאו"]
imfsinkwriter["IMFSinkWriter(Sink Writer)"]
imfsourcereader["IMFSourceReader(Source Reader)"]
gdiplus["GDI+"]
direct2d_directwrite["Direct2D / DirectWrite"]
video_stride["stride (מספר הבייטים בשורת תמונה)"]
imf2dbuffer_lock2d["IMF2DBuffer::Lock2D"]
top_down_bottom_up_orientation["top-down / bottom-up (כיוון השורות בתמונה)"]
mfvideoformat_rgb32["MFVideoFormat_RGB32"]
h264_video_encoder["H.264 Video Encoder(Media Foundation)"]
nv12_pixel_format["פורמט הפיקסלים NV12"]
rgb_to_nv12_conversion["המרת צבע מ-BGRA ל-NV12"]
video_processor_mft["Video Processor MFT"]
readsample["IMFSourceReader::ReadSample"]
null_sample_result["דגימת null מ-ReadSample"]
audio_remux["remux של האודיו"]
custom_mft["custom MFT"]
media_foundation["Media Foundation"]
video_overlay_compositing -->|"משתמש ב"| imfsourcereader
video_overlay_compositing -->|"משתמש ב"| gdiplus
direct2d_directwrite -.->|"מענה מומלץ ל"| video_overlay_compositing
video_overlay_compositing -->|"מחייב"| video_stride
video_stride -->|"משתמש ב"| imf2dbuffer_lock2d
video_stride -->|"מחייב"| top_down_bottom_up_orientation
video_overlay_compositing -->|"משתמש ב"| mfvideoformat_rgb32
mfvideoformat_rgb32 -.->|"אינו מתיישב עם"| h264_video_encoder
h264_video_encoder -.->|"מחייב"| nv12_pixel_format
rgb_to_nv12_conversion -->|"מחייב"| mfvideoformat_rgb32
video_overlay_compositing -->|"משתמש ב"| rgb_to_nv12_conversion
video_processor_mft -->|"מענה מומלץ ל"| rgb_to_nv12_conversion
imfsinkwriter -.->|"מחייב"| nv12_pixel_format
imfsinkwriter -.->|"משתמש ב"| h264_video_encoder
video_overlay_compositing -->|"מחייב"| imfsinkwriter
imfsourcereader -->|"משתמש ב"| readsample
readsample -->|"עלול לגרום ל"| null_sample_result
video_overlay_compositing -->|"צריך לקדום ל"| audio_remux
custom_mft -->|"מענה מומלץ ל"| video_overlay_compositing
imfsourcereader -->|"מחייב"| media_foundation
בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 20, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle
2. למה הבעיה הזו קצת מסובכת
“להכניס טקסט לווידאו” הוא בעצם ערבוב של ארבעה נושאים שונים.
-
נושא המכל (container) והקודק
mp4הוא מכל, לא המסגרת עצמה. התוכן בדרך כלל נתונים דחוסים בפורמטH.264אוH.265. -
נושא הפענוח / הקידוד כל עוד הנתונים דחוסים, אי אפשר להציב עליהם טקסט או PNG ישירות עם API רגיל לציור דו-ממדי. קודם צריך להחזיר אותם למסגרת לא דחוסה.
-
נושא הציור טקסט, לוגו, הרכבה שקופה של PNG, ציור טקסט עם anti-aliasing - אלה לא תפקידי הגוף המרכזי של Media Foundation. זו עבודה של
GDI+אוDirect2D / DirectWrite / WIC. -
נושא מרחב הצבע ופורמט הפיקסלים הפורמט שקל לצייר עליו לא זהה לפורמט שהמקודד אוהב. כאן קל להיתקע בשקט.
אם מסכמים בשורה אחת בגסות: הדרך הכי קלה לסדר את המחשבה היא לא “מכניסים טקסט עם Media Foundation”, אלא “מסובבים מסגרות עם Media Foundation, מציבים עליהן עם API ציור, ומכניסים המרת צבע נדרשת לפני הקידוד”.
flowchart TB
accTitle: ארבעת הנושאים המעורבבים
accDescr: תרשים שמראה שהדרישה להכניס טקסט לווידאו מערבבת ארבעה נושאים - מכל וקודק, פענוח וקידוד, ציור, ומרחב צבע ופורמט פיקסלים.
mixq["להכניס טקסט לווידאו"] --> t1["נושא המכל והקודק"]
mixq --> t2["נושא הפענוח והקידוד"]
mixq --> t3["נושא הציור"]
t3 -.-> t4["נושא מרחב הצבע ופורמט הפיקסלים"]
איור 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 תמונת התהליך
flowchart LR
accTitle: תמונת התהליך המלאה
accDescr: תרשים שמראה שהקלט עובר דרך Source Reader למסגרת לא דחוסה RGB32, GDI+ מציירת תמונה ו-HelloWorld, מתבצעת המרת BGRA ל-NV12, ו-IMFSinkWriter כותב את הפלט, כשמקביל לכך דגימת האודיו מועתקת כמות שהיא או מקודדת מחדש.
A["input.mp4"] --> B["IMFSourceReader"]
B --> C["מסגרת לא דחוסה(RGB32)"]
C --> D["GDI+ מציירת תמונה + HelloWorld"]
D --> E["המרת BGRA -> NV12"]
E --> F["IMFSinkWriter"]
F --> G["output.mp4"]
B --> H["דגימת אודיו"]
H --> I["מעתיקים כמות שהיא, או מקודדים מחדש"]
I --> F
איור 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.
flowchart TB
accTitle: הבחירה הראשונית באיזה פורמט לקבל
accDescr: תרשים שמראה שאם רוצים לפשט את המימוש בוחרים RGB32 או ARGB32, ואם רוצים עדיפות ליעילות קידוד בוחרים YUV כמו NV12, אבל מכיוון שהרכבת טקסט ו-PNG קלה יותר במשפחת RGB, נוח להתחיל שם.
rq1{"מה נותנים לו עדיפות"}
rq1 -->|"פשטות המימוש"| rf1["מקבלים ב-RGB32 / ARGB32"]
rq1 -->|"יעילות הקידוד"| rf2["מקבלים ב-YUV כמו NV12"]
rf1 -.-> rf3["הרכבה קלה יותר במשפחת RGB"]
איור 4: כשמתלבטים, מתחילים לקבל במשפחת RGB מתוך עדיפות לקלות הציור.
אם מפעילים את MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, ה-Source Reader יבצע עבורכם המרת YUV -> RGB32 וגם ביטול interlace.
זה נוח בשלב שבו “רוצים קודם כל להוציא ולעבד מסגרות”, אבל בסרטונים ארוכים או ברזולוציה גבוהה זה נוטה להיות כבד, ולכן אם צריך מהירות בייצור, יש ערך לבדוק מחדש את ההרכב בהמשך.
flowchart TB
accTitle: הרווח וההפסד ב-ENABLE_VIDEO_PROCESSING
accDescr: תרשים שמראה שהפעלת MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING גורמת ל-Source Reader לבצע המרת YUV ל-RGB32 וביטול interlace, אבל בסרטונים ארוכים או ברזולוציה גבוהה זה נוטה להיות כבד, ולכן יש ערך לבדוק מחדש בייצור.
ev1["מפעילים את הדגל"] --> ev2["מטילים את המרת YUV ל-RGB32"]
ev1 --> ev3["מטילים גם ביטול interlace"]
ev2 -.-> ev4["בסרטון ארוך או רזולוציה גבוהה - כבד יותר"]
איור 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 הוא אפשרות חזקה.
flowchart TB
accTitle: שתי הדרכים לחבר RGB ל-NV12
accDescr: תרשים שמראה שאחרי הרכבה במשפחת RGB שקלה לציור, נדרשת אחת מהאפשרויות - להכניס Video Processor MFT ולהמיר, או לבצע המרה עצמאית ל-NV12, כאשר הדוגמה הזו בוחרת בהמרה העצמאית מתוך עדיפות להשלמה בקובץ אחד.
cv1["ההרכבה במשפחת RGB הסתיימה"] --> cv2["המרה עם Video Processor MFT"]
cv1 --> cv3["המרה עצמאית ל-NV12"]
cv3 -.-> cv4["הדוגמה נותנת עדיפות להשלמה בקובץ אחד"]
cv2 -.-> cv5["הרכב חזק בייצור"]
איור 6: שלב ההמרה נדרש בכל מקרה, ורק בוחרים למי להטיל אותו.
4.4 כותבים את הפלט עם IMFSinkWriter
לפלט וידאו, IMFSinkWriter נוח.
הגישה פשוטה:
- טיפוס זרם הפלט - הפורמט שרוצים לכתוב לקובץ
לדוגמה:
MFVideoFormat_H264 - טיפוס זרם הקלט - הפורמט שהאפליקציה מעבירה ל-
Sink Writerלדוגמה:MFVideoFormat_NV12
ומגדירים אותם בנפרד.
כלומר, מנקודת המבט של Sink Writer:
- צד האפליקציה מעביר מסגרות לא דחוסות מסוג
NV12 -
Sink Writerמקודד אותן ל-H.264 וכותב ל-MP4
זהו היחס.
flowchart TB
accTitle: חלוקת התפקידים בין טיפוסי הקלט והפלט של Sink Writer
accDescr: תרשים שמראה של-Sink Writer מגדירים בנפרד את טיפוס זרם הקלט שהאפליקציה מעבירה - NV12, ואת טיפוס זרם הפלט שרוצים לכתוב לקובץ - H.264, כאשר Sink Writer מטיל את הקידוד וכותב ל-MP4.
ap1["האפליקציה מעבירה מסגרת NV12"] --> sw1["Sink Writer"]
sw1 --> sw2["מקודד ל-H.264"]
sw2 --> sw3["כותב ל-MP4"]
sw1 -.-> sw4["מגדירים בנפרד טיפוס קלט וטיפוס פלט"]
איור 7: להגדיר בנפרד את הפורמט שמעבירים לבין הפורמט שנכתב הוא דרך העבודה של Sink Writer.
4.5 בהתחלה נוח לחשוב על האודיו בנפרד
מקרים שבהם רוצים להכניס לוגו או טקסט לווידאו, אבל לא רוצים לשנות בכלל את האודיו עצמו, די נפוצים.
בפועל, ההרכב:
- זרם הווידאו בלבד עובר
Source Reader -> הרכבה -> Sink Writer - זרם האודיו נשאר compressed ועובר remux
נוח.
עם זאת, הדוגמה הזו מתמקדת ב-צריבת תמונה וטקסט על המסגרת, ולכן הפלט הוא MP4 שהוא רק וידאו. גרסה ששומרת את האודיו כדאי להוסיף בשלב הרחבה מאוחר יותר, כדי שכל התמונה תישאר קלה למעקב.
flowchart TB
accTitle: לחשוב בנפרד על וידאו ואודיו
accDescr: תרשים שמראה שבפועל נוח שזרם הווידאו בלבד עובר מ-Source Reader דרך הרכבה ל-Sink Writer, וזרם האודיו נשאר דחוס ועובר remux, כאשר הדוגמה הזו לצורך מיקוד לא מטפלת באודיו.
vs1["זרם הווידאו"] --> vs2["הרכבה ואז ל-Sink Writer"]
as1["זרם האודיו"] --> as2["remux כשהוא נשאר דחוס"]
as2 -.-> as3["לא מטופל בדוגמה הזו"]
איור 8: אם רוצים לשנות רק את הווידאו, האודיו נשאר בלי לגעת בו ועובר יחד עם המכל.
5. ההנחות והשימוש בדוגמה הזו
הנחות היסוד של הקוד הזה הן:
- Windows 10 / 11
- אפליקציית console ב-C++ עם Visual Studio 2022
- בנייה עם
x64 - קובץ ה-
.cppהזה לא משתמש ב-header מוקדם-קומפילציה - רוחב וגובה הווידאו בקלט זוגיים
- הקלט הוא קובץ וידאו MP4 רגיל
- הפלט הוא MP4 שהוא רק וידאו
- התמונה בפורמט כמו PNG / JPEG / BMP / GIF, שגם GDI+ יכולה לקרוא
מכיוון ש-NV12 הוא 4:2:0, רוחב וגובה חייבים להיות זוגיים.
לכן, בדוגמה הזו, אם התנאי לא מתקיים, יש שגיאה מפורשת.
flowchart TB
accTitle: ההנחה שרוחב וגובה זוגיים
accDescr: תרשים שמראה שמכיוון ש-NV12 הוא בדגימה 4:2:0, רוחב וגובה הווידאו בקלט חייבים להיות זוגיים, ובדוגמה הזו אם התנאי לא מתקיים, זה נעצר בשגיאה מפורשת.
nvq1["NV12 הוא 4:2:0"] --> nvq2["רוחב וגובה חייבים להיות זוגיים"]
nvq2 --> nvq3{"הקלט זוגי?"}
nvq3 -->|"זוגי"| nvq4["ממשיכים בעיבוד"]
nvq3 -->|"כולל ערך אי-זוגי"| nvq5["נעצרים בשגיאה מפורשת"]
איור 9: עדיף שקלט שלא עומד בתנאי ייעצר בכניסה, ולא שיתפרק בשקט.
5.1 השימוש
- יוצרים Console App ב-Visual Studio
- מדביקים את ה-
.cppהזה כמות שהוא - הופכים את ה-header המוקדם-קומפילציה של ה-
.cppהזה ל”לא בשימוש” - בונים עם
x64 - מריצים כך:
OverlayMp4.exe input.mp4 overlay.png output.mp4
-
input.mp4הסרטון המקורי -
overlay.pngהתמונה שרוצים להרכיב -
output.mp4יעד הפלט
הטקסט קבוע בקבוע kOverlayText בראש הקוד כ-HelloWorld. ניתן לשנות את המיקום והגודל דרך הקבועים בקוד. שיפור שמאפשר להעביר את הטקסט כארגומנט שורת פקודה מובא בסעיף 9.5.
5.2 מוודאים שזה עבד נכון
“הסתיים בלי שגיאה” ו-“נצרב נכון” הם שני דברים שונים. אם בודקים בסדר את ארבעת אלה, אפשר לגלות את רוב הכשלים.
- בודקים את מספר המסגרות בסיום. הדוגמה הזו, כשהיא מסיימת, מדפיסה
Done. frames=יחד עם מספר המסגרות שנכתבו. אם יש פער גדול ממספר המסגרות הכולל של הסרטון בקלט, אז מקום כלשהו בלולאתReadSampleמפיל מסגרות - משווים את המידע הבסיסי של קובץ הפלט לקלט. אם לוחצים ימני על ה-MP4 בפלט ב-Explorer ופותחים מאפיינים > פרטים, מקבלים משך, רוחב מסגרת, גובה מסגרת וקצב מסגרות. אם המשך לא תואם לקלט, יש לחשוד בטיפול ב-timestamp (7.4)
- בודקים חזותית שלושה מקומות - התחלה, אמצע וסוף. אם בודקים רק את המסגרת הראשונה ומרגישים בטוחים, קל לפספס תקלה שבה ה-overlay נעלם באמצע. חיתוך תמונת סטילס מאותו זמן משני הקבצים, קלט ופלט, והצבתם זה לצד זה, בטוח יותר, והתהליך מפורט ב”איך לחתוך תמונת סטילס מזמן מסוים ב-MP4 עם Media Foundation”
- בודקים שהצבע לא מוזר. אם צבע העור או השמיים נראים לא טבעיים, יש חשד שבחירת המקדמים ב-
BgraToNv12(BT.601 מול BT.709) לא תואמת לקלט
בבדיקה הראשונה, מומלץ להשתמש ב-MP4 קצר של כמה שניות, ו-PNG עם קווי מתאר ברורים. אם מנסים להעביר את הדוגמה הראשונה עם סרטון ארוך, בידוד הבעיה לוקח זמן.
flowchart TB
accTitle: תהליך הבדיקה שההרצה נכונה
accDescr: תרשים שמראה ש"הסתיים בלי שגיאה" ו"נצרב נכון" הם שני דברים שונים, ולכן בודקים בסדר את התאמת מספר המסגרות, השוואת מידע בסיסי של קובץ הפלט, בדיקה חזותית של התחלה-אמצע-סוף, ובדיקת חריגה בצבע.
ck1["מתאימים את מספר המסגרות לקלט"] --> ck2["משווים משך וגודל דרך מאפיינים"]
ck2 --> ck3["בודקים חזותית התחלה-אמצע-סוף"]
ck3 --> ck4["בודקים חריגה בצבע"]
ck4 -.-> ck5["אם יש חריגה, חושדים בבחירת המקדמים"]
איור 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”.
flowchart TB
accTitle: שלוש הפונקציות המרכזיות שכדאי לקרוא
accDescr: תרשים שמראה שמתוך הקוד הארוך, מה שבאמת חשוב לקרוא הוא שלוש הפונקציות CopySampleToTopDownBgra, DrawOverlay ו-BgraToNv12, יחד עם לולאת wmain, כשכל אחת מתאימה לנרמול למסגרת לא דחוסה, לציור, ולהמרת NV12.
lp1["לולאת wmain מסובבת מסגרת אחר מסגרת"] --> fn1["CopySampleToTopDownBgra"]
fn1 --> fn2["DrawOverlay"]
fn2 --> fn3["BgraToNv12"]
fn1 -.-> ro1["נרמול למסגרת לא דחוסה"]
fn2 -.-> ro2["ציור תמונה וטקסט"]
fn3 -.-> ro3["הכנה לצורך קידוד"]
איור 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, ¤tType), "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, ¤tLength), "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,
×tamp,
&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, ורק אז מציירים. אם מסדרים את זה מראש, קוד הציור עצמו הרבה יותר פשוט.
flowchart TB
accTitle: למה מנרמלים לפני הציור
accDescr: תרשים שמראה שפערים כמו אי-התאמת stride לרוחב כפול 4, כיוון שורות הפוך, וטיפול שונה בין IMF2DBuffer ל-IMFMediaBuffer, נספגים מראש דרך נרמול ל-buffer מסוג BGRA שהוא top-down, ורק אז מציירים.
ir1["ה-stride לא תואם"] --> nr1["נרמול ל-BGRA מסוג top-down"]
ir2["לפעמים כיוון השורות הפוך"] --> nr1
ir3["הטיפול משתנה לפי סוג ה-buffer"] --> nr1
nr1 --> nr2["קוד הציור נעשה פשוט"]
איור 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, קל שעיבוד ציר הזמן בהמשך יתקלקל.
flowchart TB
accTitle: 3 הדברים שבודקים ב-ReadSample
accDescr: תרשים שמראה ש-ReadSample יכול להחזיר S_OK גם כש-sample הוא nullptr, כאשר STREAMTICK ו-ENDOFSTREAM הם דוגמאות אופייניות, ולכן בלולאה בודקים יחד את HRESULT, flags ואת sample.
rs1["ReadSample מחזיר"] --> rs2["בודקים HRESULT"]
rs2 --> rs3["בודקים flags"]
rs3 --> rs4["בודקים האם קיים sample"]
rs4 -.-> rs5["גם ב-S_OK ייתכן nullptr"]
איור 13: לא שופטים לפי ערך מוחזר אחד - מטפלים במסגרת עם שלושה משתנים יחד.
7.4 בטוח יותר להעביר הלאה את timestamp ו-duration של הקלט
חותמת הזמן ביחידות של 100 ננושנייה.
בנוסף, את ה-duration צריך להוציא בנפרד מתוך IMFSample.
יותר בטוח להעביר ככל האפשר את ה-timestamp / duration של ה-sample בקלט, מאשר לחשב חיבור קבוע מראש בהנחת fps קבוע.
גם בדוגמה הזו, נופלים אל fallback לערך ברירת מחדל שמחושב מ-fps רק כשלא ניתן להשיג duration.
flowchart TB
accTitle: הטיפול ב-timestamp וב-duration
accDescr: תרשים שמראה שבמקום לחשב חיבור קבוע מראש בהנחת fps קבוע, מעבירים ככל האפשר את ה-timestamp וה-duration של ה-sample בקלט, ורק אם ה-duration לא מתקבל נופלים לערך ברירת מחדל שמחושב מ-fps.
ts1["לוקחים מתוך sample הקלט"] --> ts2{"התקבל duration?"}
ts2 -->|"התקבל"| ts3["מעבירים כמות שהוא"]
ts2 -->|"לא התקבל"| ts4["משתמשים בערך ברירת מחדל מ-fps"]
ts3 -.-> ts5["פחות נשבר מחיבור קבוע מראש"]
איור 14: את הזמן לא בונים בעצמכם - הגישה הבסיסית היא להעביר אותו מהקלט.
7.5 GDI+ קל להטמעה, אבל בסרטון ארוך או רזולוציה גבוהה יש שלב הבא
GDI+ מתאים מאוד לדוגמה שמושלמת בקובץ אחד, אבל בשימושים של סרטונים ארוכים או עיבוד 4K בכמות גדולה, ל-D3D11 + Direct2D + DirectWrite יש לפעמים יתרון.
- קודם מריצים את הכול עם
GDI+ - אחר כך, כשצריך, מחליפים ל-
Direct2D / DirectWrite - מעבירים את המרת הצבע ל-
Video Processor MFTאו לצד ה-GPU
אם מתקדמים בשלבים כאלה, קל יותר להרחיב בלי לשבור את התכנון.
flowchart TB
accTitle: התקדמות שלב-אחר-שלב ב-API הציור
accDescr: תרשים שמראה שקודם מריצים את הכול עם GDI+, ואז מחליפים ל-Direct2D ו-DirectWrite כשצריך, ומעבירים את המרת הצבע ל-Video Processor MFT או לצד ה-GPU, כשזו התקדמות שלא שוברת את התכנון.
gd1["קודם מריצים את הכול עם GDI+"] --> gd2["מחליפים ל-Direct2D כשצריך"]
gd2 --> gd3["מעבירים המרת צבע ל-MFT או GPU"]
gd1 -.-> gd4["השלב הבא הוא סרטון ארוך או 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
כלומר, המהות היא לא איך שומרים את נתוני הווידאו, אלא איך מציירים על כל מסגרת אחרי הפענוח.
flowchart TB
accTitle: כשהקלט הוא רצף בייטים, משנים רק את הכניסה
accDescr: תרשים שמראה שאם הקלט הוא נתיב קובץ משתמשים ב-MFCreateSourceReaderFromURL, ואם הוא רצף בייטים של MP4 בזיכרון מכינים IMFByteStream ומעבירים ל-Source Reader, כשהזרימה מ-RGB32 ואילך זהה.
in1["נתיב קובץ"] --> sr1["Source Reader"]
in2["רצף בייטים בזיכרון"] --> bs1["הופכים ל-IMFByteStream"]
bs1 --> sr1
sr1 --> same1["ההמשך זהה"]
איור 16: גם אם צורת שמירת הנתונים משתנה, רק שלב הכניסה משתנה.
9. אם רוצים להרחיב לקראת ייצור
9.1 מוסיפים remux של אודיו
ההרחבה הראשונה שהכי מעשית היא לשמר את האודיו כמות שהוא. אם בונים הרכב שבו רק הווידאו עובר קידוד מחדש, והאודיו נכתב חזרה באותו פורמט כשהוא compressed, אפשר לענות על הדרישה בלי להגדיל משמעותית את המימוש.
Sink Writer תומך במפורש בשילוב שבו קלט דחוס נכתב לפלט באותו פורמט, לצורך remux בלי קידוד מחדש. שלושת המקומות שמוסיפים:
- קבלת זרם האודיו כשהוא נשאר דחוס. מפעילים עם
reader->SetStreamSelection(MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE), ומעבירים את הטיפוס שהתקבל מ-GetNativeMediaTypeישירות ל-SetCurrentMediaType. כשלא רוצים שיפוענח, מציינים את הטיפוס ה-native - זו דרך העבודה בצד ה-Source Reader - הגדרת אותו טיפוס גם כקלט וגם כפלט ב-Sink Writer. מעבירים את אותו media type ל-
writer->AddStream(audioType.Get(), &audioStreamIndex)וגם ל-writer->SetInputMediaType(audioStreamIndex, audioType.Get(), nullptr) - הפיכת בסיס ה-timestamp לזהה בין וידאו לאודיו. את ה-
firstTimestampשהקוד בסעיף 6.2 משתמש בו עבור הווידאו, מחסירים באותו אופן גם מדגימות האודיו. אם מפרידים לבסיסים שונים, הצליל והתמונה יהיו לא מסונכרנים
גם את הקריאה ל-ReadSample, שמציינת כרגע רק וידאו, משנים לשימוש ב-MF_SOURCE_READER_ANY_STREAM, ומפרידים לפי stream index שמוחזר.
יש לציין ש-Sink Writer, אלא אם המקודד מספק זאת, לא מבצע resampling של אודיו או שינוי גודל/קצב מסגרות של וידאו. אם פורמט האודיו בקלט לא יכול להתקבל על ידי ה-sink של MP4, במקום remux יידרש קידוד מחדש.
flowchart TB
accTitle: 3 המקומות שמוסיפים עבור remux של אודיו
accDescr: תרשים שמראה שכדי לשמר את האודיו כמות שהוא, מוסיפים שלושה דברים - קבלת זרם האודיו כשהוא דחוס, הגדרת אותו טיפוס בקלט ובפלט של Sink Writer, והפיכת בסיס ה-timestamp לזהה בין וידאו לאודיו.
ar1["מקבלים את האודיו כשהוא דחוס"] --> ar2["מגדירים אותו טיפוס בקלט ובפלט"]
ar2 --> ar3["בסיס timestamp זהה לווידאו"]
ar3 -.-> ar4["בסיס שונה = חוסר סנכרון בצליל"]
איור 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 רק כשעולה הצורך.
flowchart TB
accTitle: מתי בוחנים custom MFT
accDescr: תרשים שמראה שקודם מריצים נכון עם Source Reader, הרכבה ו-Sink Writer, ורק כשעולה הרצון להשתמש חוזר באותו עיבוד overlay בכמה אפליקציות או pipeline, מוציאים אותו כ-custom MFT.
mf1["קודם מריצים נכון עם ההרכב הנוכחי"] --> mf2{"עלה רצון לשימוש חוזר?"}
mf2 -->|"רוצים בכמה אפליקציות"| mf3["מוציאים כ-custom MFT"]
mf2 -->|"מספיק עם הרכב אחד"| mf4["נשארים עם ההרכב הנוכחי"]
mf3 -.-> mf5["דרגת הקושי במימוש, רישום וניפוי עולה"]
איור 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. אם בסביבה בלי הגופן זה שקט עובר לגופן אחר, קשה לעקוב אחרי הסיבה לפער בעימוד
flowchart TB
accTitle: השינויים הנדרשים לציור יפנית
accDescr: תרשים שמראה שכדי להפוך את הטקסט לארגומנט ולצייר יפנית, משנים ארבעה מקומות - הוספת קבוע שם הגופן, הוספת ארגומנט ל-DrawOverlay, החלפה ליצירת גופן עם fallback, וקבלת הארגומנט ב-wmain - ויש לשים לב למוג'יבאקה ולהיעדר גופן.
jp1["מוסיפים קבוע לשם הגופן"] --> jp2["מוסיפים ארגומנט ל-DrawOverlay"]
jp2 --> jp3["יצירת גופן עם fallback"]
jp3 --> jp4["מקבלים ומעבירים ארגומנט ב-wmain"]
jp3 -.-> jp5["גופן שלא קיים נופל לברירת מחדל"]
איור 19: השינוי הוא ב-4 מקומות, וההכנה של הגופן וקידוד התווים הם המקום שהכי קל למעוד בו.
10. סיכום
כשצריבים תמונה או טקסט על כל מסגרת בסרטון MP4 עם Media Foundation, הפירוק לארבעת האלה משפר את הבהירות.
- מוציאים:
IMFSourceReader - מציירים:
GDI+אוDirect2D / DirectWrite - ממירים לפורמט שקל למקודד לקבל:
NV12וכדומה - כותבים בחזרה:
IMFSinkWriter
ואם רוצים “דוגמה שכולה מודבקת ל-.cpp אחד ופשוט רצה”, ההרכב שהוצג כאן -
Source Reader -> RGB32 -> GDI+ עם תמונה + HelloWorld -> BGRA ל-NV12 -> Sink Writer
הוא די ישיר.
אם רוצים להרחיב הלאה לקראת ייצור, הסדר הזה קשה יותר להישבר.
- מוסיפים remux של אודיו
- מחליפים את
GDI+ב-Direct2D / DirectWrite - מעבירים את המרת
NV12ל-Video Processor MFTאו לצד ה-GPU - עבור סרטון ארוך ורזולוציה גבוהה, עוברים להרכב מבוסס
D3D11 surface - אם דרוש שימוש חוזר, מוציאים ל-custom
MFT
אם מנסים לדחוס הכול בבת אחת, COM, stride, מרחב צבע וניהול surface מסתערים בבת אחת. בהתחלה עדיף לחלק לשלבים ולהריץ, ולחזק בהמשך רק את המקומות הנדרשים - כך גם התכנון וגם ניפוי הבאגים הרבה יותר קלים.
flowchart TB
accTitle: הסדר להרחבה לקראת ייצור
accDescr: תרשים שמראה שמוסיפים remux של אודיו, מחליפים GDI+ ב-Direct2D ו-DirectWrite, מעבירים את המרת NV12 ל-Video Processor MFT או ל-GPU, עוברים ל-D3D11 surface עבור סרטון ארוך ורזולוציה גבוהה, ומוציאים ל-custom MFT אם דרוש שימוש חוזר, ושסדר זה קשה יותר להישבר.
ex1["מוסיפים remux של אודיו"] --> ex2["מחליפים ציור ל-Direct2D"]
ex2 --> ex3["מעבירים המרה ל-MFT או GPU"]
ex3 --> ex4["עוברים להרכב D3D11 surface"]
ex4 --> ex5["אם דרוש, מוציאים ל-custom MFT"]
איור 20: נמנעים מלדחוס הכול בבת אחת, ומחזקים שלב אחר שלב בסדר הזה.
11. מאמרים קשורים
- מבוא ל-Media Foundation - הבנת ה-API מנקודת מבט של COM
- איך לחתוך תמונת סטילס מזמן מסוים ב-MP4 עם Media Foundation
12. מקורות
- ערכת הדוגמה המלאה של המאמר הזה (
.cppבקובץ אחד והרכב בנייה עם CMake) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/media-foundation-overlay-image-text-on-mp4-frames - Microsoft Learn: Using the Source Reader to Process Media Data
- Microsoft Learn: MFCreateSourceReaderFromByteStream
- Microsoft Learn: MFCreateMFByteStreamOnStream
- Microsoft Learn: IMFSourceReader::SetCurrentMediaType
- Microsoft Learn: MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING
- Microsoft Learn: MF_SOURCE_READER_ENABLE_ADVANCED_VIDEO_PROCESSING
- Microsoft Learn: IMFSourceReader::ReadSample
- Microsoft Learn: Working with Media Samples
- Microsoft Learn: IMF2DBuffer::Lock2D
- Microsoft Learn: Video Subtype GUIDs
- Microsoft Learn: H.264 Video Encoder
- Microsoft Learn: Video Processor MFT
- Microsoft Learn: Using the Sink Writer
- Microsoft Learn: Tutorial: Using the Sink Writer to Encode Video
- Microsoft Learn: Interoperability Overview (Direct2D)
- Microsoft Learn: Text Rendering with Direct2D and DirectWrite
- Microsoft Learn: Writing a Custom MFT
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
המלכודות של הזיכרון המשותף ושיטות העבודה המומלצות בפועל
המאמר מסכם את המלכודות בשימוש בזיכרון משותף בעבודה בפועל, ואת התכנון שמוריד את שיעור התקלות - כולל סנכרון, נראות (visibility), אורך חיים,...
איך ממירים YUV ל-RGB עם Media Foundation
מסדרים איך ממירים מסגרת YUV ל-RGB עם Media Foundation, דרך ההמרה האוטומטית של Source Reader וההמרה העצמית של NV12/YUY2, ה-stride ומרחב הצבע.
איך לחלץ תמונת סטילס מ-MP4 בזמן נתון עם Media Foundation
מסכמים את שלבי המימוש לחילוץ מסגרת קרובה לזמן נתון מתוך MP4 עם Source Reader, יישור ה-stride ו-alpha של RGB32, ושמירה כ-PNG.
מבוא ל-Media Foundation — מבינים את ה-API מנקודת המבט של COM
המאמר מסביר מה זה Media Foundation, יחד עם המונחים הבסיסיים של ה-API למדיה ב-Windows כמו COM, HRESULT, IMFSourceReader ו-MFT, בסדר שכדא...
המלכודות של יישום תקשורת טורית - עד לתכנון חיבור מחדש ויומן
המאמר מסדר, מנקודת מבט מעשית, את המלכודות שכדאי להימנע מהן ביישום תקשורת טורית לחיבור ציוד ובקרת מכשירי מדידה - מבניית מסגרות, timeout, ...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
נושא שקשור ישירות למימוש אפליקציות Windows שחוצה בין Media Foundation, GDI+, Direct2D / DirectWrite, המרת צבע ופלט וידאו.
ייעוץ טכני וסקירת תכנון
מתאים גם לתכנון איך להרחיב מיישום בקובץ אחד להרכב מיועד לייצור, ואיפה מפרידים בין remux של אודיו לבין מעבר ל-GPU.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- מה הזרימה הבסיסית לצריבת תמונה וטקסט על כל מסגרת ב-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.