مجالات متخصصة الأدلة التقنية

FFmpeg عملياً: الأداة التي تقف خلف تطبيقات الفيديو

كيف يعمل FFmpeg من الداخل، وكيف تكتب أوامره للقص والدمج والترجمة العربية والصوت والصور المصغرة دون أن تفسد الملف.

FFmpeg عملياً: الأداة التي تقف خلف تطبيقات الفيديو

لنفرض أن لديك شركة ناشئة تدير منصة دورات تعليمية مسجلة، يرفع فيها المدرب محاضرته كما خرجت من كاميرته أو من برنامج تسجيل الشاشة، ثم يطلب منك فريق المحتوى قائمة لا تنتهي: احذف الدقائق الأولى قبل أن يبدأ المدرب الكلام، وضع شعار المنصة في الزاوية، وأضف ترجمة عربية مكتوبة على الفيديو للمشاهدين الذين يشاهدون دون صوت، ووحد مستوى الصوت لأن محاضرة تصرخ وأخرى تهمس، واصنع صورة مصغرة لكل محاضرة وشريطاً من الصور يظهر عند تمرير المؤشر فوق شريط التقدم، وأضف مقدمة المنصة المتحركة قبل كل محاضرة. وفي كل منصة فيديو تقريباً، من YouTube إلى أصغر تطبيق تعليمي، ستجد أن الأداة التي تنفذ هذه القائمة في الخلفية هي نفسها: FFmpeg.

وFFmpeg أداة سطر أوامر تبدو من الوهلة الأولى بسيطة، فأمر واحد مثل ffmpeg -i in.mov out.mp4 يحول الملف ويعمل، لكن في الواقع فخلف هذا الأمر خط معالجة كامل من فك الحاوية إلى فك الترميز إلى المرشحات إلى الترميز إلى الحاوية الجديدة، وأغلب الأخطاء التي يقع فيها المطورون، مثل قص يبدأ قبل الثانية المطلوبة، أو دمج يخرج فيديو بصورة متجمدة، أو ترجمة عربية لا تظهر أصلاً، سببها عدم فهم هذا الخط. وقد شرحنا مفاهيم الفيديو نفسها، أي الحاوية والترميز ومعدل البت والإطارات المفتاحية، في مفاهيم الفيديو للمبتدئين، وشرحنا البث بالأجزاء في كيف يعمل بث الفيديو، أما هذا المقال فعن الأداة نفسها: كيف تعمل، وكيف تكتب أوامرها، ولماذا يتصرف كل أمر بالطريقة التي يتصرف بها.

وسوف نناقش في هذا المقال ما يلي:

  • ما هو FFmpeg، ومكتباته، ورخصته، ولماذا تجده في كل مكان.
  • خط المعالجة من الداخل: فك الحاوية Demux وفك الترميز Decode والمرشحات Filters والترميز Encode والتجميع Mux، ونسخ الدفق Stream Copy الذي يتجاوز أغلبها.
  • تشريح الأمر: لماذا يغير موضع الخيار معناه، واختيار الدفق بالخيار -map.
  • تجربة عملية على مقطع Sintel المفتوح: سرعة الترميز وجودته بإعدادات مختلفة، والقص الدقيق والسريع، والشعار والترجمة العربية، وتوحيد مستوى الصوت، والصور المصغرة، والدمج.
  • الأخطاء التي ظهرت فعلاً: ترجمة عربية لم تظهر، ودمج خرج بإطارات معطوبة، وقص بدأ قبل وقته.
  • FFmpeg في الإنتاج: رموز الخروج ومتابعة التقدم والأمان مع الملفات التي يرفعها الناس.

ما هو FFmpeg، ولماذا تجده في كل مكان؟

FFmpeg مشروع مفتوح المصدر بدأه المبرمج الفرنسي Fabrice Bellard سنة 2000، ويصفه موقعه الرسمي بأنه إطار متكامل لتسجيل الصوت والفيديو وتحويلهما وبثهما، يستطيع أن يفك ويرمز ويحول ويجمع ويفصل ويبث ويرشح ويشغل أي شيء تقريباً صنعه البشر أو الآلات. وهو في الحقيقة مجموعة مكتبات بلغة C، والأداة ffmpeg التي تكتبها في سطر الأوامر مجرد واجهة فوقها:

المكتبةمسؤوليتها
libavformatقراءة الحاويات وكتابتها مثل MP4 وMKV وWebM وMPEG-TS وHLS، والبروتوكولات مثل HTTP وRTMP وSRT
libavcodecالمرمزات وفاكات الترميز، أي تحويل البيانات المضغوطة إلى إطارات وعينات صوت والعكس
libavfilterالمرشحات: التصغير والقص والتراكب والترجمة وتعديل الصوت، وسلاسلها Filtergraphs
libswscale وlibswresampleتغيير مقاس الصورة وتنسيق البكسلات، وتغيير معدل عينات الصوت وقنواته
libavutil وlibavdeviceأدوات مشتركة، والقراءة من الكاميرات والشاشات وبطاقات الصوت

ولهذا تجد هذه المكتبات داخل برامج كثيرة لا تحمل اسم FFmpeg: مشغلات الفيديو وبرامج تحرير الفيديو ومتصفحات ومنصات الوسائط مثل Jellyfin، وأغلب أدوات المقالات التالية في هذه المرحلة من خارطة الطريق، مثل Tdarr وOwncast وPeerTube، تستدعي ffmpeg في الخلفية. أما الرخصة فيذكر صفحة الشؤون القانونية أن FFmpeg مرخص بموجب LGPL الإصدار 2.1 أو أحدث، لكن بعض أجزائه الاختيارية مرخصة بموجب GPL، وإذا بنيته معها، مثل libx264 الذي سوف نستخدمه، فإن GPL تطبق على FFmpeg كله، وهذا مهم إذا كنت توزع برنامجاً فيه FFmpeg، أما تشغيله على سيرفرك لمعالجة ملفاتك فلا يلزمك بنشر كودك. وتذكر أيضاً أن بعض الترميزات مثل H.264 وHEVC عليها براءات اختراع لها رخص منفصلة عن رخصة الكود، وقد شرحنا ذلك في قسم الرخص في مفاهيم الفيديو.

كيف يعمل FFmpeg من الداخل؟

كل أمر ffmpeg يبني خط معالجة من خمس مراحل، وفهم هذه المراحل هو ما يجعل الأوامر الغريبة منطقية، والصورة التالية تبينها:

خط FFmpeg من اليمين إلى اليسار: ملف الإدخال MP4، ثم فك الحاوية Demux بمكتبة libavformat يخرج حزماً مضغوطة Packets لكل دفق فيديو وصوت، ثم فك الترميز Decode بمكتبة libavcodec يخرج إطارات Frames غير مضغوطة، ثم المرشحات Filters بمكتبة libavfilter مثل scale وsubtitles وoverlay وloudnorm، ثم الترميز Encode مثل libx264 وaac، ثم التجميع Mux في حاوية الإخراج. ومسار مختصر أخضر اسمه نسخ الدفق Stream Copy ينقل الحزم من فك الحاوية إلى التجميع مباشرة دون فك ترميز ولا مرشحات
كل الوقت والجودة في المنتصف، ونسخ الدفق يتجاوز المنتصف كله
  • فك الحاوية Demux: يقرأ الملف ويفصل الحزم Packets المضغوطة لكل دفق Stream، أي دفق الفيديو ودفق الصوت ودفق الترجمة، دون أن يفك ضغطها. وهذه المرحلة سريعة جداً لأنها قراءة ونقل بايتات.
  • فك الترميز Decode: يحول كل حزمة مضغوطة إلى إطار Frame كامل من البكسلات، أو إلى عينات صوت، وهي مرحلة ثقيلة، لكن أخف بكثير من الترميز.
  • المرشحات Filters: تعدل الإطارات غير المضغوطة، مثل التصغير والقص ووضع شعار وكتابة ترجمة وتعديل مستوى الصوت، وتربط في سلسلة اسمها Filtergraph.
  • الترميز Encode: يضغط الإطارات من جديد بالترميز المطلوب، وهو غالباً أثقل مرحلة في الخط كله، وفيه يتحدد حجم الملف وجودته.
  • التجميع Mux: يضع الحزم الجديدة في الحاوية المطلوبة مع الطوابع الزمنية Timestamps والفهرس.

والخيار -c copy يقول ل FFmpeg: لا تفك الترميز ولا ترمز، وانقل الحزم كما هي من الحاوية القديمة إلى الجديدة، وهذا ما يسمى نسخ الدفق Stream Copy. فهو سريع جداً ولا يخسر شيئاً من الجودة، لكن ثمنه أنك لا تستطيع استخدام أي مرشح، لأن المرشحات تعمل على الإطارات غير المضغوطة، ولا تستطيع أن تقص إلا عند الإطارات المفتاحية، كما سنرى في التجربة. وفي أي أمر تكتبه اسأل نفسك أولاً: هل أحتاج تغيير البكسلات أو الصوت فعلاً؟ فإذا كان الجواب لا فنسخ الدفق هو الأسرع والأدق.

تشريح الأمر: لماذا يغير موضع الخيار معناه؟

أكثر ما يربك من يبدأ مع FFmpeg أن نفس الخيار يعطي نتيجة مختلفة بحسب مكانه في الأمر، والقاعدة بسيطة كما يشرح دليل الأداة: كل خيار ينطبق على الملف الذي يأتي بعده، فالخيارات قبل -i خيارات إدخال تخص الملف الذي يليها، والخيارات بعد آخر -i وقبل اسم ملف الإخراج خيارات إخراج تخص ذلك الملف. والأمر التالي يبين ذلك:

ffmpeg -hide_banner -ss 30.5 -i sintel.mp4 -t 10 -map 0:v:0 -map 0:a:0 \
  -c:v libx264 -preset veryfast -crf 20 -c:a aac -b:a 128k lesson-cut.mp4
  • -ss 30.5 قبل -i خيار إدخال: ابدأ القراءة من الثانية 30.5، وهذا يعني أن FFmpeg يقفز في الملف مباشرة دون أن يفك ما قبلها.
  • -t 10 بعد الإدخال خيار إخراج: اكتب عشر ثوان فقط في الملف الناتج.
  • -map 0:v:0 و-map 0:a:0: خذ أول دفق فيديو وأول دفق صوت من الإدخال رقم 0، والسبب أن FFmpeg إذا لم تحدد يختار وحده دفقاً واحداً من كل نوع بقواعده، وقد يختار مسار صوت بلغة لا تريدها في ملف فيه عدة مسارات.
  • -c:v و-c:a: الترميز لكل نوع من الدفق، و-crf 20 جودة ثابتة شرحناها في قسم معدل البت، و-preset سرعة المرمز، وسوف نقيس أثرها.

ولاحظ أن ترتيب المرشحات في سلسلة واحدة مهم أيضاً، فالسلسلة scale=-2:720,subtitles=… تصغر الصورة أولاً ثم تكتب الترجمة على الصورة الصغيرة، فيبقى حجم الخط كما صممته، أما العكس فيكتب الترجمة على صورة 1080 ثم يصغرها، فيصغر الخط معها.

لغة سلاسل المرشحات Filtergraph

المرشحات في FFmpeg لها لغة صغيرة خاصة بها يشرحها توثيق المرشحات، وتستحق دقائق من وقتك لأن أغلب الأوامر المعقدة مكتوبة بها. وقواعدها الأساسية ثلاث: الفاصلة تصل مرشحاً بالذي بعده في نفس السلسلة، فالمخرج الأول يصبح مدخل الثاني، مثل scale=-2:720,fps=24. والفاصلة المنقوطة تفصل بين سلسلتين مستقلتين داخل نفس الرسم. والأسماء بين الأقواس المربعة تصل السلاسل ببعضها، فالسلسلة تكتب مخرجها في [v] وسلسلة أخرى تبدأ منه. أما الخيار -vf فهو اختصار لسلسلة واحدة لها مدخل واحد هو الفيديو، وعندما تحتاج أكثر من مدخل، مثل الفيديو والشعار، أو أكثر من مخرج، فاستخدم -filter_complex ثم اختر المخرج النهائي ب -map.

وداخل كل مرشح خيارات بصيغة اسم=قيمة تفصلها النقطتان، مثل overlay=x=W-w-24:y=24، أو بالترتيب دون أسماء كما كتبناها. وبعض القيم تعابير تحسب لكل إطار، مثل W-w-24، وفي scale=-2:720 تعني -2 «احسب العرض بنفس النسبة واجعله عدداً زوجياً»، والسبب أن أغلب الترميزات مع تنسيق yuv420p لا تقبل أبعاداً فردية، وهذا خطأ تراه كثيراً إذا كتبت -1 بدلها مع مقاس ينتج عرضاً فردياً.

تجربة عملية: محاضرة المنصة مع مقطع Sintel

بدل محاضرة حقيقية نستخدم المقطع الدعائي لفيلم Sintel من مؤسسة Blender، وهو فيلم مفتوح مرخص بموجب Creative Commons Attribution 3.0 كما في صفحة الرخصة، والإسناد المطلوب هو «© copyright Blender Foundation | durian.blender.org»، وننزل نسخته 1080p من صفحة التنزيل الرسمية. ونستخدم FFmpeg 8.1.2 من حزمة Alpine 3.24 كما يأتي داخل Image مشروع MediaMTX الرسمية، وهي مبنية مع libx264 وlibass وHarfBuzz وFriBidi التي نحتاجها للترجمة العربية:

curl -LO https://download.blender.org/durian/trailer/sintel_trailer-1080p.mp4 && mv sintel_trailer-1080p.mp4 sintel.mp4
alias ff='docker run --rm -v "$PWD:/w" -w /w --entrypoint ffmpeg bluenviron/mediamtx:1.21.1-ffmpeg'
alias fp='docker run --rm -v "$PWD:/w" -w /w --entrypoint ffprobe bluenviron/mediamtx:1.21.1-ffmpeg'
fp -v error -show_entries format=duration,size,bit_rate:stream=index,codec_type,codec_name,profile,width,height,r_frame_rate,pix_fmt,sample_rate,channels,bit_rate -of default=nw=1 sintel.mp4
index=0
codec_name=h264
profile=High
codec_type=video
width=1920
height=1080
pix_fmt=yuv420p
r_frame_rate=24/1
bit_rate=2108423
index=1
codec_name=aac
profile=LC
codec_type=audio
sample_rate=48000
channels=2
r_frame_rate=0/0
bit_rate=126694
duration=52.208333
size=14621544
bit_rate=2240491

الملف فيه دفقان: فيديو H.264 بدقة 1920 في 1080 و24 إطاراً في الثانية بمعدل 2.1 ميجابت، وصوت AAC ستيريو بمعدل 127 كيلوبت، ومدته 52.2 ثانية. وفي كل الخطوات التالية نسخنا الملف داخل ال Container قبل القياس، والسبب أن المجلد المربوط من Windows بطيء في القراءة كما رأينا في مقال معالجة الصور، ولاحظ أن الجهاز كان يشغل أعمالاً أخرى في نفس الوقت، فقارن الأرقام ببعضها ولا تعتبرها سرعة المعالج المطلقة.

كم يأخذ فك الترميز وحده؟

الوجهة -f null - تعني «افعل كل شيء ثم ارم الناتج»، فهي طريقة قياس زمن المراحل التي قبل الكتابة:

T() { /usr/bin/time -f "%e s wall, %U s user, %M KB" "$@" 2>&1 | tail -1; }
T ffmpeg -hide_banner -v error -i sintel.mp4 -f null -
T ffmpeg -hide_banner -v error -i sintel.mp4 -f null -
T ffmpeg -hide_banner -v error -threads 1 -i sintel.mp4 -an -f null -
7.51 s wall, 15.95 s user, 139592 KB
7.93 s wall, 15.50 s user, 140760 KB
22.03 s wall, 20.89 s user, 78036 KB

فك 52 ثانية من فيديو 1080p أخذ 7.5 ثانية بكل الخيوط، أي قرابة سبعة أضعاف سرعة التشغيل، و22 ثانية بخيط واحد، ولاحظ أن الزمن الذي يقضيه المعالج User أكبر من الزمن الفعلي Wall في الحالة الأولى، لأن عدة خيوط عملت معاً. وهذا الرقم هو الحد الأدنى لأي عملية تفك الفيديو، فلا يوجد أمر فيه مرشح على هذا الملف يمكن أن يكون أسرع منه على هذا الجهاز.

اختيار الدفق: أي مسار صوت سوف يأخذ FFmpeg؟

المحاضرة المسجلة قد تحمل أكثر من مسار صوت، مثل الصوت الأصلي وتعليق عربي مسجل لاحقاً، فما الذي يحدث إذا لم تحدد؟ صنعنا ملفاً فيه الفيديو ومساران: الصوت الأصلي ستيريو بوسم اللغة الإنجليزية، ومسار أحادي بوسم اللغة العربية، ثم نسخناه مرة دون -map ومرة بتحديد اللغة:

ffmpeg -hide_banner -v error -y -i sintel.mp4 -f lavfi -i "sine=frequency=300:duration=52:sample_rate=48000" -map 0:v -map 0:a -map 1:a \
  -c:v copy -c:a aac -metadata:s:a:0 language=eng -metadata:s:a:0 title="Original" -metadata:s:a:1 language=ara -metadata:s:a:1 title="Arabic narration" multi.mkv
fp -v error -show_entries stream=index,codec_type,channels:stream_tags=language,title -of compact=p=0 multi.mkv
ffmpeg -hide_banner -y -i multi.mkv -t 2 -c copy auto.mkv 2>&1 | grep -A3 "^Stream mapping"
ffmpeg -hide_banner -y -i multi.mkv -t 2 -map 0:v -map 0:m:language:ara -c copy ar.mkv 2>&1 | grep -A3 "^Stream mapping"
index=0|codec_type=video
index=1|codec_type=audio|channels=2|tag:language=eng|tag:title=Original
index=2|codec_type=audio|channels=1|tag:language=ara|tag:title=Arabic narration
Stream mapping:
  Stream #0:0 -> #0:0 (copy)
  Stream #0:1 -> #0:1 (copy)
Output #0, matroska, to 'auto.mkv':
Stream mapping:
  Stream #0:0 -> #0:0 (copy)
  Stream #0:2 -> #0:1 (copy)
Output #0, matroska, to 'ar.mkv':

دون -map أخذ FFmpeg مساراً صوتياً واحداً فقط، وهو الستيريو الأصلي، لأن قاعدته الافتراضية تختار من كل نوع دفقاً واحداً، وفي الصوت تفضل الأكثر قنوات، وتجاهل التعليق العربي بصمت. ومع -map 0:m:language:ara اختار المسار بوسم اللغة العربية. والقسم «Stream mapping» في السجل هو أسرع طريقة لترى ما اختاره FFmpeg فعلاً، فاقرأه في أول تشغيل لأي أمر جديد، وفي خط المعالجة اكتب -map دائماً بدل الاعتماد على الاختيار الافتراضي.

سرعة المرمز Preset: الوقت مقابل الحجم والجودة

المرمز libx264 له إعدادات سرعة جاهزة اسمها Presets، من ultrafast إلى veryslow، وكل إعداد يحدد كم يبحث المرمز عن أفضل طريقة لضغط كل إطار كما يشرح دليل ترميز H.264. رمزنا الفيديو بأربعة إعدادات بنفس الجودة الثابتة CRF 23، ثم قسنا الحجم ومقياس التشابه SSIM بين كل ناتج والأصل:

for p in ultrafast veryfast medium slow; do
  r=$(T ffmpeg -hide_banner -v error -y -i sintel.mp4 -an -c:v libx264 -preset $p -crf 23 p-$p.mp4)
  printf "%-10s %s  %s bytes
" $p "$r" $(stat -c %s p-$p.mp4)
done
ultrafast  22.15 s wall, 47.97 s user, 331632 KB  30546892 bytes
veryfast   57.89 s wall, 128.45 s user, 624924 KB  11047663 bytes
medium     95.49 s wall, 279.10 s user, 865396 KB  12386264 bytes
slow       124.96 s wall, 376.42 s user, 941844 KB  11996802 bytes

ثم قسنا في تشغيل ثان التشابه SSIM بين كل ناتج والأصل، والرقم بين القوسين نفس المقياس بالديسيبل:

for p in ultrafast veryfast medium slow; do
  ffmpeg -hide_banner -i p-$p.mp4 -i sintel.mp4 -lavfi "[0:v][1:v]ssim" -f null - 2>&1 | grep -o "All:[0-9.]* ([0-9.]*)" | sed "s/^/$p /"
done
ultrafast All:0.993980 (22.204335)
veryfast All:0.994743 (22.792536)
medium All:0.995788 (23.755351)
slow All:0.995796 (23.763682)

والصورة التالية تضع الأرقام في رسم واحد:

رسم للإعدادات الأربعة لمرمز libx264 بنفس الجودة الثابتة CRF 23 على 52 ثانية من Sintel 1080p: ultrafast في 22 ثانية بحجم 29.1 ميجابايت وSSIM 0.99398، وveryfast في 58 ثانية بحجم 10.5 ميجابايت وSSIM 0.99474، وmedium في 95 ثانية بحجم 11.8 ميجابايت وSSIM 0.99579، وslow في 125 ثانية بحجم 11.4 ميجابايت وSSIM 0.99580، مع الذاكرة من 324 إلى 920 ميجابايت
ultrafast أكبر بثلاث مرات، والإعدادات الأبطأ ترفع الجودة أكثر مما تصغر الحجم

في النتائج أعلاه لاحظ التالي:

  • ultrafast أسرع بمرتين ونصف من veryfast، لكن ملفه أكبر بقرابة ثلاث مرات، 29 ميجابايت مقابل 10.5، لأنه يتخلى عن أغلب أدوات الضغط، فلا تستخدمه إلا لما تحذفه بعد دقائق، مثل ملف وسيط في خط معالجة.
  • medium أخرج ملفاً أكبر من veryfast، وهذا يفاجئ كثيرين، والسبب في عمود SSIM: الجودة الثابتة CRF ليست نفس الجودة بالضبط بين الإعدادات، فالإعدادات الأبطأ تصرف البتات على جودة أعلى قليلاً، 0.9958 مقابل 0.9947، وهو فرق لا تراه العين غالباً في هذا المقطع.
  • من medium إلى slow زاد الزمن 30% وصغر الملف 3% فقط بنفس الجودة، وهذه هي العلاقة المعتادة: كل خطوة أبطأ تعطي توفيراً أقل من التي قبلها.
  • الذاكرة زادت من 324 ميجابايت إلى 920 مع الإعدادات الأبطأ، لأن المرمز يحتفظ بإطارات مرجعية أكثر ويبحث فيها، وهذا مهم عندما تشغل عدة عمليات ترميز على نفس السيرفر.

والقرار العملي لمنصة المحاضرات أن veryfast أو medium نقطة البداية، والسبب أن المحاضرة ترمز مرة وتشاهد كثيراً، فكل دقيقة معالج إضافية تتوزع على كل المشاهدات، وهي نفس الحسبة التي شرحناها في منصة الفيديو عند الطلب.

القص: سريع أم دقيق؟

أول طلب في القائمة كان حذف بداية المحاضرة، فلنبدأ بسؤال بسيط: كيف تستخرج الإطار عند الثانية 30.5؟ هناك طريقتان، القفز قبل -i وبعده:

T ffmpeg -hide_banner -v error -y -ss 30.5 -i sintel.mp4 -frames:v 1 seek-input.jpg
T ffmpeg -hide_banner -v error -y -i sintel.mp4 -ss 30.5 -frames:v 1 seek-output.jpg
cmp -s seek-input.jpg seek-output.jpg && echo "same frame bytes"
2.23 s wall, 1.87 s user, 136128 KB
8.99 s wall, 13.93 s user, 146620 KB
same frame bytes

الإطار نفسه بالبايت، لكن القفز في الإدخال أسرع بأربع مرات، والسبب كما يشرح دليل القفز في FFmpeg: مع -ss قبل -i يقفز FFmpeg إلى أقرب إطار مفتاحي قبل الثانية المطلوبة، ثم يفك الإطارات منه حتى يصل إليها ويرمي ما قبلها، أما بعد -i فيفك كل الإطارات من بداية الملف ثم يرميها حتى يصل. ومنذ سنوات صار القفز في الإدخال دقيقاً عند إعادة الترميز، فلا يوجد سبب لاستخدام الطريقة البطيئة إلا في حالات خاصة.

لكن الدقة تختفي عندما تقص بنسخ الدفق، والسبب أن نسخ الدفق لا يفك أي إطار، فلا يستطيع أن يبدأ إلا من إطار مفتاحي. وفي مقطعنا الإطارات المفتاحية في الثواني التالية:

fp -v error -select_streams v -skip_frame nokey -show_entries frame=pts_time -of csv=p=0 sintel.mp4 | tr '\n' ' '
0.000000, 9.333333 13.916667 22.291667 23.791667 25.875000 27.416667 32.166667 42.583333 47.500000

فإذا طلبنا عشر ثوان من الثانية 30.5، فأقرب إطار مفتاحي قبلها هو 27.42، ثم قارنا القص بنسخ الدفق مع القص بإعادة الترميز:

ffmpeg -hide_banner -v error -y -ss 30.5 -i sintel.mp4 -t 10 -c copy cut-copy.mp4
ffmpeg -hide_banner -v error -y -ss 30.5 -i sintel.mp4 -t 10 -c:v libx264 -crf 20 -c:a aac cut-enc.mp4
for f in cut-copy cut-enc; do
  echo "$f: duration $(fp -v error -show_entries format=duration -of csv=p=0 $f.mp4)" \
       " first video pts $(fp -v error -select_streams v -show_entries packet=pts_time -of csv=p=0 $f.mp4 | head -1)" \
       " size $(stat -c %s $f.mp4)"
done
cut-copy: duration 10.166992  first video pts -3.083333  size 4417823
cut-enc: duration 10.000000  first video pts 0.000000  size 3810980
خط زمني للإطارات المفتاحية في مقطع Sintel عند الثواني 0 و9.33 و13.92 و22.29 و23.79 و25.88 و27.42 و32.17 و42.58 و47.50، وعليه شريط برتقالي يبدأ من 27.42 لما يحمله ملف القص بنسخ الدفق، وشريط أخضر من 30.5 إلى 40.5 لما طلبناه. وتحته بطاقتان: نسخ الدفق أول حزمة بطابع -3.08 ثانية ومدة 10.17 وحجم 4.4 ميجابايت، وإعادة الترميز تبدأ من صفر بمدة 10.00 وحجم 3.8 ميجابايت
نسخ الدفق يحمل كل ما بين الإطار المفتاحي وبداية القص

أول حزمة فيديو في القص بنسخ الدفق طابعها الزمني -3.08 ثانية، أي أن FFmpeg وضع في الملف كل الإطارات من الإطار المفتاحي 27.42 لأنها لازمة لفك الإطار عند 30.5، ووسمها بأوقات سالبة حتى يبدأ التشغيل من الثانية الصحيحة. وبعض المشغلات تحترم ذلك، وبعضها يعرض ثلاث ثوان إضافية أو صورة متجمدة في البداية، وبعض برامج التحرير تتعامل مع الملف بطريقة مختلفة. أما القص بإعادة الترميز فبدأ من صفر بالضبط ومدته عشر ثوان بالضبط، وثمنه وقت الترميز وخسارة صغيرة في الجودة. والقاعدة: إذا كان حد القص يهمك بدقة الإطار، كبداية محاضرة، فأعد الترميز، وإذا كان يكفيك تقريبياً، كقص ساعة من تسجيل مراقبة، فنسخ الدفق أسرع بكثير.

الطوابع الزمنية PTS وDTS: لماذا ظهرت أوقات سالبة؟

كل حزمة في ملف الفيديو تحمل طابعين زمنيين، وفهمهما يفسر رسائل كثيرة سوف تراها في سجلات FFmpeg. الطابع الأول هو وقت العرض Presentation Timestamp، واختصاراً PTS، أي متى يجب أن يظهر هذا الإطار على الشاشة، والثاني وقت فك الترميز Decoding Timestamp، واختصاراً DTS، أي متى يجب أن يفك. وقد يبدو أن الاثنين متساويان دائماً، لكن الترميزات الحديثة تستخدم إطارات ثنائية الاتجاه B-frames تعتمد على إطار قبلها وإطار بعدها، كما شرحنا في قسم الإطارات المفتاحية، فيجب أن يفك الإطار اللاحق أولاً ثم الإطار الذي بينهما، فيصبح ترتيب فك الترميز مختلفاً عن ترتيب العرض. والطوابع لا تكتب بالثواني، وإنما بعدد وحدات من قاعدة زمنية Timebase يحددها الملف لكل دفق، أي جزء صغير من الثانية، وهذا سبب الأرقام الكبيرة مثل 44932 في تحذيرات الدمج.

والآن تصبح النتائج الغريبة في التجربة منطقية: في القص بنسخ الدفق وضع FFmpeg الإطارات من الإطار المفتاحي 27.42 لأنها لازمة لفك ما بعدها، وأعطاها طوابع عرض سالبة حتى لا تعرض، فظهرت -3.08 في أول حزمة. وفي الدمج بنسخ الدفق انتقل التسلسل من ملف بقاعدة زمنية و30 إطاراً إلى ملف بقاعدة أخرى و24 إطاراً، فرجعت طوابع فك الترميز إلى الوراء، والحاوية لا تقبل طابعاً أصغر من الذي قبله، فعدلها FFmpeg بزيادة واحد وكتب التحذير «Non-monotonic DTS». والقاعدة العملية: أي تحذير فيه PTS أو DTS في السجل يعني أن التوقيت في الملف الناتج قد لا يكون ما تتوقعه، فلا تتجاهله.

الخيوط وتسريع العتاد Hardware Acceleration

رأينا في التجربة أن فك الترميز بخيط واحد أبطأ بثلاث مرات تقريباً، فالمرمز libx264 ومعظم فاكات الترميز تستخدم كل الأنوية تلقائياً، ويمكنك تحديدها بالخيار -threads. وفي خط معالجة يشغل عدة ملفات في نفس الوقت قد يكون الأفضل أن تحد الخيوط لكل عملية وتشغل عدة عمليات، كما فعلنا مع مكتبات الصور ومع Tesseract، لأن خيوط العمليات المتعددة تتزاحم على نفس الأنوية. أما تسريع العتاد فيعني أن ترمز أو تفك بالدوائر المخصصة للفيديو في بطاقة الرسوميات أو المعالج، مثل NVENC في بطاقات NVIDIA وQuick Sync في معالجات Intel وVAAPI على Linux، ويشرح دليل تسريع العتاد الخيارات المتاحة لكل منصة. وهذه الدوائر أسرع بكثير وتستهلك طاقة أقل، لكنها في الغالب تعطي ملفاً أكبر لنفس الجودة من libx264 بإعداد بطيء، وتحتاج تمرير جهاز العتاد إلى ال Container. والقاعدة: للبث المباشر وعدد كبير من الكاميرات فكر في العتاد، وللمحتوى المسجل الذي يرمز مرة ويشاهد كثيراً فالترميز بالمعالج بإعداد أبطأ يوفر في التخزين والنقل.

الشعار والترجمة العربية: مرشحات في سلسلة واحدة

الطلب التالي: صغر المحاضرة إلى 720p، واكتب عليها الترجمة العربية، وضع الشعار في الزاوية العليا. وكل ذلك في أمر واحد بسلسلة مرشحات -filter_complex، لأن عندنا إدخالين، الفيديو وصورة الشعار. ملف الترجمة subs-ar.srt بصيغة SRT العادية:

1
00:00:01,000 --> 00:00:04,500
مرحباً بكم في الدرس الأول من دورة التصميم

2
00:00:05,000 --> 00:00:09,000
سوف نتعلم اليوم كيف نرسم الشخصية خطوة خطوة

ومرشح subtitles يكتب الترجمة على الصورة بمكتبة libass، وهي تستخدم HarfBuzz لتشكيل الحروف العربية المتصلة وFriBidi لاتجاه النص، كما يذكر توثيق المرشح. ووضعنا خط IBM Plex Sans Arabic في مجلد وأشرنا إليه بالخيار fontsdir:

ffmpeg -hide_banner -v error -y -f lavfi -i "color=c=0xE95420@1:s=220x60,format=rgba" -frames:v 1 logo.png
ffmpeg -hide_banner -v error -y -i sintel.mp4 -i logo.png -t 12 -filter_complex \
 "[0:v]scale=-2:720,subtitles=subs-ar.srt:fontsdir=/t/fonts:force_style='FontName=IBM Plex Sans Arabic,FontSize=22'[v];[v][1:v]overlay=W-w-24:24[out]" \
 -map "[out]" -map 0:a -c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 128k lesson-720.mp4
[Parsed_subtitles_1 @ 0x7f68d2e35440] Failed to load fontconfig fonts!

الأمر انتهى ورمز خروجه نجاح، وخرج الملف بالشعار والمقاس الصحيح، لكن الترجمة لم تظهر في أي إطار، وكل ما تركه FFmpeg هو هذا التحذير. والسبب أن libass تبحث عن الخطوط عبر مكتبة fontconfig، وال Image فيها ملف إعداد fontconfig دون أي خط مثبت، فلم تجد شيئاً تكتب به، والخيار fontsdir وحده لم يكف في هذه البيئة. والحل الذي نجح أن نكتب ملف إعداد fontconfig صغيراً يشير إلى مجلد الخطوط، ونمرره بالمتغير FONTCONFIG_FILE:

cat > /t/fonts.conf <<'X'
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig><dir>/t/fonts</dir><cachedir>/tmp/fc-cache</cachedir></fontconfig>
X
export FONTCONFIG_FILE=/t/fonts.conf
ffmpeg -hide_banner -v error -y -i sintel.mp4 -i logo.png -t 12 -filter_complex \
 "[0:v]scale=-2:720,subtitles=subs-ar.srt:force_style='FontName=IBM Plex Sans Arabic,FontSize=22'[v];[v][1:v]overlay=W-w-24:24[out]" \
 -map "[out]" -map 0:a -c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 128k lesson-720.mp4
fp -v error -show_entries stream=codec_name,width,height -show_entries format=duration -of compact=p=0:nk=1 lesson-720.mp4
h264|1280|720
aac
12.010667

والصورة التالية تبين الإطار نفسه قبل الإصلاح وبعده، ومعه نفس الجملة بمرشح drawtext:

ثلاثة إطارات حقيقية من التجربة من مقطع Sintel: الأول دون أي ترجمة رغم أن الأمر نجح، والسبب تحذير Failed to load fontconfig fonts، والثاني بعد الإصلاح وفيه سطر الترجمة العربية متصل الحروف أسفل الصورة سوف نتعلم اليوم كيف نرسم الشخصية خطوة خطوة، والثالث بمرشح drawtext وفيه جملة مرحباً بكم في الدرس الأول بحروف متصلة صحيحة
الأمر نجح والترجمة لم تظهر: اقرأ التحذيرات، وافحص إطاراً من الناتج

في الإعداد أعلاه لاحظ التالي:

  • الأسماء بين الأقواس المربعة مثل [v] و[out] أسماء وصلات بين المرشحات، و[1:v] هو الفيديو من الإدخال الثاني، أي صورة الشعار، وoverlay=W-w-24:24 تضعه على بعد 24 بكسلاً من الحافة اليمنى العليا، حيث W عرض الفيديو وw عرض الشعار.
  • الترجمة كتبت بعد التصغير، فبقي حجم الخط 22 كما اخترناه نسبة إلى صورة 720.
  • هذه ترجمة محروقة Burned-in، أي أنها صارت جزءاً من البكسلات ولا يستطيع المشاهد إخفاءها، وهي مناسبة للمقاطع القصيرة على الشبكات الاجتماعية، أما في منصتك فالأفضل غالباً ملف ترجمة منفصل بصيغة WebVTT يختاره المشاهد، فتضيف لغات دون إعادة ترميز.
  • مرشح drawtext شكل الحروف العربية بشكل صحيح أيضاً، لأن FFmpeg مبني مع HarfBuzz كما يشترط توثيق drawtext، لكنه مصمم لنص ثابت مثل اسم المنصة أو الوقت، وليس لترجمة متغيرة بتوقيتات.
  • الدرس الأهم أن FFmpeg لا يفشل دائماً عندما يفشل، فهذا الأمر أعاد نجاحاً وأخرج ملفاً سليماً دون ما طلبته، لذلك في خط المعالجة استخرج إطاراً من الناتج وافحصه، أو على الأقل اعتبر أي سطر تحذير في السجل سبباً للمراجعة.

توحيد مستوى الصوت Loudness Normalization

شكوى المشاهدين الأشهر في منصات المحاضرات أن محاضرة تحتاج رفع الصوت إلى آخره وأخرى تصم الأذن. والمقياس الذي تستخدمه منصات البث والإذاعات هو جهارة الصوت المتكاملة Integrated Loudness بوحدة LUFS، المبنية على توصية ITU-R BS.1770، ومرشح loudnorm في FFmpeg يقيسها ويعدلها بطريقة توصية EBU R128. والطريقة الصحيحة بمرورين: الأول يقيس، والثاني يعدل بالقيم المقاسة:

ffmpeg -hide_banner -i sintel.mp4 -vn -af loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json -f null - 2>&1 | sed -n '/^{/,/^}/p'
{
	"input_i" : "-12.91",
	"input_tp" : "1.16",
	"input_lra" : "19.90",
	"input_thresh" : "-23.95",
	"output_i" : "-15.38",
	"output_tp" : "-1.50",
	"output_lra" : "11.70",
	"output_thresh" : "-25.87",
	"normalization_type" : "dynamic",
	"target_offset" : "-0.62"
}

الصوت الأصلي جهارته -12.91 LUFS، أي أعلى من الهدف -16 الشائع لمنصات الإنترنت، وذروته الحقيقية True Peak +1.16، أي فوق الصفر، وهذا يعني تشوهاً محتملاً عند التشغيل على بعض الأجهزة. ثم المرور الثاني بالقيم المقاسة، مع الإبقاء على الفيديو كما هو بنسخ الدفق، ثم قياس الناتج بمرشح ebur128:

ffmpeg -hide_banner -v error -y -i sintel.mp4 -c:v copy -af "loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=-12.91:measured_TP=1.16:measured_LRA=19.90:measured_thresh=-23.95:offset=-0.62:linear=true" -c:a aac -b:a 128k norm.mp4
ffmpeg -hide_banner -i norm.mp4 -vn -af ebur128 -f null - 2>&1 | grep -E "^\s+(I|LRA):" | tail -2
    I:         -16.2 LUFS
    LRA:        12.4 LU

الناتج -16.2 LUFS، أي على الهدف تقريباً. ولاحظ أننا طلبنا تعديلاً خطياً linear=true، أي رفع الصوت أو خفضه كله بنفس المقدار، لكن المدى الديناميكي للمقطع الدعائي واسع جداً، 19.9 LU، والهدف 11، فلا يكفي تعديل خطي لتحقيق كل الأهداف معاً، لذلك بقي المدى 12.4. وفي محاضرة بصوت واحد متقارب يكون المدى أصغر بكثير وتصل إلى الهدف بسهولة، أما إذا شغلت المرشح بمرور واحد فسوف يضغط الصوت ديناميكياً كما يقول السطر "normalization_type" : "dynamic"، وقد يغير طبيعة الصوت، لذلك المروران أفضل في المحتوى المسجل.

الصور المصغرة وشريط المعاينة

الصورة المصغرة الجيدة ليست الإطار الأول، فهو غالباً شاشة سوداء أو شعار، والطريقة الأذكى أن تبحث عن تغيرات المشهد وتختار منها، ثم تصنع شريط معاينة من عدة إطارات في صورة واحدة يعرضه المشغل عند تمرير المؤشر:

ffmpeg -hide_banner -i sintel.mp4 -vf "select='gt(scene,0.4)',showinfo" -vsync vfr -f null - 2>&1 | grep -o "pts_time:[0-9.]*" | tr '\n' ' '
T ffmpeg -hide_banner -v error -y -i sintel.mp4 -vf "fps=1/5,scale=320:-2,tile=5x2" -frames:v 1 sprite.jpg
pts_time:16.5 pts_time:25.875 pts_time:28
10.68 s wall

المرشح select مع scene يحسب لكل إطار درجة تغير عن الإطار السابق من 0 إلى 1، فوجد ثلاث نقلات كبيرة فوق 0.4، وفي المحاولة الأولى شغلنا نفس الأمر مع -v error فكانت النتيجة صفراً، والسبب أن المرشح showinfo يكتب في السجل بمستوى المعلومات، والخيار -v error يخفيه، أي أن خيار تقليل الضجيج في السجل أخفى البيانات التي نبحث عنها. أما شريط المعاينة فأخذ إطاراً كل خمس ثوان وصغره إلى عرض 320 ورتب عشرة منها في شبكة 5 في 2، والناتج صورة واحدة حجمها 29 كيلوبايت:

شريط معاينة حقيقي من التجربة: عشر لقطات من مقطع Sintel مرتبة في صفين من خمس، كل لقطة بعد خمس ثوان من التي قبلها، فيها المشاهد الثلجية والشخصية والتنين، وحجم الصورة كلها 29 كيلوبايت
صورة واحدة بعشر لقطات: المشغل يعرض الجزء المناسب منها عند تمرير المؤشر. © copyright Blender Foundation | durian.blender.org، CC BY 3.0

نجرب العطل: دمج مقدمة بمحاضرة بخصائص مختلفة

آخر طلب في القائمة: مقدمة المنصة قبل كل محاضرة. وأسرع طريقة لذلك هي قائمة الدمج Concat Demuxer مع نسخ الدفق، فلا يعاد ترميز أي شيء، كما يشرح دليل الدمج. لكن المقدمة التي صممها فريق التسويق بدقة 1280 في 720 و30 إطاراً في الثانية وصوت 44.1 كيلوهرتز، والمحاضرة 1920 في 1080 و24 إطاراً و48 كيلوهرتز:

printf "file 'intro.mp4'\nfile 'part.mp4'\n" > list.txt
ffmpeg -hide_banner -v warning -y -f concat -i list.txt -c copy joined-copy.mp4 2>&1 | tail -3; echo "exit=$?"
fp -v error -show_entries stream=codec_type,width,height,r_frame_rate,sample_rate -of compact=p=0 joined-copy.mp4
[vost#0:0/copy @ 0x7f897a6e28c0] Non-monotonic DTS; previous: 44932, current: 43877; changing to 44933. This may result in incorrect timestamps in the output file.
[vost#0:0/copy @ 0x7f897a6e28c0] Non-monotonic DTS; previous: 44933, current: 44389; changing to 44934. This may result in incorrect timestamps in the output file.
[vost#0:0/copy @ 0x7f897a6e28c0] Non-monotonic DTS; previous: 44934, current: 44901; changing to 44935. This may result in incorrect timestamps in the output file.
exit=0
codec_type=video|width=1280|height=720|r_frame_rate=30/1
codec_type=audio|sample_rate=44100|r_frame_rate=0/0

رمز الخروج صفر، أي نجاح، لكن الملف معطوب بطريقتين: الحاوية تقول إن كل الفيديو 1280 في 720 و30 إطاراً والصوت 44.1 كيلوهرتز، أي خصائص الملف الأول فقط، بينما الجزء الثاني حزمه 1080p و24 إطاراً، فبعض المشغلات تعرضه مشوهاً أو تتوقف عند نقطة الانتقال، وطوابع فك الترميز DTS في نقطة الانتقال رجعت إلى الوراء فعدلها FFmpeg بنفسه، كما تقول التحذيرات. والسبب أن قائمة الدمج مع نسخ الدفق تفترض أن كل الملفات بنفس الترميز والإعدادات تماماً، وهي لا تتحقق من ذلك. والحل مرشح الدمج concat الذي يعمل على الإطارات بعد توحيد خصائصها، أي بإعادة ترميز:

T ffmpeg -hide_banner -v error -y -i intro.mp4 -i part.mp4 -filter_complex \
 "[0:v]scale=1920:1080,fps=24,setsar=1[v0];[1:v]setsar=1[v1];[0:a]aresample=48000[a0];[1:a]aresample=48000[a1];[v0][a0][v1][a1]concat=n=2:v=1:a=1[v][a]" \
 -map "[v]" -map "[a]" -c:v libx264 -preset veryfast -crf 23 -c:a aac joined-enc.mp4
fp -v error -show_entries stream=codec_type,width,height,r_frame_rate,sample_rate -show_entries format=duration -of compact=p=0 joined-enc.mp4
10.54 s wall
codec_type=video|width=1920|height=1080|r_frame_rate=24/1
codec_type=audio|sample_rate=48000|r_frame_rate=0/0
duration=9.083333

الآن الملف كله 1080p و24 إطاراً و48 كيلوهرتز، ومدته 9.08 ثانية، أي ثلاث ثوان للمقدمة وست للمحاضرة. والحل الأفضل لمنصة تضيف نفس المقدمة لكل محاضرة أن ترمز المقدمة مرة واحدة بنفس إعدادات المحاضرات بالضبط، ثم تدمج بنسخ الدفق دون إعادة ترميز المحاضرة كاملة، لكن بشرط أن تتحقق قبل الدمج من أن الخصائص متطابقة، بأمر ffprobe مثل الذي استخدمناه.

حذف التجربة

rm -f sintel.mp4 p-*.mp4 cut-*.mp4 lesson-720.mp4 norm.mp4 joined-*.mp4 intro.mp4 part.mp4 sprite.jpg seek-*.jpg logo.png list.txt
docker image rm bluenviron/mediamtx:1.21.1-ffmpeg

FFmpeg في الإنتاج: السكربت الذي لا يكذب عليك

في التجربة كتبنا الأوامر بأيدينا ورأينا النتائج، أما في المنصة فسوف يشغلها Worker خلف طابور آلاف المرات دون أن يراها أحد، كما شرحنا في مفاهيم الطوابير، وهنا تظهر أشياء لا تهمك على سطر الأوامر.

كيف تقرأ سجل FFmpeg؟

عندما يفشل أمر أو يعطي نتيجة غير متوقعة فالسجل يجيب غالباً، إذا عرفت ترتيبه. يبدأ بمعلومات البناء والمكتبات، وتخفيها ب -hide_banner كما فعلنا في كل الأوامر، ثم وصف كل ملف إدخال بدفقاته وترميزاتها، ثم قسم «Stream mapping» الذي يقول أي دفق ذهب إلى أين وهل نسخ أم أعيد ترميزه، ثم وصف ملف الإخراج، ثم سطر التقدم الذي يحدث نفسه، ثم ملخص الأحجام في النهاية. ومستوى التفصيل تحدده بالخيار -v: error للسكربتات حتى لا يمتلئ السجل، وwarning لترى التحذيرات مثل التي كشفت الترجمة المفقودة والدمج المعطوب، وverbose عندما تبحث عن سبب، كما فعلنا مع قائمة HLS. لكن تذكر ما حدث لنا مع كشف تغير المشهد: المستوى المنخفض قد يخفي بيانات يكتبها مرشح في السجل، فاختر المستوى بحسب ما تحتاجه من السجل، لا بحسب ما يريحك.

رموز الخروج Exit Codes والملفات المعطوبة

أول ما يجب أن يفحصه سكربتك هو رمز الخروج، فقصصنا ملف المقطع بعد أول 300 كيلوبايت لنحاكي رفعاً انقطع في منتصفه، وطلبنا من FFmpeg أن ينسخه:

head -c 300000 sintel.mp4 > broken.mp4
ffmpeg -hide_banner -v error -nostdin -y -i broken.mp4 -c copy fixed.mp4; echo "exit=$?"
[in#0 @ 0x7f588850e340] moov atom not found
[in#0 @ 0x7f5882637b40] Error opening input: Invalid data found when processing input
Error opening input file broken.mp4.
Error opening input files: Invalid data found when processing input
exit=183

الرسالة moov atom not found تعني أن فهرس ملف MP4 غير موجود، لأن الكاميرات وبرامج التسجيل تكتبه غالباً في آخر الملف، فالرفع المقطوع يفقده، ورمز الخروج غير صفري، فيعرف السكربت أن يعيد طلب الرفع. لكن تذكر ما رأيناه في التجربة: الترجمة التي لم تظهر والدمج المعطوب انتهيا كلاهما برمز خروج صفر، لذلك رمز الخروج يكفي لاكتشاف الفشل الكامل فقط، ويحتاج خط المعالجة فحوصاً بعده: مدة الناتج قريبة من المتوقعة، والخصائص كما طلبتها، ولا تحذيرات في السجل، وإطار من منتصف الناتج يستخرج ويراجع عند الشك. وأضف دائماً -nostdin في السكربتات، لأن FFmpeg يقرأ من لوحة المفاتيح افتراضياً، وفي بعض البيئات يعلق أو يأكل مدخلات سكربت آخر.

متابعة التقدم Progress

المحاضرة التي مدتها ساعة قد تأخذ دقائق في الترميز، والمدرب يريد أن يرى نسبة التقدم، والخيار -progress يكتب حالة منظمة كل نصف ثانية تقريباً بدل السطر الذي يحدث نفسه على الشاشة:

ffmpeg -hide_banner -v error -nostdin -y -i sintel.mp4 -t 4 -an -c:v libx264 -preset ultrafast -progress pipe:1 -f null - | head -14
frame=17
fps=0.00
stream_0_0_q=17.0
bitrate=N/A
total_size=N/A
out_time_us=708333
out_time_ms=708333
out_time=00:00:00.708333
dup_frames=0
drop_frames=0
speed= 1.4x
progress=continue
frame=37
fps=36.36

اقسم out_time_us على مدة الملف التي تعرفها من ffprobe لتحصل على النسبة، والسطر progress=end يعني الانتهاء. ولاحظ أن out_time_ms بالميكروثانية رغم اسمه، فهو بنفس قيمة out_time_us كما ترى في المخرج، وهذا من الأشياء التي يجب أن تقرأها من المخرج الفعلي لا من الاسم. والرسالة في آخر المخرج الكامل عن «Broken pipe» سببها أن الأمر head أغلق القراءة بعد 14 سطراً فقط، وهي متوقعة هنا.

الأمان: الملف الذي يرفعه شخص لا تعرفه

FFmpeg يقرأ مئات الصيغ والبروتوكولات، وهذا رائع على حاسبك، لكنه على السيرفر يعني أن الملف المرفوع قد يطلب من FFmpeg أشياء لم تقصدها. وأشهر مثال ثغرة CVE-2016-1897 المنشورة في يناير 2016، التي تصفها قاعدة NVD بأن FFmpeg 2.x كان يسمح بقراءة ملفات من السيرفر عبر ملف قائمة HLS بامتداد M3U8 يستخدم بروتوكول concat، فيرسل FFmpeg أول سطر من الملف المحلي ضمن طلب HTTP إلى سيرفر المخترق، ومعها CVE-2016-1898 من نفس النوع. وكان يكفي أن يرفع المخترق ملفاً يسميه video.mp4 ومحتواه قائمة HLS، لأن FFmpeg يحدد الصيغة من المحتوى وليس من الاسم. وجربنا الفكرة على الإصدار الحالي بقائمة HLS تشير إلى ملف كلمات المرور في النظام:

printf '#EXTM3U\n#EXT-X-TARGETDURATION:10\n#EXTINF:10,\nfile:///etc/passwd\n#EXT-X-ENDLIST\n' > evil.m3u8
ffmpeg -hide_banner -v verbose -y -i evil.m3u8 -c copy out.ts 2>&1 | grep -iE "allowed|passwd"
ffmpeg -hide_banner -v verbose -y -allowed_extensions ALL -i evil.m3u8 -c copy out.ts 2>&1 | grep -iE "allowed|Invalid"
[in#0 @ 0x7f8d27356340] URL file:///etc/passwd is not in allowed_segment_extensions, consider updating hls.c and submitting a patch to ffmpeg-devel, if this should be added
[in#0 @ 0x7f93a4799340] URL file:///etc/passwd is not in allowed_segment_extensions, consider updating hls.c and submitting a patch to ffmpeg-devel, if this should be added
[in#0 @ 0x7f93a4799340] Error opening input: Invalid data found when processing input

الإصدار الحالي رفض المقطع لأن امتداده ليس من امتدادات الوسائط المسموحة في قارئ HLS، حتى عندما فتحنا الخيار -allowed_extensions. لكن لا تعتمد على هذا وحده، فالدرس من الحادثة أعم من ثغرة واحدة، وهذه هي الطبقات التي تحمي بها خط المعالجة:

  • حدد صيغة الإدخال المتوقعة بالخيار -f، مثل -f mov لملفات MP4 وMOV، فلا يفسر FFmpeg ملفاً مرفوعاً على أنه قائمة HLS أو صيغة غريبة.
  • قيد البروتوكولات بالخيار -protocol_whitelist file كما في توثيق البروتوكولات، فلا يستطيع ملف أن يطلب HTTP أو أي بروتوكول آخر.
  • شغل ال Worker في Container بلا شبكة خارجية، وبنظام ملفات للقراءة فقط إلا مجلد العمل، وبحدود للذاكرة والزمن، كما فعلنا مع مكتبات الصور في مقال معالجة الصور على السيرفر في مرحلة الصور.
  • حدث FFmpeg باستمرار، لأن قارئات الصيغ هي أكثر كود فيه يقرأ مدخلات معقدة من أشخاص لا تعرفهم، وافحص ال Image بأداة مثل Trivy كما في فحص الحاويات.

كم Worker تحتاج المنصة؟

من أرقام التجربة نستطيع أن نقدر السعة تقديراً أولياً، مع تذكر أن الجهاز كان مشغولاً بأعمال أخرى، فاعتبر الأرقام حداً أدنى. ترميز 52 ثانية من فيديو 1080p بإعداد veryfast أخذ قرابة 58 ثانية من الزمن الفعلي و128 ثانية من زمن المعالج، أي قرابة 2.5 ثانية من زمن المعالج لكل ثانية فيديو. فإذا رفع المدربون في المنصة عشر ساعات محاضرات في اليوم، فهذا 36,000 ثانية فيديو، وبهذا المعدل قرابة 90,000 ثانية معالج، أي 25 ساعة معالج في اليوم لنسخة 1080p واحدة، أو سيرفر بثماني أنوية يعمل قرابة ثلاث ساعات. وإذا أضفت سلم جودات للبث المتكيف كما في المقال التالي فاضرب في عدد الجودات تقريباً، مع أن الجودات الصغيرة أرخص. والمحاضرات في الغالب أسهل ترميزاً من مقطع Sintel المليء بالحركة والضباب، لأن أغلبها شرائح ثابتة أو مدرب أمام خلفية لا تتغير، فقس على محاضراتك قبل أن تشتري سيرفراً.

وقد يتساءل البعض: هل أكتب كل هذه الأوامر بنفسي، أم توجد طبقة فوق FFmpeg؟ والإجابة أن هناك مكتبات تستدعي مكتبات FFmpeg مباشرة من لغتك، مثل PyAV في Python، وهناك إطار مختلف تماماً هو GStreamer يبني خطوط معالجة الوسائط من عناصر متصلة ويستخدم كثيراً في البث والأجهزة المدمجة. لكن في منصات الفيديو على الويب يبقى استدعاء أداة ffmpeg من Worker هو الأكثر انتشاراً، والسبب أن الأوامر نفسها تجربها على حاسبك ثم تنقلها كما هي إلى السيرفر، وأن خطأ في أمر يبقى في عملية منفصلة لا يوقف تطبيقك.

مرجع سريع: أوامر المنصة كما استخدمناها

المهمةالأمرملاحظة من التجربة
قراءة خصائص الملفffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate -of compact f.mp4افحص قبل الدمج وبعد كل خطوة
قص دقيقffmpeg -ss 30.5 -i in.mp4 -t 10 -c:v libx264 -crf 20 -c:a aac out.mp4يبدأ من صفر بالضبط
قص سريعffmpeg -ss 30.5 -i in.mp4 -t 10 -c copy out.mp4يبدأ من أقرب إطار مفتاحي قبله
إطار واحدffmpeg -ss 30.5 -i in.mp4 -frames:v 1 f.jpgالقفز قبل -i أسرع بأربع مرات
شعار وترجمة ومقاس-filter_complex "[0:v]scale=-2:720,subtitles=s.srt[v];[v][1:v]overlay=W-w-24:24"FONTCONFIG_FILE يشير إلى مجلد الخطوط
توحيد الصوت-af loudnorm=I=-16:TP=-1.5:LRA=11 بمرورينمن -12.91 إلى -16.2 LUFS
شريط معاينة-vf "fps=1/5,scale=320:-2,tile=5x2" -frames:v 1صورة واحدة 29 كيلوبايت
دمج ملفات مختلفةمرشح concat بعد توحيد المقاس والإطارات والصوتقائمة الدمج مع النسخ تعطي ملفاً معطوباً بنجاح

أخطاء شائعة وكيف تكتشفها

  • وضع الخيار في المكان الخطأ: -ss بعد -i يبطئ القص أربع مرات، و-t قبل -i يحد قراءة الإدخال وليس طول الناتج، فاقرأ الأمر من اليسار على أنه «خيارات، ثم الملف الذي تخصه».
  • القص بنسخ الدفق عندما تحتاج الدقة: يبدأ الملف قبل وقته بثوان، واكتشفه بطابع أول حزمة في ffprobe.
  • الاعتماد على رمز الخروج وحده: الترجمة المفقودة والدمج المعطوب نجحا برمز صفر، فافحص الناتج نفسه.
  • تجاهل التحذيرات في السجل: «Failed to load fontconfig fonts» و«Non-monotonic DTS» كانتا الإشارة الوحيدة على المشكلة.
  • ultrafast للملفات النهائية: ثلاثة أضعاف الحجم، أي ثلاثة أضعاف التخزين والنقل مع كل مشاهدة.
  • ترك FFmpeg يخمن صيغة الملف المرفوع وبروتوكولاته: حدد الصيغة وقيد البروتوكولات.
  • كتابة scale=-1:720 بدل scale=-2:720: قد ينتج عرضاً فردياً يرفضه المرمز مع تنسيق yuv420p، و-2 تضمن عدداً زوجياً.
  • نسيان -map في ملف فيه عدة مسارات صوت أو ترجمة: يختار FFmpeg واحداً بقواعده، وقد لا يكون ما تريد.

الخلاصة

  • FFmpeg مجموعة مكتبات تقف خلف أغلب برامج الفيديو، وكل أمر فيه خط من خمس مراحل: فك الحاوية، وفك الترميز، والمرشحات، والترميز، والتجميع، ونسخ الدفق يتجاوز المنتصف كله فيكون سريعاً دون خسارة.
  • موضع الخيار يحدد الملف الذي يخصه، والقفز قبل الإدخال أسرع بأربع مرات بنفس الإطار.
  • سرعة المرمز تبادل بين الوقت والحجم والجودة: ultrafast أكبر بثلاث مرات، والإعدادات الأبطأ ترفع الجودة أكثر مما تصغر الحجم.
  • الترجمة العربية تحتاج libass مع HarfBuzz وFriBidi وخطاً تجده fontconfig فعلاً، وإلا نجح الأمر دون ترجمة.
  • القص بنسخ الدفق يبدأ من إطار مفتاحي، والدمج بنسخ الدفق يفترض ملفات متطابقة، وكلاهما قد ينجح برمز صفر وناتج غير صحيح.
  • في الإنتاج افحص الناتج لا رمز الخروج وحده، وتابع التقدم ب -progress، وعامل كل ملف مرفوع كمدخل غير موثوق.

وفي المقال التالي من نفس المرحلة نستخدم FFmpeg ليصنع سلم الجودات ويقطعه أجزاء للبث المتكيف بحسب سرعة المشاهد، وهذه مقالات قريبة من الموضوع:

دليلمفاهيم الفيديو: الحاوية والترميز Codec ومعدل البتما الذي يحدد حجم ملف الفيديو وجودته، ولماذا يعمل ملف في متصفح ولا يعمل في آخر، مع تجربة حقيقية تقيس الفرق بين الترميزات.دليلكيف يعمل بث الفيديو المباشر وعند الطلب؟كيف يصل الفيديو إلى كل مشاهد بجودة تناسب سرعة اتصاله، ولماذا يتأخر البث المباشر عشرين ثانية أحياناً وأقل من ثانية أحياناً أخرى.دليلكيف تبني منصة فيديو عند الطلب مثل Netflixكيف يتحول فيديو مرفوع إلى بث يناسب كل شاشة وسرعة اتصال، وما الذي تحتاجه منصتك من التحويل إلى التوصيل والتكلفة.دليلكيف تبني تطبيق فيديو قصير مثل TikTokكيف يجهز تطبيق الفيديو القصير المقطع التالي قبل أن تسحب الشاشة، وكيف يختار ما يناسبك من إشارات المشاهدة.دليلالطابور Queue للمبتدئين: لماذا تحتاجه وكيف يعمللماذا تضع العمل البطيء في طابور بدل أن ينتظره المستخدم، وكيف تضمن ألا تضيع الرسالة إذا توقف ال Worker فجأة.دليلفحص ال Docker Images وملفات البنية ككود IaCماذا يرى فاحص الثغرات داخل ال Docker Image وما الذي يفوته، وكيف تختار Base Image أصغر وتجعل الفحص بوابة في خط CI.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على FFmpeg 8.1.2 من حزمة Alpine 3.24 مع libx264 وlibass في Docker Engine 29.8.0، على المقطع الدعائي لفيلم Sintel.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.