Overlay של תמונה וטקסט על MP4 עם Media Foundation

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

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

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

Go Komura (2026). Overlay של תמונה וטקסט על MP4 עם Media Foundation. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173573 https://comcomponent.com/he/blog/media-foundation-overlay-image-text-on-mp4-frames/

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

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

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

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

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

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

איור 1: הדוגמה הראשונה היא pipeline מינימלי שרק נושא ה-overlay עובר בו.

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

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

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

המאמר מיועד למפתחי C++ ברמת ביניים שכותבים עכשיו עיבוד וידאו ב-Windows. ההנחה היא שכבר נגעתם ביסודות COM (ComPtr, HRESULT, reference counting), וש-Media Foundation הוא הצעד הבא.

כדי להריץ את הדוגמה צריך את הסביבה הבאה. פירוט ותנאי נתוני הקלט מרוכזים בפרק 5.

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

מונחים שכדאי לדעת מראש

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

מונח משמעות
remux בונים מחדש רק את ה-container, והנתונים הדחוסים בפנים נשארים כמו שהם. בלי encode מחדש אין פגיעה באיכות התמונה או השמע, והעיבוד גם קל יותר
topology גרף ב-Media Foundation שמתאר מאיזה רכיב לאיזה רכיב זורמים הנתונים. מקביל לתרשים שמחבר source, transform ו-sink
custom MFT Media Foundation Transform שכותבים בעצמכם. אחרי מימוש IMFTransform אפשר לשלב אפקט כרכיב בתוך ה-pipeline של Media Foundation
stride כמה בייטים תופסת שורת תמונה אחת בזיכרון. לא חייב להיות שווה ל-רוחב × 4; לפעמים יש padding בסוף השורה

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

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

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

2. למה זה קצת מסובך

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

  1. container ו-codec mp4 הוא container, לא הפריים עצמו. בפנים בדרך כלל יושבים נתונים דחוסים ב-H.264 או H.265.

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

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

  4. color space ו-pixel format הפורמט שקל לצייר עליו אינו הפורמט שה-encoder מעדיף. כאן נתקעים בשקט.

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

ארבעת הנושאים שמתערבביםתרשים שמראה שהדרישה להכניס טקסט לווידאו מערבבת ארבעה נושאים - container ו-codec, decode ו-encode, ציור, ו-color space ו-pixel format.להכניס טקסט לווידאוcontainer ו-codecdecode ו-encodeציורcolor space ו-pixel format

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

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

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

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

3.1 תמונת הזרימה

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

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

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

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

4.1 הקלט מגיע דרך IMFSourceReader

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

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

  • אם רוצים לממש בפשטות: RGB32 או ARGB32
  • אם חשובה יעילות ה-encode: YUV כמו NV12

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

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

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

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

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

איור 5: ה-flag שמקל על הוצאת הפריימים עולה בביצועים.

4.2 composite של תמונה וטקסט: GDI+ או Direct2D / DirectWrite

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

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

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

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

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

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

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

במימוש צריך אחת משתי ההמרות.

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

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

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

איור 6: שלב ההמרה נחוץ בכל מקרה. בוחרים רק מי מבצע אותו.

4.4 הפלט נכתב עם IMFSinkWriter

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

הרעיון פשוט. מגדירים בנפרד:

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

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

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

זה היחס.

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

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

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

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

בפרקטיקה נוח ה-pipeline הזה:

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

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

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

איור 8: אם משנים רק את הווידאו, האודיו נשאר כמו שהוא ונוסע עם ה-container.

5. ההנחות ואופן השימוש בדוגמה

ההנחות של הקוד:

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

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

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

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

5.1 איך מריצים

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

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

5.2 איך בודקים שזה באמת עבד

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

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

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

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

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

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

6.1 מפת הקוד

קודם המפה. הקוד ארוך, אבל מה שבאמת צריך לקרוא הוא CopySampleToTopDownBgra, DrawOverlay, BgraToNv12, ולולאת wmain. השאר הוא initialization ו-cleanup.

פונקציה / מחלקה תפקיד הסבר מפורט
ScopedMf / ScopedGdiplus מצמידים עם RAII את ה-startup וה-shutdown של 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נרמול לפריים לא דחוסציור תמונה וטקסטהכנה ל-encode

איור 11: השאר הוא initialization ו-cleanup. המרכז הוא שלוש הפונקציות והלולאה.

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 הפורמט שקל לצייר עליו והפורמט שה-encoder מקבל בנוחות הם לא אותו דבר

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

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

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

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

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, עיבוד ציר הזמן בהמשך מתקלקל בקלות.

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

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

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

timestamp הוא ביחידות של 100ns. את ה-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 הדוגמה מצטמצמת לווידאו בלבד

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

בפרקטיקה, השלב הבא נוח כ-pipeline כזה:

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

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

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

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

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

כלומר, העיקר אינו איך מחזיקים את נתוני הווידאו, אלא איך מציירים על כל פריים אחרי ה-decode.

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

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

9. אם רוצים לגדול ל-production

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

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

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

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

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

Sink Writer, אלא אם ה-encoder מספק את זה, לא עושה resampling לאודיו ולא משנה גודל או frame rate של וידאו. אם פורמט האודיו בקלט לא מתקבל על ידי ה-sink של MP4, במקום remux צריך encode מחדש.

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

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

9.2 מכניסים Video Processor MFT

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

עם Video Processor MFT קל יותר לטפל יחד ב:

  • המרת color space
  • שינוי גודל
  • deinterlace
  • המרת frame rate

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

overlay כמו לוגו, כתוביות או timestamp מספיק לרוב ל-GDI+, אבל אם משפרים ביצועים, Direct2D / DirectWrite יעיל יותר.

במיוחד אם יש תנאים כמו:

  • רזולוציה גבוהה
  • סרט ארוך
  • הרבה קבצים
  • רוצים בעתיד לעבור ל-GPU path

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

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

ב-Media Foundation אפשר לממש אפקטים כ-IMFTransform. אם רוצים להשתמש באותו overlay בכמה אפליקציות או ב-pipeline, custom MFT הוא אפשרות נקייה.

אבל כדוגמה ראשונה:

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

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

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

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

9.5 מעבירים את הטקסט כ-command-line argument ומאפשרים ציור יפנית

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

השינוי הוא בארבעה מקומות.

1. ב-namespace האנונימי מוסיפים את שם הפונט שבו משתמשים. את kOverlayText הקיים משאירים כברירת מחדל.

    const wchar_t* kOverlayText = L"HelloWorld";          // קיים. משמש כברירת מחדל כשאין command-line argument
    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 מקבלים את ה-argument ומעבירים אותו ל-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. אם מפספסים את זה, מקבלים mojibake (תווים משובשים). אם מעבירים דרך command-line argument, wmain מקבל UTF-16 והבעיה הזו לא קורית
  • הפונט לא בהכרח מותקן בסביבה. כמו בקוד למעלה, חובה להכין fallback. אם בסביבה בלי הפונט הקוד עובר בשקט לפונט אחר, קשה לעקוב אחרי למה ה-layout זז
השינויים הנדרשים לציור יפניתתרשים שמראה שכדי להפוך את הטקסט ל-argument ולצייר יפנית משנים ארבעה מקומות - הוספת קבוע שם הפונט, הוספת פרמטר ל-DrawOverlay, החלפה ליצירת פונט עם fallback, וקבלת ה-argument ב-wmain - ויש לשים לב ל-mojibake ולהיעדר פונט.מוסיפים קבוע לשם הפונטמוסיפים פרמטר ל-DrawOverlayיצירת פונט עם fallbackמקבלים ומעבירים argument ב-wmainפונט שלא קיים נופל לברירת מחדל

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

10. סיכום

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

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

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

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

די ישיר.

אם רוצים לגדול משם ל-production, הסדר הזה פחות נשבר.

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

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

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

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

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

12. מקורות

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

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

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

שאלות נפוצות

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

מה הזרימה הבסיסית להוספת overlay של תמונה וטקסט על כל פריים ב-MP4 עם Media Foundation?
הצורה הבסיסית היא decode עם Source Reader, composite על פריים לא דחוס, המרת צבע אם צריך, ואז encode מחדש עם Sink Writer. עצם הציור של התמונה או הטקסט אינו תפקיד של Media Foundation, אלא של APIs לציור כמו GDI+, Direct2D / DirectWrite או WIC. לדוגמה ראשונה שרצה, ה-pipeline Source Reader -> RGB32 -> ציור עם GDI+ -> NV12 -> Sink Writer ברור יחסית. אם חשובים ביצועים והרחבה, כדאי להתקרב ל-pipeline שמסתמך על D3D11 / DXGI surface ו-Direct2D / DirectWrite.
אפשר להעביר פריים RGB32 ישירות ל-encode של H.264?
לא בהכרח. H.264 encoders של Microsoft מצפים בדרך כלל לקלט YUV כמו I420/IYUV/NV12/YUY2/YV12, ולכן אחרי composite ב-RGB32/ARGB32 שקל לצייר עליו כמעט תמיד צריך שלב המרה. אפשר להכניס Video Processor MFT שממיר RGB32 ל-NV12, או לכתוב המרה עצמאית מ-RGB ל-NV12. בנוסף NV12 הוא 4:2:0, כך שרוחב וגובה הפריים חייבים להיות זוגיים.
לציור ה-overlay, עדיף GDI+ או Direct2D?
במימוש הראשון GDI+ נוח לדוגמה שכולה בקובץ אחד: טעינת תמונה וציור טקסט בלי הרבה setup. בסרטים ארוכים, 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. timestamp הוא ביחידות של 100ns, ועדיף להעביר הלאה ככל האפשר את ה-timestamp וה-duration של ה-sample מהקלט, במקום לחבר duration קבוע לפי fps.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג