عندما تضغط زر التشغيل في منصة فيديو وأنت على شبكة الجوال في الطريق، يبدأ الفيديو بجودة منخفضة، ثم يصبح أوضح بعد ثوان، ثم ينخفض مرة أخرى عندما تدخل نفقاً، دون أن يتوقف. وعندما تشاهد مباراة مباشرة، تسمع صراخ الجيران بالهدف قبل أن تراه على شاشتك بعشرين ثانية. وفي مكالمة فيديو لا يحدث هذا التأخير إطلاقاً. وقد يتساءل البعض: أليس كل هذا فيديو يصل عبر الانترنت؟ فلماذا يتصرف بثلاث طرق مختلفة؟ والإجابة أن كل واحدة منها طريقة بث مختلفة، صممت لتوازن بشكل مختلف بين ثلاثة أشياء: عدد المشاهدين، وثبات التشغيل، والتأخير.
في مفاهيم الفيديو للمبتدئين شرحنا الإطار والترميز Codec والحاوية Container ومعدل البت Bitrate والإطارات المفتاحية Keyframes، وهنا نأخذ الملف ونوصله إلى المشاهد، وكل ما في هذا المقال يعتمد على تلك المفاهيم، وخاصة الإطارات المفتاحية. وسوف نناقش في هذا المقال ما يلي:
- الفرق بين الفيديو عند الطلب Video on Demand والبث المباشر Live.
- التحميل التدريجي Progressive Download، ولماذا لا يكفي، ثم البث المتكيف Adaptive Streaming.
- HLS وDASH: ملفات القوائم Manifests والأجزاء Segments، وسلم الجودات Bitrate Ladder، وكيف يختار المشغل بينها.
- بروتوكولات الإدخال Ingest: RTMP وSRT وWHIP، ومستويات التأخير Latency من عشرات الثواني إلى أقل من ثانية.
- دور شبكة توصيل المحتوى CDN، وحماية المحتوى DRM باختصار.
- تجربة عملية: سلم من ثلاث جودات بأداة ffmpeg يقدمه nginx ويشغله hls.js في المتصفح، ونخنق الاتصال لنرى المشغل يغير الجودة، ثم بث مباشر عبر RTMP وSRT إلى MediaMTX، ونقيس التأخير بساعة مطبوعة على الفيديو.
عند الطلب أم مباشر؟
- الفيديو عند الطلب Video on Demand واختصاراً VOD: الملف موجود كاملاً قبل أن يطلبه أحد، مثل فيلم أو درس مسجل، فتستطيع أن تضغطه بأبطأ إعداد وأفضل جودة، وتجهز له عدة نسخ، وتضعه في ال Cache مسبقاً، والمشاهد يستطيع أن يقفز إلى أي دقيقة.
- البث المباشر Live: الفيديو يصنع الآن، والسيرفر يستقبله من الكاميرا ويضغطه ويقسمه ويرسله في نفس اللحظة، فلا وقت لإعداد بطيء، وكل ثانية يقضيها في أي خطوة تضاف إلى التأخير عن الواقع، وهو ما يسمى التأخير من الكاميرا إلى الشاشة Glass-to-Glass Latency.
والجميل أن الطريقتين اليوم تستخدمان نفس الأدوات تقريباً: نفس الأجزاء ونفس القوائم ونفس المشغل، والفرق أن القائمة في البث المباشر تتحدث كل بضع ثوان، وفي الفيديو عند الطلب ثابتة ولها نهاية.
التحميل التدريجي: الطريقة البسيطة
الطريقة البسيطة أن تضع ملف MP4 على سيرفر ويب، وتكتب في الصفحة <video src="movie.mp4">، فيبدأ المتصفح بتحميل الملف ويعرضه أثناء التحميل، وإذا قفزت إلى الدقيقة 10 طلب ذلك الجزء بطلب نطاق HTTP Range، وهذا يسمى التحميل التدريجي Progressive Download، وهو يعمل جيداً لمقطع قصير على موقع شركتك. ولكنه يفشل في ثلاث حالات:
- جودة واحدة لكل المشاهدين: ملف 1080p بمعدل 6 Mbps يتوقف كل بضع ثوان عند مشاهد سرعته 2 Mbps، وملف 360p يبدو سيئاً على تلفزيون كبير باتصال ألياف.
- لا يتكيف أثناء المشاهدة: إذا انخفضت سرعة الاتصال في منتصف الفيلم فلا يوجد ملف أصغر ينتقل إليه المشغل.
- لا يصلح للبث المباشر: لا يوجد ملف كامل أصلاً حتى يحمله المتصفح.
والحل الذي تستخدمه كل المنصات اليوم هو البث المتكيف Adaptive Bitrate Streaming واختصاراً ABR، وفكرته ثلاث خطوات: اضغط الفيديو بعدة جودات، وقسم كل جودة إلى أجزاء Segments قصيرة متساوية، واكتب قائمة Manifest تصف كل ذلك، ثم اترك المشغل يختار لكل جزء الجودة التي يتحملها اتصاله الآن. والصورة التالية تبين الفرق:

HLS وDASH: القائمة والأجزاء
هناك معياران مشهوران للبث المتكيف، وفكرتهما واحدة والفرق في صيغة القائمة:
- HLS اختصار HTTP Live Streaming، من Apple، ونشر في RFC 8216 سنة 2017 كوثيقة معلوماتية Informational. القائمة ملف نصي بامتداد
.m3u8، وهي على مستويين: قائمة رئيسية Master Playlist فيها الجودات المتاحة، ولكل جودة قائمة وسائط Media Playlist فيها أجزاؤها بالترتيب. والأجزاء كانت تقليدياً بحاوية TS، واليوم يمكن أن تكون Fragmented MP4. وأجهزة Apple ومتصفح Safari تشغله مباشرة، وفي المتصفحات الأخرى تستخدم مكتبة مثل hls.js التي تحول أجزاء TS إلى أجزاء MP4 وتسلمها للمتصفح عبر Media Source Extensions. - MPEG-DASH اختصار Dynamic Adaptive Streaming over HTTP، وهو معيار دولي برقم ISO/IEC 23009-1، والقائمة ملف XML بامتداد
.mpdاسمه Media Presentation Description، والأجزاء غالباً MP4 مقسمة، ويشغله في المتصفح مثلاً dash.js من منتدى DASH Industry Forum.
ولأن المعيارين يستخدمان نفس نوع الترميز ونفس فكرة الأجزاء، ظهرت صيغة CMAF برقم ISO/IEC 23000-19، وهي أجزاء MP4 موحدة يمكن أن تشير إليها قائمة HLS وقائمة DASH معاً، فتحفظ نسخة واحدة من الأجزاء بدل نسختين. والمهم هنا أن كل جزء يجب أن يبدأ بإطار مفتاحي، حتى يستطيع المشغل أن يبدأ منه أو ينتقل إليه من جودة أخرى، وهذا هو السبب الذي جعل مواصفات Apple لتأليف HLS تشترط أن يبدأ كل جزء بإطار IDR، وتوصي بإطار مفتاحي كل ثانيتين وبأجزاء مدتها 6 ثوان.

سلم الجودات Bitrate Ladder
سلم الجودات هو قائمة الجودات التي تقدمها، كل درجة فيها دقة ومعدل بت، وتختاره بحيث يجد كل مشاهد درجة تناسب اتصاله وشاشته. ولا يوجد سلم صحيح لكل المحتوى، ولكن مواصفات Apple تعطي مثالاً كنقطة بداية، ومنه أخذنا الدرجات الثلاث للتجربة:
| الدقة | H.264 بالكيلوبت في الثانية | HEVC بالكيلوبت في الثانية |
|---|---|---|
| 416×234 | 145 | |
| 640×360 | 365 | 145 |
| 960×540 | 2000 | 600 إلى 1600 |
| 1280×720 | 3000 و4500 | 2400 و3400 |
| 1920×1080 | 6000 و7800 | 4500 و5800 |
| 3840×2160 | 11600 و16800 |
ولاحظ أن HEVC يصل إلى نفس الدقة بمعدل أقل، كما رأينا في مقارنة الترميزات في المقال السابق، وأن المنصات الكبيرة لا تستخدم سلماً ثابتاً وإنما تحسبه لكل فيديو بحسب صعوبته، فمقطع رسوم متحركة بسيط لا يحتاج ما يحتاجه مقطع رياضي سريع.
كيف يختار المشغل الجودة؟
المشغل في البث المتكيف هو الذي يقرر، وليس السيرفر، فالسيرفر يقدم ملفات ثابتة فقط. وطرق القرار المعروفة ثلاث:
- بحسب السرعة Throughput-Based: يقيس المشغل سرعة تحميل آخر الأجزاء، ويختار أعلى جودة يكون معدلها أقل من السرعة المقدرة بهامش أمان. وهذا ما سوف نراه في سجل hls.js في التجربة.
- بحسب المخزن المؤقت Buffer-Based: يراقب كم ثانية من الفيديو محملة مسبقاً، فإذا امتلأ المخزن رفع الجودة، وإذا قارب الفراغ خفضها قبل أن يتوقف الفيديو.
- المختلطة Hybrid: تجمع الاثنين، وهي ما تستخدمه أغلب المشغلات الحديثة بدرجات مختلفة.
والقرار يحدث على حدود الأجزاء فقط، ولذلك يجب أن تبدأ الأجزاء في كل الجودات في نفس اللحظة وبإطار مفتاحي، فإذا كانت الإطارات المفتاحية في 720p عند الثانية 6 وفي 1080p عند الثانية 8.3، فلن يجد المشغل نقطة نظيفة ينتقل منها.

بروتوكولات الإدخال Ingest: من الكاميرا إلى السيرفر
في البث المباشر يجب أن يصل الفيديو أولاً من برنامج البث، مثل OBS أو ffmpeg أو كاميرا، إلى سيرفر الوسائط Media Server، وهذا الجزء اسمه الإدخال Ingest، وله بروتوكولاته الخاصة لأنه اتصال واحد مستمر بجودة عالية، وليس آلاف المشاهدين:
- RTMP: بروتوكول قديم من أيام Flash يعمل فوق TCP على المنفذ 1935، وما زال أشهر طريقة لإرسال البث إلى المنصات وأسهلها في برامج البث، وحاويته FLV كانت محدودة بـ H.264 وAAC، ثم جاء مشروع Enhanced RTMP فأضاف ترميزات مثل HEVC وAV1 وVP9 وOpus. ومشكلته أن TCP يتوقف لينتظر الحزمة المفقودة، فعلى اتصال سيئ يتراكم التأخير.
- SRT: اختصار Secure Reliable Transport، من Haivision، مفتوح المصدر برخصة MPL-2.0 وموصوف في مسودة IETF، ويعمل فوق UDP ويعيد إرسال الحزم المفقودة بطريقة ARQ داخل نافذة تأخير تحددها أنت، ويدعم التشفير AES، لذلك هو مناسب لإرسال البث عبر الانترنت العام من موقع تصوير إلى السيرفر.
- WHIP: اختصار WebRTC-HTTP Ingestion Protocol، ونشر في RFC 9725 في مارس 2025 كمعيار Standards Track، وهو يجعل إرسال البث عبر WebRTC بسيطاً: طلب HTTP POST واحد يحمل عرض SDP، والرد يحمل الجواب، ثم تمر الوسائط عبر WebRTC العادي. وفائدته أن المتصفح نفسه يستطيع أن يبث دون برنامج، وأن التأخير منخفض من البداية. وقد شرحنا SDP وICE وTURN في مكالمات WebRTC خلف NAT، ولن نكررها هنا.
وبعد الإدخال يقوم السيرفر بثلاثة أعمال: يعيد الترميز Transcode إلى درجات السلم إذا احتاج، ويقسم الفيديو إلى أجزاء ويكتب القوائم Packaging، ويقدم كل ذلك لمن يطلبه، مباشرة أو عبر CDN. والصورة التالية تبين المسار كاملاً:

لماذا يتأخر البث المباشر؟
كل خطوة في المسار أعلاه تضيف وقتاً، ولكن الجزء الأكبر في HLS التقليدي يأتي من قاعدة واحدة: المشغل لا يبدأ من آخر جزء في القائمة. ففي RFC 8216 أن المشغل لا يجب أن يختار جزءاً يبدأ على بعد أقل من ثلاث مدد مستهدفة Target Durations من نهاية القائمة، والسبب أن الجزء التالي قد يتأخر في الوصول، فيحتاج المشغل مخزناً يكفيه حتى لا يتوقف، وبالتالي مع أجزاء 6 ثوان يبدأ المشغل على بعد 18 ثانية على الأقل من الحافة الحية Live Edge، ثم أضف إليها مدة الجزء الذي يجب أن يكتمل قبل أن يكتب في القائمة، ووقت الترميز والشبكة. وهذا يقسم البث إلى مستويات:
- HLS وDASH العاديان: تأخير من عشرات الثواني غالباً، وفي تجربتنا قرابة 23 ثانية، والمقابل أن البث يتحمل الشبكات السيئة ويمر عبر أي CDN كملفات عادية.
- HLS منخفض التأخير Low-Latency HLS: امتداد من Apple يقسم الجزء إلى أجزاء صغيرة Partial Segments، وتوثيق Apple يعطي مثالاً أجزاء جزئية مدتها 200 ملي ثانية داخل أجزاء 6 ثوان، مع طلبات قائمة تنتظر حتى يظهر الجديد Blocking Playlist Reload، والهدف أن يصل التأخير إلى مستوى بث التلفزيون. وفي تجربتنا قرابة 3.5 إلى 4 ثوان.
- WebRTC: لا توجد أجزاء ولا قوائم، وإنما حزم RTP تصل فور ترميزها، والتأخير أقل من ثانية، وفي تجربتنا قرابة ربع ثانية على نفس الجهاز. والمقابل أن توزيعه لآلاف المشاهدين يحتاج سيرفرات خاصة SFU وليس CDN عادية، وهو أغلى بكثير لكل مشاهد.

متى تحتاج كل مستوى؟ محاضرة أو خطبة جمعة أو حفل مسجل لا يضره تأخير 20 ثانية، وHLS العادي هنا أرخص وأثبت. مباراة يتابعها الناس مع تعليقاتهم في وسائل التواصل تحتاج LL-HLS حتى لا يسبق التعليق الهدف. أما المزاد المباشر أو البث التفاعلي الذي يرد فيه المذيع على المشاهدين، أو كاميرا مراقبة تتحكم بها، فتحتاج WebRTC.
دور CDN: البث المتكيف مجرد ملفات
أهم ميزة في HLS وDASH أن الأجزاء والقوائم ملفات HTTP عادية، فتمر عبر أي شبكة توصيل محتوى CDN دون أي إعداد خاص، وقد شرحنا ال Cache وترويسة Cache-Control وHIT وMISS في كيف تعمل شبكة توصيل المحتوى CDN. والقاعدة العملية للبث بسيطة:
- الأجزاء لا تتغير أبداً بعد كتابتها: الجزء
seg004.tsيبقى كما هو، فأعطه مدة تخزين طويلة، وكل مشاهد بعد الأول يأخذه من أقرب نقطة دون أن يصل إلى سيرفرك، وهذا ما يجعل مليون مشاهد ممكناً بسيرفر أصل واحد. - القائمة في البث المباشر تتغير كل جزء: فإما ألا تحفظها أو تحفظها لمدة أقل من مدة الجزء، وإلا رأى المشاهدون قائمة قديمة وتأخر البث أو توقف.
- في الفيديو عند الطلب القائمة ثابتة أيضاً: وفي نهايتها
#EXT-X-ENDLIST، فتحفظ مثل الأجزاء.
حماية المحتوى DRM باختصار
إذا كان محتواك مدفوعاً، مثل أفلام أو دورات، فقد يطلب منك أصحاب الحقوق إدارة الحقوق الرقمية Digital Rights Management واختصاراً DRM، وفكرتها أن الأجزاء مشفرة، والمفتاح لا يعطى للمشغل نفسه، وإنما لوحدة فك مغلقة داخل المتصفح أو الجهاز اسمها Content Decryption Module واختصاراً CDM، بعد أن تسأل سيرفر رخص License Server هل يحق لهذا المستخدم أن يشاهد. وهناك ثلاثة أنظمة تغطي أغلب الأجهزة: Widevine من Google في Chrome وAndroid، وPlayReady من Microsoft في Edge وأجهزة Windows وكثير من أجهزة التلفزيون، وFairPlay Streaming من Apple في Safari وأجهزة Apple.
وحتى لا تشفر الفيديو ثلاث مرات، يأتي معيار التشفير المشترك Common Encryption واختصاراً CENC برقم ISO/IEC 23001-7، فتشفر الأجزاء مرة واحدة ويستطيع كل نظام أن يفكها بمفتاحه من سيرفر رخصه، والمتصفح يربط المشغل بوحدة الفك عبر واجهة Encrypted Media Extensions من W3C. والصورة التالية تبين المسار:

ولاحظ أن DRM يرفع كلفة النسخ ولا يمنعه تماماً، فكل ما يظهر على الشاشة يمكن تصويره، لذلك لا تضفه إلا إذا طلبه أصحاب الحقوق، فهو يضيف سيرفرات ورخصاً وتعقيداً في كل جهاز.
التجربة العملية
سوف نبني التجربة على جزأين: فيديو عند الطلب بسلم من ثلاث جودات نشغله في المتصفح ونخنق اتصاله، ثم بث مباشر نقيس تأخيره بثلاث طرق. ونستخدم نفس Docker Image من تجربة مفاهيم الفيديو، أي lab-video-tools:1 وفيها ffmpeg 9.0.2، ونفس مقطع Artemis II من NASA في الملكية العامة، وMediaMTX بالإصدار 1.21.2 كسيرفر وسائط، وnginx بالإصدار 1.29.8 لتقديم الملفات، وكل المنافذ على 127.0.0.1 فقط.
سلم من ثلاث جودات بأداة ffmpeg
نأخذ دقيقة من المقطع ونضغطها مرة واحدة إلى ثلاث درجات من سلم Apple، ونطلب من ffmpeg أن يكتب أجزاء HLS والقوائم مباشرة. ملف ladder.sh:
#!/bin/sh
# One source, three renditions (Apple's ladder: 360p 365k, 720p 3000k, 1080p 6000k), 6 s HLS segments
cd /work
ffmpeg -hide_banner -v error -y -ss 60 -t 60 -i artemis-src.webm \
-filter_complex "[0:v]split=3[a][b][c];[a]scale=640:360[v360];[b]scale=1280:720[v720];[c]scale=1920:1080[v1080]" \
-map "[v360]" -map "[v720]" -map "[v1080]" -map 0:a -map 0:a -map 0:a \
-c:v libx264 -preset veryfast -profile:v high -g 60 -keyint_min 60 -sc_threshold 0 \
-b:v:0 365k -maxrate:v:0 400k -bufsize:v:0 730k \
-b:v:1 3000k -maxrate:v:1 3300k -bufsize:v:1 6000k \
-b:v:2 6000k -maxrate:v:2 6600k -bufsize:v:2 12000k \
-c:a aac -b:a 128k -ac 2 \
-f hls -hls_time 6 -hls_playlist_type vod -hls_segment_type mpegts \
-hls_segment_filename "web/vod/%v/seg%03d.ts" \
-master_pl_name master.m3u8 \
-var_stream_map "v:0,a:0,name:360p v:1,a:1,name:720p v:2,a:2,name:1080p" \
web/vod/%v/index.m3u8mkdir -p web/vod
docker run --rm --name lab-stream-ladder -v "$PWD:/work" lab-video-tools:1 sh /work/ladder.sh
cat web/vod/master.m3u8 web/vod/720p/index.m3u8 && du -sh web/vod/*/والمخرج سوف يكون كما يلي (اختصرنا أسطر الأجزاء من الرابع إلى التاسع):
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=582468,AVERAGE-BANDWIDTH=441959,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2"
360p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=3550652,AVERAGE-BANDWIDTH=3069101,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2"
720p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=7160652,AVERAGE-BANDWIDTH=6085891,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
1080p/index.m3u8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:6.006000,
seg000.ts
#EXTINF:6.006000,
seg001.ts
#EXTINF:6.006000,
seg002.ts
...
#EXTINF:6.006000,
seg009.ts
#EXT-X-ENDLIST
44M web/vod/1080p/
3.2M web/vod/360p/
22M web/vod/720p/في المخرج أعلاه لاحظ التالي:
- القائمة الرئيسية فيها ثلاثة أسطر
EXT-X-STREAM-INF، ولكل جودةBANDWIDTHوهو أعلى معدل فعلي مع الصوت، وAVERAGE-BANDWIDTHالمتوسط، وCODECSبالصيغة التي يفهمها المتصفح:avc1.64001fتعني H.264 بال Profile High والمستوى 3.1، وmp4a.40.2تعني AAC-LC. والمشغل يقرأ هذا قبل أن يحمل أي جزء، فيستبعد ما لا يستطيع تشغيله. - قائمة 720p فيها
TARGETDURATION:6وعشرة أجزاء، كل منها 6.006 ثانية بالضبط، لأننا وضعنا إطاراً مفتاحياً كل 60 إطاراً بالخيارات-g 60 -keyint_min 60 -sc_threshold 0، و60 إطاراً بمعدل 29.97 تساوي 2.002 ثانية، فكل جزء ثلاث مجموعات GOP كاملة. PLAYLIST-TYPE:VODوEXT-X-ENDLISTتقولان للمشغل إن القائمة كاملة ولن تتغير، وهذا ما سوف يختفي في البث المباشر.- كل الجودات معاً 69 ميغابايت لدقيقة واحدة، أي أن البث المتكيف يكلفك تخزيناً أكثر مقابل أن يجد كل مشاهد ما يناسبه.
نكسر السلم: إطارات مفتاحية في أماكن عشوائية
ماذا يحدث إذا نسيت خيارات الإطارات المفتاحية؟ نعيد نفس الأمر لجودة 360p فقط دون -g و-sc_threshold:
docker run --rm --name lab-stream-broken -v "$PWD:/work" -w /work lab-video-tools:1 sh -c 'ffmpeg -hide_banner -v error -y -ss 60 -t 60 -i artemis-src.webm -vf scale=640:360 -c:v libx264 -preset veryfast -b:v 365k -c:a aac -b:a 128k -f hls -hls_time 6 -hls_playlist_type vod -hls_segment_filename "broken/seg%03d.ts" broken/index.m3u8 && grep -E "TARGET|EXTINF" broken/index.m3u8'والمخرج سوف يكون كما يلي:
#EXT-X-TARGETDURATION:11
#EXTINF:8.341667,
#EXTINF:10.944267,
#EXTINF:5.472133,
#EXTINF:8.341667,
#EXTINF:8.341667,
#EXTINF:8.341667,
#EXTINF:8.341667,
#EXTINF:1.935267,طلبنا أجزاء 6 ثوان فحصلنا على أجزاء بين 1.9 و10.9 ثانية، والسبب أن ffmpeg لا يستطيع قطع الجزء إلا عند إطار مفتاحي، وx264 بإعداده الافتراضي يضع إطاراً مفتاحياً كل 250 إطاراً، أي 8.34 ثانية، وإطاراً إضافياً عند كل تغير للمشهد. والنتيجة ثلاث مشكلات: المدة المستهدفة أصبحت 11 ثانية فزاد تأخير البث المباشر، والأجزاء لا تبدأ في نفس اللحظات في الجودات المختلفة فيصعب الانتقال بينها، ومدة التحميل لكل جزء غير متوقعة. لذلك ثبت الفاصل بين الإطارات المفتاحية دائماً في أي فيديو للبث.
تشغيل السلم في المتصفح مع hls.js
نقدم المجلد web من nginx، مع أنواع الملفات MIME Types الصحيحة للقوائم والأجزاء. ملف nginx.conf:
server {
listen 80;
root /usr/share/nginx/html;
types {
text/html html;
text/javascript js;
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
location /vod/ {
add_header Cache-Control "public, max-age=3600";
}
}ثم صفحة web/index.html، ونحمل hls.js بالإصدار 1.7.3 إلى web/lib بالأمر curl -sfL -o web/lib/hls.min.js https://unpkg.com/[email protected]/dist/hls.min.js:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>HLS ladder</title>
<style>
body { margin: 0; background: #111; color: #eee; font: 16px/1.5 Consolas, monospace; }
video { width: 960px; height: 540px; background: #000; display: block; }
#info { padding: 10px 14px; }
</style>
</head>
<body>
<video id="v" muted autoplay playsinline></video>
<div id="info"></div>
<script src="/lib/hls.min.js"></script>
<script>
const video = document.getElementById('v');
const info = document.getElementById('info');
window.log = [];
const hls = new Hls({ maxBufferLength: 12, maxMaxBufferLength: 12 }); // a short buffer, so the switch shows in a 60 s clip
hls.loadSource('/vod/master.m3u8');
hls.attachMedia(video);
const t0 = performance.now();
hls.on(Hls.Events.FRAG_LOADED, (e, d) => {
const f = d.frag;
window.log.push(`t=${((performance.now() - t0) / 1000).toFixed(1)}s ${hls.levels[f.level].height}p seg${String(f.sn).padStart(3, '0')} estimate ${Math.round(hls.bandwidthEstimate / 1000)} kbps`);
});
setInterval(() => {
const l = hls.levels[hls.currentLevel];
const buf = video.buffered.length ? video.buffered.end(video.buffered.length - 1) - video.currentTime : 0;
info.textContent = `level: ${l ? l.height + 'p' : '-'} | bandwidth estimate: ${Math.round(hls.bandwidthEstimate / 1000)} kbps | buffer: ${buf.toFixed(1)} s`;
}, 250);
</script>
</body>
</html>وملف compose.yaml، وفيه سيرفر الوسائط الذي سوف نستخدمه في الجزء الثاني:
name: lab-stream
services:
web:
image: nginx:1.29.8-alpine
container_name: lab-stream-web
volumes:
- ./web:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "127.0.0.1:18771:80"
mediamtx:
image: bluenviron/mediamtx:1.21.2
container_name: lab-stream-mtx
environment:
MTX_HLSVARIANT: ${HLS_VARIANT:-lowLatency} # lowLatency (default) or mpegts
MTX_HLSSEGMENTDURATION: ${HLS_SEGMENT:-1s}
MTX_WEBRTCLOCALUDPADDRESS: ":18189"
MTX_WEBRTCIPSFROMINTERFACES: "false"
MTX_WEBRTCADDITIONALHOSTS: "127.0.0.1"
ports:
- "127.0.0.1:19350:1935" # RTMP ingest
- "127.0.0.1:18890:8890/udp" # SRT ingest
- "127.0.0.1:18888:8888" # HLS out
- "127.0.0.1:18889:8889" # WebRTC out (WHEP) and WHIP in
- "127.0.0.1:18189:18189/udp" # WebRTC mediadocker compose up -d
curl -s -D - -o /dev/null http://127.0.0.1:18771/vod/master.m3u8HTTP/1.1 200 OK
Server: nginx/1.29.8
Date: Fri, 09 Oct 2026 00:49:45 GMT
Content-Type: application/vnd.apple.mpegurl
Content-Length: 414
Last-Modified: Fri, 09 Oct 2026 00:49:06 GMT
Connection: keep-alive
ETag: "6ac83a02-19e"
Cache-Control: public, max-age=3600
Accept-Ranges: bytesالآن افتح http://127.0.0.1:18771، وبعد ثوان قليلة سوف ترى الجودة 1080p، والصورة التالية من المتصفح بعد 12 ثانية:

ثم خنقنا الاتصال إلى 1.5 ميغابت في الثانية من أدوات المطور في Chrome، أي من تبويب Network واختيار سرعة مخصصة، وهو نفس ما فعلناه في التجربة من بروتوكول أدوات المطور بالأمر Network.emulateNetworkConditions، وتركنا الفيديو يكمل، ثم قرأنا السجل من Console بالأمر window.log.join('\n'):
t=0.2s 360p seg000 estimate 582 kbps
t=0.5s 1080p seg001 estimate 19073 kbps
t=0.8s 1080p seg002 estimate 110434 kbps
t=7.3s 1080p seg003 estimate 141783 kbps
t=17.6s 360p seg004 estimate 582 kbps
t=20.5s 360p seg005 estimate 1077 kbps
t=26.7s 360p seg006 estimate 1167 kbps
t=32.4s 360p seg007 estimate 1268 kbps
t=38.5s 360p seg008 estimate 1308 kbps
t=44.6s 360p seg009 estimate 1315 kbps
في السجل أعلاه لاحظ التالي:
- الجزء الأول دائماً بجودة 360p، لأنها أول جودة في القائمة الرئيسية ولا يعرف المشغل سرعة الاتصال بعد، ثم قاس السرعة من الجزء الأول وقفز مباشرة إلى 1080p.
- الأجزاء 1 إلى 3 حملت بسرعة لأن المخزن كان فارغاً، ثم توقف المشغل عن التحميل حتى يقل المخزن عن 12 ثانية، وهو الحد الذي وضعناه بالخيار
maxBufferLength. والقيمة الافتراضية في hls.js هي 30 ثانية، وصغرناها حتى يظهر الانتقال في مقطع مدته دقيقة. - بعد الخنق بدأ تحميل الجزء الرابع بجودة 1080p ولم يكتمل، فانتقل المشغل إلى 360p ونزل تقديره إلى 582، ثم بدأ يرتفع ببطء مع كل جزء حتى 1315 kbps، وهو أقل من 3550 kbps التي تحتاجها 720p في القائمة الرئيسية، لذلك بقي على 360p.
- لم يتوقف الفيديو أثناء الانتقال، لأن المخزن كان فيه ثوان كافية، وهذا هو الهدف كله: أن تنخفض الجودة بدل أن تظهر دائرة التحميل.
ونفس السلم يمكن أن يكتب كقائمة DASH، فنفس ffmpeg يكتب ملف manifest.mpd بالخيار -f dash (ملف dash.sh بدرجتين وصوت)، وهذا جزء القائمة الذي يصف درجات الصورة (اختصرنا الترويسة وبعض السمات):
#!/bin/sh
# The same source as a DASH presentation: two video representations and one audio, 6 s segments
cd /work
ffmpeg -hide_banner -v error -y -ss 60 -t 60 -i artemis-src.webm \
-filter_complex "[0:v]split=2[a][b];[a]scale=640:360[v360];[b]scale=1280:720[v720]" \
-map "[v360]" -map "[v720]" -map 0:a \
-c:v libx264 -preset veryfast -g 60 -keyint_min 60 -sc_threshold 0 \
-b:v:0 365k -b:v:1 3000k -c:a aac -b:a 128k \
-f dash -seg_duration 6 -use_template 1 -use_timeline 0 \
-adaptation_sets "id=0,streams=v id=1,streams=a" web/dash/manifest.mpd<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" profiles="urn:mpeg:dash:profile:isoff-live:2011" type="static"
mediaPresentationDuration="PT1M0.0S" maxSegmentDuration="PT6.0S" minBufferTime="PT12.0S">
<Period id="0" start="PT0.0S">
<AdaptationSet id="0" contentType="video" startWithSAP="1" segmentAlignment="true" bitstreamSwitching="true" frameRate="30000/1001" maxWidth="1280" maxHeight="720" par="16:9">
<Representation id="0" mimeType="video/mp4" codecs="avc1.64001e" bandwidth="365000" width="640" height="360" sar="1:1">
<SegmentTemplate timescale="1000000" duration="6000000" initialization="init-stream$RepresentationID$.m4s" media="chunk-stream$RepresentationID$-$Number%05d$.m4s" startNumber="1">
</SegmentTemplate>
</Representation>
<Representation id="1" mimeType="video/mp4" codecs="avc1.64001f" bandwidth="3000000" width="1280" height="720" sar="1:1">
...الفكرة نفسها بأسماء أخرى: AdaptationSet مجموعة من الدرجات يمكن الانتقال بينها، وRepresentation هي الدرجة، وSegmentTemplate يصف أسماء الأجزاء بقالب بدل أن يكتبها كلها، وstartWithSAP="1" وsegmentAlignment="true" تعنيان أن كل جزء يبدأ بنقطة دخول نظيفة وأن الأجزاء متطابقة الحدود بين الدرجات، أي نفس شرط الإطارات المفتاحية الذي كسرناه قبل قليل. والأجزاء هنا .m4s، أي MP4 مقسمة وليس TS.
بث مباشر وقياس التأخير من الكاميرا إلى الشاشة
الآن نحتاج مصدر بث حي، ونحتاج طريقة نقيس بها التأخير، والفكرة بسيطة: نطبع ساعة الجهاز بالملي ثانية على كل إطار عند الإرسال، ونعرض في الصفحة ساعة المتصفح بجانب الفيديو، فالفرق بينهما في نفس لقطة الشاشة هو التأخير من الإرسال إلى العرض، والجهاز واحد فالساعتان واحدة. ملف clock.txt فيه نص الساعة بصيغة drawtext:
%{localtime:%X.%3N}وملف publish.sh:
#!/bin/sh
# A live test source: moving pattern, a tone, and the wall clock (with milliseconds) burned into every frame
# usage: publish.sh <output url> <format>
exec ffmpeg -hide_banner -v error -re \
-f lavfi -i testsrc2=size=1280x720:rate=30 -f lavfi -i sine=frequency=440:sample_rate=48000 \
-vf "drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf:textfile=/work/clock.txt:fontsize=72:fontcolor=white:box=1:[email protected]:boxborderw=16:x=(w-tw)/2:y=h-th-60" \
-c:v libx264 -preset veryfast -tune zerolatency -g 30 -keyint_min 30 -sc_threshold 0 -b:v 2500k -maxrate 2500k -bufsize 2500k \
-c:a aac -b:a 128k -f "$2" "$1"في الإعداد أعلاه لاحظ التالي:
-reيجعل ffmpeg يرسل بسرعة الوقت الحقيقي كما تفعل الكاميرا، وليس بأقصى سرعة.-tune zerolatencyيلغي إطارات B والتجميع المسبق في x264، حتى لا ينتظر المرمز إطارات قادمة قبل أن يرسل الحالي.-g 30إطار مفتاحي كل ثانية، لأن MediaMTX يقطع الأجزاء عند الإطارات المفتاحية كما رأينا.- إذا كتبت الوقت بالصيغة
%H\:%M\:%Sداخل الملف فسوف يرفضها drawtext بخطأ ينتهي بعبارةis not known، لأن النقطتين تفصلان معاملات الدالة، والصيغة%Xتعطي نفس الساعة دون نقطتين.
نبدأ بالإرسال عبر RTMP إلى MediaMTX على شبكة ال Compose:
docker run -d --name lab-stream-pub --network lab-stream_default -v "$PWD:/work" lab-video-tools:1 \
sh /work/publish.sh rtmp://lab-stream-mtx:1935/live flv
docker logs lab-stream-mtx 2>&1 | tail -32026/10/09 01:00:01 INF [RTMP] [conn 192.168.144.4:54900] opened
2026/10/09 01:00:02 INF [path live] stream is available and online, 2 tracks (H264, MPEG-4 Audio)
2026/10/09 01:00:02 INF [RTMP] [conn 192.168.144.4:54900] is publishing to path 'live'وللإرسال عبر SRT بدل RTMP نغير الرابط والحاوية فقط، فالحاوية في SRT هي TS، وstreamid=publish:live هي الطريقة التي يعرف بها MediaMTX أن هذا الاتصال يرسل إلى المسار live:
docker rm -f lab-stream-pub
docker run -d --name lab-stream-pub --network lab-stream_default -v "$PWD:/work" lab-video-tools:1 \
sh /work/publish.sh "srt://lab-stream-mtx:8890?streamid=publish:live&pkt_size=1316" mpegts
docker logs lab-stream-mtx 2>&1 | grep -E "SRT|path live" | tail -32026/10/09 01:09:54 INF [SRT] [conn 192.168.144.4:32794] opened
2026/10/09 01:09:55 INF [path live] stream is available and online, 2 tracks (H264, MPEG-4 Audio)
2026/10/09 01:09:55 INF [SRT] [conn 192.168.144.4:32794] is publishing to path 'live'وMediaMTX يحول نفس البث تلقائياً إلى HLS على المنفذ 18888 وإلى WebRTC على المنفذ 18889، وكلاهما مع صفحة مشغل جاهزة على المسار /live. ولنقرأ قائمة HLS الحية بأداة curl نحتاج أن نحفظ Cookie، لأن MediaMTX في هذا الإصدار يرد على الطلب الأول بإعادة توجيه 302 إلى ?cookieCheck=1:
curl -s -L -c cj.txt -b cj.txt http://127.0.0.1:18888/live/index.m3u8
curl -s -L -c cj.txt -b cj.txt http://127.0.0.1:18888/live/main_stream.m3u8 | head -11والمخرج سوف يكون كما يلي، وهو مع HLS_VARIANT=mpegts HLS_SEGMENT=6s، أي HLS التقليدي بأجزاء 6 ثوان:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=2765104,AVERAGE-BANDWIDTH=2765104,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=1280x720,FRAME-RATE=30.000
main_stream.m3u8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-ALLOW-CACHE:NO
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PROGRAM-DATE-TIME:2026-10-09T01:07:07.89Z
#EXTINF:6.00000,
c40fb9e6d7ce_main_seg0.ts
#EXT-X-PROGRAM-DATE-TIME:2026-10-09T01:07:13.89Z
#EXTINF:6.00000,
c40fb9e6d7ce_main_seg1.tsلاحظ الفرق عن قائمة الفيديو عند الطلب: لا توجد EXT-X-ENDLIST لأن البث لم ينته، وMEDIA-SEQUENCE يزيد كلما حذف السيرفر جزءاً قديماً من أول القائمة، وPROGRAM-DATE-TIME يربط كل جزء بوقت حقيقي، وALLOW-CACHE:NO تذكير بأن هذه القائمة تتغير ولا تحفظ.
ثم صفحة القياس web/live.html، وهي تعرض صفحة مشغل MediaMTX داخل إطار، وتحتها ساعة المتصفح:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Glass to glass</title>
<style>
body { margin: 0; background: #111; color: #eee; font: 16px Consolas, monospace; }
iframe { width: 960px; height: 540px; border: 0; display: block; }
#clock { font: bold 54px "DejaVu Sans Mono", Consolas, monospace; padding: 8px 20px; }
#clock small { font-size: 20px; color: #aaa; }
</style>
</head>
<body>
<!-- the player page from MediaMTX: ?src=http://127.0.0.1:18888/live (HLS) or http://127.0.0.1:18889/live (WebRTC) -->
<iframe id="player" allow="autoplay"></iframe>
<div id="clock"></div>
<script>
const src = new URLSearchParams(location.search).get('src');
document.getElementById('player').src = src;
const clock = document.getElementById('clock');
const tick = () => {
clock.innerHTML = new Date().toISOString().slice(11, 23) + ' <small>browser clock (UTC)</small>';
requestAnimationFrame(tick);
};
tick();
</script>
</body>
</html>وفتحنا الصفحة ثلاث مرات مع الإرسال عبر SRT في كل مرة: مرة مع HLS التقليدي بأجزاء 6 ثوان، ومرة مع LL-HLS الذي يأتي افتراضياً في MediaMTX بأجزاء ثانية وأجزاء جزئية 200 ملي ثانية، ومرة مع WebRTC، وفي كل مرة انتظرنا 20 ثانية ثم أخذنا ثلاث لقطات بفاصل 4 ثوان. والصورة التالية لقطة كاملة من WebRTC:

والصورة السابقة كانت أول لقطة بعد الاتصال عبر RTMP، والفرق فيها 1.29 ثانية، ثم نزل إلى 1.01 ثم 0.50 في اللقطتين التاليتين لأن مخزن WebRTC يتقلص بعد أن يستقر الاتصال. أما مع SRT فكانت النتائج كما في الصورة التالية، وهي الجزء السفلي من اللقطة الأولى لكل طريقة:

والأرقام من اللقطات الثلاث لكل طريقة:
| طريقة المشاهدة | اللقطة 1 | اللقطة 2 | اللقطة 3 |
|---|---|---|---|
| HLS بأجزاء 6 ثوان | 22.933 ث | 23.005 ث | 23.014 ث |
| LL-HLS بأجزاء ثانية | 3.514 ث | 3.695 ث | 3.961 ث |
| WebRTC | 0.249 ث | 0.268 ث | 0.256 ث |
في النتائج أعلاه لاحظ التالي:
- HLS التقليدي تأخر قرابة 23 ثانية، وهذا يطابق القاعدة: ثلاث مدد مستهدفة من 6 ثوان هي 18 ثانية، ثم الجزء الذي يجب أن يكتمل قبل أن يظهر في القائمة، وبقية الثواني في التحميل والمخزن.
- هذا الرقم كان بعد أن عمل البث أكثر من دقيقة وامتلأت القائمة، وعندما فتحنا الصفحة في أول 20 ثانية من بدء البث كان التأخير 7.4 ثانية، ثم 9.3 في محاولة أخرى، لأن القائمة لم يكن فيها إلا جزء أو جزءان فبدأ المشغل من أقرب ما وجد، ثم يبقى على هذا البعد ولا يقترب من الحافة الحية. لذلك قس التأخير دائماً بعد أن يستقر البث.
- LL-HLS نزل بالتأخير إلى 3.5 أو 4 ثوان دون تغيير في الإرسال، والفرق كله في طريقة التقسيم والقائمة، وفي محاولة سابقة عبر RTMP تراوح بين 1.2 و5 ثوان، أي أنه أقل ثباتاً من HLS التقليدي.
- WebRTC كان ربع ثانية، ولكن تذكر أن السيرفر والمتصفح على نفس الجهاز، فعلى الانترنت سوف يضاف وقت الشبكة، وما زال أقل من ثانية في أغلب الحالات.
- التأخير من RTMP أو SRT نفسه صغير هنا لأن الإرسال داخل نفس الجهاز، وفائدة SRT تظهر على اتصال حقيقي يفقد حزماً، حيث يعيد إرسالها دون أن يتوقف كما يفعل TCP في RTMP.
تنظيف التجربة
نحذف مصدر البث بالاسم، ثم Containers ال Compose وشبكتها:
docker rm -f lab-stream-pub
docker compose downثم ال Images والملفات إذا لم تعد تحتاجها:
docker image rm bluenviron/mediamtx:1.21.2 nginx:1.29.8-alpine
rm -rf web broken artemis-src.webm cj.txtأخطاء شائعة
- إطارات مفتاحية غير منتظمة في البث: أجزاء بأطوال عشوائية وتأخير أكبر وانتقال سيئ بين الجودات، والحل
-gبقيمة ثابتة مع-sc_threshold 0في كل الدرجات. - حفظ القائمة الحية في ال Cache: المشاهدون يرون قائمة قديمة فيتوقف البث أو يتأخر، فأعط القوائم الحية مدة قصيرة جداً أو لا تحفظها، وأعط الأجزاء مدة طويلة.
- نوع الملف الخاطئ: بعض المشغلات ترفض
.m3u8إذا وصل بالنوعtext/plain، فحددapplication/vnd.apple.mpegurlكما في إعداد nginx أعلاه. - نسيان CORS: إذا كانت الأجزاء على دومين مختلف عن الصفحة، مثل CDN أو تخزين كائنات، فيجب أن يرد بترويسة
Access-Control-Allow-Origin، وإلا رفض المتصفح الطلبات من hls.js. - اختيار WebRTC لكل شيء: التأخير المنخفض جميل، ولكنه لا يمر عبر CDN عادية، فإذا كان المحتوى محاضرة لا تفاعل فيها فـ HLS أرخص وأثبت.
- قياس التأخير في أول ثوان البث: كما رأينا، الرقم يتغير حتى تمتلئ القائمة.
تدريب عملي
- أضف إلى السلم درجة رابعة 960×540 بمعدل 2000 kbps، ثم خنق الاتصال إلى 3 ميغابت بدل 1.5 وانظر أي درجة يختار المشغل.
- أرسل البث من OBS Studio بدل ffmpeg إلى
rtmp://127.0.0.1:19350/live، ثم قس التأخير بنفس الصفحة مع LL-HLS وWebRTC، وقارنه بما رأيناه هنا.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- التحميل التدريجي يعطي كل المشاهدين نفس الملف، والبث المتكيف يقسم الفيديو إلى أجزاء بعدة جودات ويترك المشغل يختار لكل جزء ما يتحمله الاتصال.
- HLS بقوائم
.m3u8وDASH بقوائم.mpdفكرتهما واحدة، وCMAF يجعلهما يشتركان في نفس الأجزاء. - كل جزء يجب أن يبدأ بإطار مفتاحي وفي نفس اللحظة في كل الدرجات، وإلا خرجت الأجزاء بأطوال عشوائية كما رأينا من 1.9 إلى 10.9 ثانية.
- الإدخال عبر RTMP أو SRT أو WHIP، والتوزيع عبر HLS وDASH وCDN لأنها ملفات HTTP عادية، أو عبر WebRTC للتأخير المنخفض.
- في تجربتنا: HLS بأجزاء 6 ثوان تأخر 23 ثانية، وLL-HLS قرابة 3.5 إلى 4 ثوان، وWebRTC ربع ثانية، وكل خطوة أقل في التأخير أغلى في التوزيع.
- DRM يشفر الأجزاء مرة واحدة بمعيار CENC، ويفكها Widevine أو PlayReady أو FairPlay داخل الجهاز، ولا تضفه إلا إذا طلبه أصحاب الحقوق.
وفي مقالات قادمة بإذن الله نبني على هذه المفاهيم منصة فيديو كاملة مثل Netflix، وتطبيق بث مباشر، وتطبيق فيديو قصير.
دليلمفاهيم الفيديو: الحاوية والترميز Codec ومعدل البتما الذي يحدد حجم ملف الفيديو وجودته، ولماذا يعمل ملف في متصفح ولا يعمل في آخر، مع تجربة حقيقية تقيس الفرق بين الترميزات.
دليلكيف تعمل شبكة توصيل المحتوى CDN وما الذي تحفظهلماذا يبطؤ موقعك على الزائر البعيد، وكيف تقرب شبكة CDN ملفاتك منه، وكيف تمنعها من حفظ الصفحات الخاصة.
دليلمكالمات WebRTC خلف NAT: شرح STUN وTURN وICEلماذا تتصل المكالمة ولا يصل الصوت، وكيف تحل STUN وTURN المشكلة دون أن تفتح باباً إلى شبكتك الداخلية.
دليلHTTP وHTTPS ببساطة: ماذا يحدث بين الرابط والصفحةماذا يحدث بين كتابة الرابط وظهور الصفحة، وكيف تقرأ الطلب والرد ورموز مثل 502 و504، ولماذا يكلف الاتصال الجديد أكثر.
دليلكيف تبني منصة فيديو عند الطلب مثل Netflixكيف يتحول فيديو مرفوع إلى بث يناسب كل شاشة وسرعة اتصال، وما الذي تحتاجه منصتك من التحويل إلى التوصيل والتكلفة.
دليلكيف تبني تطبيق بث مباشر مثل Jaco وTwitchكيف يصل بث المذيع إلى آلاف المشاهدين بتأخير ثانية أو ثانيتين، ومعه دردشة وهدايا لا تتوقف عند الذروة.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة بأداة ffmpeg 9.0.2 وMediaMTX 1.21.2 وnginx 1.29.8 وhls.js 1.7.3 وChrome بلا واجهة Headless، وDocker Engine 29.8.0 على Windows 11 مع Docker Desktop.