الأدلة التقنية

كيف تبني منصة فيديو عند الطلب مثل Netflix

كيف يتحول فيديو مرفوع إلى بث يناسب كل شاشة وسرعة اتصال، وما الذي تحتاجه منصتك من التحويل إلى التوصيل والتكلفة.

كيف تبني منصة فيديو عند الطلب مثل Netflix

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

وهذا المقال من قسم الفيديو والبث المباشر في خارطة الطريق، ومفاهيم الفيديو الأساسية مثل الحاوية Container والترميز Codec ومعدل البت Bitrate، وطريقة عمل HLS وDASH من جهة المشغل، لها مقالاتها المستقلة في نفس القسم، فلن نكررها هنا وسوف نركز على بناء المنصة نفسها.

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

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

  • ما الذي تبنيه بالضبط، وتقدير سريع للتخزين ونقل البيانات قبل أي سطر كود.
  • المعمارية الكاملة للمنصة: المكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو، وماذا يحدث عندما يتعطل كل جزء.
  • الرفع Upload الذي لا يمر عبر التطبيق، والتحويل Transcoding إلى سلم جودات Bitrate Ladder، ولماذا انتقلت Netflix من سلم ثابت إلى سلم لكل فيديو Per-Title Encoding ثم إلى ترميز كل لقطة Shot-based Encoding.
  • التجزئة Packaging إلى HLS وDASH بنسخة واحدة من المقاطع CMAF، والتشفير DRM وما يحميه فعلاً.
  • التخزين والتوصيل، وكيف بنت Netflix شبكتها Open Connect داخل شبكات مزودي الإنترنت، وتكلفة نقل البيانات Egress التي تحدد ربح المنصة أو خسارتها.
  • الكتالوج والبحث والتوصيات، وما تعلمته Netflix من مسابقة Netflix Prize، وتحليلات التشغيل وجودة التجربة QoE.
  • منصة MVP بأدوات مفتوحة المصدر، وPeerTube كخيار جاهز.
  • تجربة عملية: رفع ملف، وطابور، واثنان من ال Workers يصنعان أربع جودات بمقاطع CMAF على تخزين Garage، ثم التشغيل في المتصفح مع تبديل الجودة عند بطء الشبكة.

ما الذي تبنيه بالضبط؟

منصة الفيديو عند الطلب Video on Demand، واختصاراً VOD، هي منصة تعرض فيديوهات مسجلة مسبقاً يشاهدها كل شخص في الوقت الذي يريده، وهذا يختلف عن البث المباشر Live الذي شرحناه في مقال تطبيق البث المباشر، فالفيديو هنا يرفع مرة واحدة ويعالج مرة واحدة ثم يشاهد آلاف المرات، وهذا يعني أن كل ثانية تصرفها في المعالجة تتوزع على كل المشاهدات، وكل بايت توفره في الملف يتوفر مع كل مشاهدة. ولذلك فالمنصة في الحقيقة نظامان منفصلان: نظام معالجة يعمل مرة لكل رفع ويمكن أن يتأخر دقائق دون أن يشعر أحد، ونظام توصيل يعمل مع كل تشغيل ويجب أن يرد في أجزاء من الثانية، والصورة التالية تبين هذين النظامين:

في الأعلى صف المعالجة: صانع المحتوى يرفع الملف إلى واجهة رفع، ثم تخزين الكائنات للملفات الأصلية، ثم طابور مهام، ثم مجموعة Encoders تعمل ب ffmpeg وتخرج سلم جودات 1080p و720p و480p و360p، ثم Packager يجزئ ويشفر، ثم تخزين المقاطع وملفات التشغيل. وفي الأسفل صف التوصيل: ال CDN القريب من المشاهد يخدم المشغلات على التلفاز والهاتف، والمشغل يرسل أحداث التشغيل إلى التحليلات، ويقرأ من الكتالوج والبحث والتوصيات، ويطلب المفاتيح من خادم التراخيص
نظامان في منصة واحدة: معالجة مرة لكل رفع، وتوصيل مع كل تشغيل

وقبل أن تختار أي أداة قم بتقدير سريع للأرقام، وهي طريقة شرحناها في مدخل إلى تصميم الأنظمة. لنفرض أن لديك منصة تعليمية فيها 2,000 ساعة فيديو، ويشاهد فيها الطلاب 50,000 ساعة في الشهر، فسوف تجد من التجربة العملية في آخر المقال أن 30 ثانية من فيديو 1080p صارت بعد التحويل إلى أربع جودات 32.88 ميجابايت، أي قرابة 3.9 جيجابايت لكل ساعة فيديو بكل جوداتها، وهذا يعني قرابة 8 تيرابايت لمكتبة من 2,000 ساعة، وهي مساحة يمكن أن تحملها أقراص سيرفر واحد. أما النقل فهو الرقم الذي يفاجئ الناس: أعلى جودة في التجربة معدلها قرابة 4.7 ميجابت في الثانية، وساعة واحدة بهذا المعدل تساوي 4.7 × 3600 ÷ 8 أي قرابة 2.1 جيجابايت، فإذا شاهد الطلاب نصف ساعاتهم بأعلى جودة فقط فأنت تنقل أكثر من 50 تيرابايت في الشهر، أي أكثر من ستة أضعاف المكتبة كلها، وفي كل شهر من جديد. ولذلك فالسؤال الأول في منصات الفيديو ليس «أين أخزن الملفات؟» وإنما «من أين يخرج كل هذا البايت، وكم أدفع عليه؟»، وسوف نعود إليه في قسم التكلفة.

المعمارية الكاملة: كيف تتصل الأجزاء ببعضها؟

قبل أن ندخل في تفاصيل الرفع والتحويل والتوصيل، لنضع المنصة كلها في صورة واحدة، لأن كل قسم بعد ذلك يشرح جزءاً منها، ومن دون الصورة الكاملة يصعب أن تعرف أين يقف كل جزء، ومن يتصل بمن، وأي طلب ينتظر الرد وأي طلب يذهب إلى طابور ويكمل وحده. والصورة التالية تبين المعمارية في خمسة أعمدة: الكلاينت Clients، ثم الحافة Edge، ثم الخدمات Services، ثم البيانات Data، ثم ال Workers والمزودين الخارجيين:

خمسة أعمدة: في الكلاينت تطبيق صانع المحتوى والمشغلات على الويب والتلفاز والهاتف، وفي الحافة بوابة API للشهادات والتحقق والحدود، وخدمة رخص DRM خارجية، ونقطة CDN. وفي الخدمات واجهة الرفع وواجهة الكتالوج والبحث وواجهة التشغيل وجامع الأحداث. وفي البيانات Bucket الملفات الأصلية وطابور المهام في Valkey وفهرس البحث في OpenSearch وقاعدة الكتالوج في PostgreSQL وسجل الأحداث في Kafka وBucket المقاطع. وفي العمود الأخير ال Encoders مع ال Packager، ومهمة التوصيات الليلية، والتحليلات في ClickHouse. الأسهم الرمادية طلبات متزامنة، والبنفسجية المتقطعة مهام وأحداث غير متزامنة، والخضراء المقاطع من ال CDN، والبرتقالية الرفع المباشر إلى التخزين وطلب الرخصة
البايتات لا تمر بالخدمات: الرفع يذهب إلى التخزين مباشرة، والمقاطع تخرج من ال CDN، والبطيء كله خلف طابور

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

المكونات ومسؤولية كل منها

المكونمسؤوليتهأداة مفتوحة المصدر مقترحةلماذا هي
المشغل Playerتشغيل HLS وDASH، واختيار الجودة مع كل مقطع، وإرسال أحداث التشغيلhls.js وShaka Player (Apache-2.0)الأول خفيف ل HLS كما في التجربة، والثاني يضيف DASH وDRM
بوابة ال API Gatewayشهادات TLS، والتحقق من التوكن، وحدود الطلبات Rate Limiting، وتوجيه كل مسار إلى خدمتهTraefik (MIT)يصدر شهادات Let's Encrypt تلقائياً، وفيه حدود الطلبات جاهزة دون كود
الهوية Identityتسجيل الدخول، وإصدار التوكن الذي تتحقق منه البوابة والخدماتKeycloak (Apache-2.0)يطبق OpenID Connect كاملاً، فلا تكتب تسجيل الدخول بنفسك
واجهة الرفع Upload APIتنشئ سجل الفيديو، وتصدر روابط الرفع، وتضع المهمة في الطابور بعد اكتمال الرفعكودك، ومعه tusd (MIT) إذا اخترت بروتوكول tusالرفع القابل للاستئناف جاهز بدل أن تكتبه
تخزين الكائنات Object Storageالملفات الأصلية في uploads، والمقاطع وملفات التشغيل في vodGarage (AGPL-3.0)متوافق مع S3، ويعمل على أجهزة عادية، ويخدم ال Bucket كموقع ثابت كما في التجربة
طابور المهام Job Queueمهام التحويل، مع قائمة المهام قيد التنفيذ وقائمة المهام الميتةValkey (BSD-3-Clause)القوائم والأمر BLMOVE تكفي لطابور لا يضيع المهام، وهو نفسه في التجربة
ال Workersالتحويل إلى سلم الجودات، ثم التجزئة إلى CMAF مع HLS وDASHFFmpeg وShaka Packagerالمعيار الفعلي للتحويل، وال Packager يكتب ملفي التشغيل من نفس المقاطع
قاعدة الكتالوج Catalog DBالفيديوهات وحالتها، والتوفر بحسب الدولة، والاشتراكات، وموضع المشاهدةPostgreSQL (PostgreSQL License)معاملات وقيود تمنع البيانات الخاطئة، وتكفي منصة متوسطة لسنوات
البحث Searchالبحث في العناوين والأوصاف مع أخطاء الكتابة العربيةOpenSearch (Apache-2.0)فيه محلل للعربية، وقد شرحناه في مقال البحث في النصوص العربية
شبكة التوصيل CDNحفظ المقاطع قريباً من المشاهد، وحماية التخزين من الطلبات المكررةnginx (BSD-2-Clause) كنقطة Cache، ثم CDN تجارية عند النموالمقاطع ملفات ثابتة لا تتغير، وأي Cache يحفظها بسهولة
رخص DRMإصدار مفتاح فك التشفير للجهاز المسموح لهلا توجد أداة مفتوحة تغني عن الاتفاق مع Google وApple وMicrosoft، فهي خدمة تجاريةلذلك رسمناها خارجية بإطار متقطع، ولا تضفها إلا إذا طلبها العقد
سجل الأحداث Event Streamاستقبال أحداث التشغيل بكميات كبيرة وحفظها حتى تقرأها التحليلاتApache Kafka (Apache-2.0)الأحداث تحفظ ويقرؤها أكثر من مستهلك، كما شرحنا في الطابور أم بث الأحداث
التحليلات Analyticsزمن البدء والتوقف للتحميل ومعدل البت بحسب الدولة والمزود والجهازClickHouse (Apache-2.0)قاعدة عمودية Columnar تجمع ملايين الأحداث في ثوان
مهمة التوصياتحساب صفوف الصفحة الرئيسية لكل مستخدم مرة في الليلةكودك بلغة SQL على ClickHouse وPostgreSQLالأكثر مشاهدة وأكمل ما بدأته لا تحتاج نموذجاً كما في قسم التوصيات

نموذج البيانات

في التجربة العملية كان سجل الفيديو Hash في Valkey اسمه video:<id>، وحالته تنتقل من queued إلى processing ثم ready أو retrying أو failed، والمهام في ثلاث قوائم queue:transcode وqueue:processing وqueue:dead، والملفات في uploads/<id>/source وvod/<id>/. وفي الإنتاج ينتقل سجل الفيديو إلى PostgreSQL بنفس الحقول والحالات، ويبقى Valkey للطابور فقط، والسبب أن الكتالوج يحتاج استعلامات وقيوداً ونسخاً احتياطية لا يقدمها Hash في الذاكرة:

-- Catalog DB (PostgreSQL): the lab's video:<id> hash becomes a row, with the same statuses
CREATE TABLE videos (
  id            text PRIMARY KEY,              -- 12 hex chars, as in the lab
  upload_key    text UNIQUE NOT NULL,          -- Idempotency-Key sent by the creator app
  title         text NOT NULL,
  status        text NOT NULL CHECK (status IN ('uploading','queued','processing','retrying','ready','failed')),
  source_key    text NOT NULL,                 -- uploads/<id>/source
  hls_path      text,                          -- /v/<id>/master.m3u8
  dash_path     text,                          -- /v/<id>/manifest.mpd
  renditions    text,                          -- '1080p,720p,480p,360p'
  duration_s    numeric(8,1),
  attempts      int NOT NULL DEFAULT 0,
  error         text,
  created_at    timestamptz NOT NULL DEFAULT now(),
  ready_at      timestamptz
);
CREATE TABLE availability (                    -- licence windows: where and when a title may be shown
  video_id   text REFERENCES videos(id),
  country    char(2) NOT NULL,
  starts_at  timestamptz NOT NULL,
  ends_at    timestamptz,
  PRIMARY KEY (video_id, country, starts_at)
);
CREATE TABLE subscriptions (                   -- users themselves live in Keycloak
  user_id     uuid PRIMARY KEY,
  plan        text NOT NULL,
  max_height  int NOT NULL,                    -- 720 or 1080: the highest rendition the plan allows
  valid_until timestamptz NOT NULL
);
CREATE TABLE watch_progress (                  -- "continue watching"
  user_id    uuid,
  video_id   text REFERENCES videos(id),
  position_s int NOT NULL,
  updated_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, video_id)
);
CREATE TABLE home_rows (                       -- written every night by the recommendations job
  user_id  uuid,
  row_name text,                               -- 'continue', 'top_week', 'because_you_watched'
  position int,
  video_id text REFERENCES videos(id),
  PRIMARY KEY (user_id, row_name, position)
);
-- Valkey, unchanged from the lab: queue:transcode, queue:processing, queue:dead (lists of video ids)
-- ClickHouse: playback_events(ts, session_id, user_id, video_id, country, isp, device, event, bitrate_kbps, rebuffer_ms)

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

  • العمود upload_key فريد، فإذا أعاد تطبيق صانع المحتوى طلب إنشاء الرفع بعد انقطاع الشبكة يعود إليه نفس الفيديو بدل أن يظهر فيديو ثان فارغ في الكتالوج.
  • الحالة uploading هي الوحيدة الجديدة عن التجربة، لأن الرفع في الإنتاج يذهب إلى التخزين مباشرة، فيعرف التطبيق أنه بدأ قبل أن يعرف أنه اكتمل.
  • أحداث التشغيل لا تدخل PostgreSQL أبداً، والسبب أنها كثيرة ولا تعدل، ويقرؤها التحليل مجمعة، وهذا ما تفعله قاعدة عمودية مثل ClickHouse أفضل بكثير.

الواجهات الرئيسية

الطريقة والمسارما تفعلهملاحظات
POST /uploadsتنشئ سجل الفيديو بحالة uploading، وتعيد معرفه وروابط رفع الأجزاء الموقعةترويسة Idempotency-Key إلزامية، وتوكن بصلاحية صانع محتوى
POST /uploads/{id}/completeتتحقق من وجود الملف في التخزين، وتغير الحالة إلى queued، وتضع المعرف في queue:transcodeإذا تكرر الطلب والحالة ليست uploading تعيد الحالة الحالية دون مهمة ثانية
GET /videos/{id}حالة المعالجة وروابط التشغيل، كما في واجهة التجربةلصانع المحتوى فقط، والمشاهد لا يرى فيديو حالته ليست ready
GET /home وGET /search?q=صفوف الصفحة الرئيسية من home_rows، والبحث من OpenSearchنتائج البحث العامة تحفظ في Cache لدقائق، والصفوف الشخصية لا تحفظ في ال CDN
POST /play/{id}تتحقق من الاشتراك والتوفر في دولة المشاهد، وتعيد رابط master.m3u8 موقعاً لدقائق ورابط خادم الرخصنقطة القرار الوحيدة في التشغيل، وردها لا يحفظ في أي Cache
PUT /progress/{id}تحفظ موضع المشاهدة في watch_progressتكرارها لا يضر، فآخر قيمة هي الصحيحة
POST /eventsتستقبل دفعة من أحداث التشغيل وتكتبها إلى Kafka وترد 202الرد لا ينتظر التحليل، والمشغل يرسل كل 10 ثوان دفعة واحدة
GET /v/{id}/master.m3u8 وما بعدهملفات التشغيل والمقاطع من ال CDNالمقاطع بصلاحية سنة وimmutable، وملفات التشغيل بدقيقة، كما في التجربة

تقدير السعة

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

  • افتراض: 50,000 ساعة مشاهدة في الشهر، ومكتبة 2,000 ساعة، كما في التقدير السابق.
  • افتراض: 20% من مشاهدة اليوم تقع في ساعة الذروة المسائية، ومتوسط الجلسة 40 دقيقة.
  • افتراض: متوسط معدل البت 2.5 ميجابت في الثانية لخليط الجودات، وأعلاها 4.7 كما قسناه في التجربة.
  • من التجربة: مقاطع مدتها 4 ثوان، ومقطع صوت منفصل، أي طلبان كل 4 ثوان لكل مشاهد.
  • افتراض: المشغل يرسل دفعة أحداث كل 10 ثوان، وموضع المشاهدة كل 30 ثانية، ويرفع صناع المحتوى ساعتين من الفيديو الجديد كل يوم.
viewing per day      50,000 h / 30                    = 1,667 h per day
peak concurrency     1,667 h x 20% in the peak hour   = ~333 viewers at the same time
CDN requests         333 x 2 requests / 4 s           = ~167 requests/s
egress at peak       333 x 2.5 Mbit/s                 = ~0.83 Gbit/s   (all at 1080p: 333 x 4.7 = ~1.57 Gbit/s)
egress per month     50,000 h x 2.5 Mbit/s x 3600 / 8 = ~56 TB per month
play starts          333 x 60 / 40 min                = 500 per hour = ~0.14 requests/s
playback events      333 / 10 s                       = ~33 events/s into Kafka
progress writes      333 / 30 s                       = ~11 writes/s into PostgreSQL
new renditions       2 h x 3.9 GB                     = ~7.8 GB per day
new originals        2 h x 4.4 GB (lab: 36.38 MB/30 s) = ~8.7 GB per day
storage per year     (7.8 + 8.7) GB x 365             = ~6 TB per year
encoding             2 h x 3600 s x 2.2 (lab: 67.2 s for 30 s, 2 workers sharing a CPU)
                                                      = ~15,800 s = ~4.4 worker-hours per day

والنتيجة التي يجب أن تلاحظها أن الخدمات وقاعدة البيانات تكاد لا تعمل، فطلب بدء التشغيل أقل من طلب واحد في الثانية، وكتابة موضع المشاهدة 11 في الثانية، وهذا يتحمله PostgreSQL واحد بسهولة، أما الأرقام الكبيرة فهي النقل الخارج 56 تيرابايت في الشهر والتخزين الذي يزيد 6 تيرابايت في السنة، وهذا بالضبط سبب أن المعمارية تضع ال CDN وتخزين الكائنات في مركزها، ولا تهتم كثيراً بتقسيم الخدمات. ولاحظ أن حجم الملف الأصلي في التجربة جاء من صورة اختبار، والملفات الحقيقية من الكاميرات قد تكون أكبر بكثير.

مراحل النمو

الجزءMVP: سيرفر أو اثنانالنمو: آلاف المشاهدين في نفس الوقتالحجم الكبير: ملايين المشاهدين
الخدماتتطبيق واحد Monolith للرفع والكتالوج والتشغيل، وال Worker وحده منفصلعدة نسخ من التطبيق خلف البوابة، وواجهة التشغيل تكبر وحدها لأنها الأكثر طلباًخدمات منفصلة لكل فريق، في أكثر من منطقة Region
التخزينGarage على عقدة واحدة مع نسخ احتياطيGarage على ثلاث عقد بثلاث نسخ من كل كائنتخزين في أكثر من منطقة، والملفات الأصلية في فئة تخزين أرخص
التوصيلnginx كنقطة Cache على نفس السيرفرCDN تجارية أمام التخزين، مع طبقة Cache وسطى Origin Shieldأكثر من CDN، ونقاط Cache خاصة في المناطق التي فيها أغلب المشاهدين، بفكرة Open Connect
المعالجةWorker أو اثنان، وسلم ثابتعدد ال Workers يزيد مع طول الطابور، وCapped CRFPer-Title وتقسيم الفيلم إلى أجزاء تحول بالتوازي، وترميزات أحدث
قاعدة البياناتPostgreSQL واحد ونسخة احتياطية يوميةنسخة قراءة Read Replica للكتالوج، ومجمع اتصالات Connection Poolتقسيم watch_progress بحسب المستخدم، كما في PostgreSQL للجداول الضخمة
الأحداث والتوصياتالأحداث في جدول، والصفوف: الأكثر مشاهدة وأكمل ما بدأتهKafka وClickHouse، و«من شاهد هذا شاهد أيضاً» كل ليلةنماذج ترتيب واختبارات A/B على الصفحة الرئيسية

ولا تقم ببناء ما في العمود الأخير من اليوم الأول: فلا تضف DRM إلا إذا طلبه عقد، ولا تبن شبكة Cache خاصة، ولا تقسم المنصة إلى خدمات مصغرة، ولا تبدأ ب Per-Title أو بنماذج التوصية، والسبب أن كل واحد منها يحل مشكلة لا تظهر إلا بعد أن يصبح لديك مشاهدون بأعداد كبيرة، وحتى ذلك الحين يكلفك وقتاً وأعطالاً دون أي فائدة، وهذه هي نفس الخلاصة في مدخل إلى تصميم الأنظمة.

ماذا لو تعطل جزء؟

ماذا لو تعطل...؟الأثركيف تخففه
Worker في منتصف التحويلالمهمة تبقى في queue:processing والحالة processing إلى الأبدمهمة مجدولة تعيد المهام الأقدم من أطول تحويل متوقع، وبعد ثلاث محاولات queue:dead، كما في تجربة توقف ال Worker
طابور Valkeyالرفع ينجح والفيديو لا يبدأ تحويلهتفعيل الحفظ على القرص AOF، وإعادة بناء الطابور من صفوف videos التي حالتها queued
تخزين الكائناتيتوقف الرفع الجديد، والمقاطع غير الموجودة في ال CDN لا تصلثلاث عقد بثلاث نسخ، والمقاطع الشائعة تبقى تعمل من ال CDN مدة صلاحيتها
ال CDNكل المشاهدين يذهبون إلى التخزين مباشرة فيسقط من الحملCDN ثانية يعرفها المشغل، وحد أقصى للطلبات على التخزين حتى لا يسقط معها
قاعدة الكتالوجلا يستطيع أحد أن يتصفح أو يبدأ تشغيلاً جديداً، لكن من بدأ يكمل لأن المقاطع من ال CDNنسخة احتياطية جاهزة للتحويل Failover كما في مفاهيم التوفر العالي، وصفحات الكتالوج في Cache
خدمة رخص DRMالمحتوى المشفر لا يبدأ تشغيله أبداًرخص بمدة تكفي الجلسة كاملة، فلا يتوقف من بدأ، وتنبيه فوري لأنه مزود خارجي لا تتحكم فيه

الرفع: ملف كبير لا يمر عبر تطبيقك

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

والطريقة الصحيحة أن يكون الرفع قابلاً للاستئناف Resumable، ويذهب مباشرة إلى تخزين الكائنات Object Storage دون أن يمر بايت واحد عبر التطبيق، وهناك طريقتان شائعتان: الرفع المجزأ Multipart Upload في واجهة S3، حيث يقسم الكلاينت الملف إلى أجزاء ويرفع كل جزء برابط موقع Presigned URL ويعيد الجزء الفاشل فقط، كما تشرح وثائق Amazon S3، أو البروتوكول المفتوح tus المصمم للرفع القابل للاستئناف فوق HTTP. وفي الحالتين يقتصر دور التطبيق، أي واجهة الرفع Upload API في المعمارية أعلاه، على التحقق من صلاحية المستخدم وإصدار الروابط، ثم يستقبل إشعاراً بأن الرفع اكتمل فيضع مهمة في الطابور. وقد شرحنا الروابط الموقعة والرفع المجزأ في أنواع التخزين.

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

التحويل Transcoding وسلم الجودات Bitrate Ladder

الملف الذي يرفعه صانع المحتوى لا يصلح للبث كما هو، فقد يكون بترميز لا تفهمه بعض الأجهزة، وبمعدل بت أعلى بكثير مما يتحمله اتصال هاتف، لذلك تحوله المنصة إلى عدة نسخ بدقات ومعدلات بت مختلفة، وتسمى هذه المجموعة سلم الجودات Bitrate Ladder، ويختار المشغل في كل لحظة الدرجة التي يتحملها اتصاله، وهذا ما يسمى البث المتكيف Adaptive Bitrate Streaming واختصاراً ABR.

السلم الثابت: وصفة واحدة لكل الفيديوهات

البداية الطبيعية أن تختار سلماً واحداً لكل المكتبة، مثل 1080p بمعدل 4.5 ميجابت و720p بمعدل 2.5 و480p بمعدل 1 و360p بمعدل 0.6، وهو السلم الذي سوف نستخدمه في التجربة العملية لأنه بسيط ومفهوم. وهذا ما فعلته Netflix عندما بدأت ترميز H.264 في أواخر 2010، كما تذكر في تدوينة Per-Title Encode Optimization المنشورة في 14 ديسمبر 2015، حيث اختار مهندسوها سلماً ثابتاً أعلى درجاته 5800 كيلوبت في الثانية، وكانت درجة 720p فيه بمعدل 2350 كيلوبت، ودرجة 480p بمعدل 1750 كيلوبت.

ومشكلة السلم الثابت أن الفيديوهات ليست متشابهة، فالرسوم المتحركة ذات المساحات الملونة المسطحة تصل إلى جودة ممتازة بعدد قليل من البتات، بينما يحتاج فيلم الحركة ذو الانفجارات وحبيبات الفيلم Film Grain أضعاف ذلك. وتذكر التدوينة نفسها أن Netflix رمزت 100 فيديو عشوائي من مكتبتها بنفس الإعدادات فوصل بعضها إلى جودة عالية جداً عند 2500 كيلوبت أو أقل، واحتاج بعضها 8000 كيلوبت أو أكثر ليصل إلى جودة مقبولة فقط. ويمكنك أن ترى هذا بنفسك بأمر واحد، فالسكربت التالي يرمز ثلاثة أنواع من المحتوى المولد بنفس الإعدادات تماماً، أي جودة ثابتة CRF 23 ودقة 720p ومدة 10 ثوان، ثم يطبع معدل البت الناتج:

# Same encoder settings (constant quality CRF 23, 720p, 10 s), three kinds of content
for src in "smptehdbars=size=1280x720:rate=30" \
           "testsrc2=size=1280x720:rate=30" \
           "testsrc2=size=1280x720:rate=30,noise=alls=25:allf=t+u"; do
  ffmpeg -hide_banner -loglevel error -y -f lavfi -t 10 -i "$src" \
    -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p /tmp/out.mp4
  kbps=$(ffprobe -v error -show_entries format=bit_rate -of csv=p=0 /tmp/out.mp4)
  printf '%-58s %6d kbps\n' "$src" $((kbps / 1000))
done

والمخرج سوف يكون كما يلي:

smptehdbars=size=1280x720:rate=30                              15 kbps
testsrc2=size=1280x720:rate=30                               2780 kbps
testsrc2=size=1280x720:rate=30,noise=alls=25:allf=t+u       25098 kbps

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

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

سلم لكل فيديو Per-Title Encoding

والحل الذي نشرته Netflix في نفس التدوينة أن يحصل كل فيديو على سلمه الخاص، فتجري المنصة ترميزات تجريبية لكل فيديو بعدة دقات ومستويات جودة، وتقيس الجودة ومعدل البت لكل محاولة، ثم ترسم منحنى الجودة مقابل معدل البت لكل دقة، وتختار من كل منطقة الدقة التي تعطي أعلى جودة لنفس معدل البت، وتسمي التدوينة الحد الذي تشكله هذه النقاط الغلاف المحدب Convex Hull. وتقيد Netflix الاختيار بعدد محدود من الدقات حتى تبقى الملفات قابلة للتشغيل على كل الأجهزة المعتمدة سابقاً، وبمعدلات بت تتباعد بقرابة 5% فقط، والصورة التالية تبين الفكرة:

رسمان بيانيان متجاوران للجودة مقابل معدل البت. في اليسار محتوى بسيط مثل الرسوم المتحركة يصل منحناه الأزرق إلى الجودة العالية مبكراً، ودرجات السلم الثابت 1750 و4300 و5800 كيلوبت رمادية على الجزء المسطح، والاختيار الأخضر 1080p عند 1540 كيلوبت. وفي اليمين محتوى معقد مثل أفلام الحركة يصعد منحناه البرتقالي ببطء، ودرجات السلم الثابت ما زالت على الجزء الصاعد، والاختيار الأخضر عند 7500 كيلوبت
المحتوى البسيط يشبع مبكراً، والمعقد ما زال يحتاج بتات عند أعلى درجة

وتذكر التدوينة مثالين بأرقامهما: في حلقة من مسلسل الرسوم المتحركة BoJack Horseman كان السلم الثابت يعطي دقة 480p عند 1750 كيلوبت، وصار السلم الخاص يبدأ بدقة 1080p عند 1540 كيلوبت فقط، أي صورة أوضح ببتات أقل. وفي مسلسل Orange is the New Black صارت أعلى درجة 4640 كيلوبت بدل 5800، وهو توفير 20% في معدل البت بنفس الجودة. وفي الاتجاه المعاكس تذكر التدوينة أن فيلماً مليئاً بالحركة قد يحتاج أعلى درجة فوق 5800 كيلوبت، أو دقة أقل من 1080p عند نفس المعدل، والسبب أن آثار الضغط Compression Artifacts مثل التكتل Blocking تزعج العين أكثر من النعومة الناتجة عن تكبير صورة بدقة أقل.

ترميز كل لقطة Shot-based Encoding

ثم ذهبت Netflix خطوة أبعد في 2018 بإطار سمته Dynamic Optimizer، وشرحته في تدوينة Dynamic optimizer المنشورة في 5 مارس 2018، والفكرة أن الفيلم نفسه ليس متجانساً، فمشهد حوار في غرفة هادئة يختلف عن مشهد مطاردة في نفس الحلقة، لذلك يقسم الفيديو عند حدود اللقطات Shots، وهي أجزاء صورت بنفس الكاميرا في ظروف متقاربة، ثم يختار لكل لقطة إعدادات الترميز الخاصة بها. وتذكر تدوينة Optimized shot-based encodes المنشورة في 9 مارس 2018 أن حلقة مدتها ساعة من Stranger Things كانت تقسم إلى عشرين جزءاً مدة كل منها 3 دقائق، وصارت تقسم إلى قرابة 900 لقطة بمتوسط 4 ثوان للقطة، فزاد عدد وحدات العمل أكثر من مئة ضعف وظهرت اختناقات في عدد الرسائل بين السيرفرات، فجمعوا اللقطات في أجزاء بطول 3 دقائق تقريباً، وحفظوا كل لقطة فور انتهائها حتى لا تعاد من البداية إذا سحب منهم السيرفر الذي يعمل عليها. والنتيجة بحسب نفس التدوينة أن الترميز الجديد احتاج أقل من نصف البتات لنفس الجودة مقارنة بالترميز السابق بملف AVCMain، ووفر 17% و30% مقارنة بأفضل ترميزات H.264 وVP9 السابقة لديهم.

وقد يتساءل البعض: هل أحتاج هذا في منصتي؟ والإجابة في أغلب الحالات لا، والسبب أن هذا التوفير يستحق ثمنه عندما تكون فاتورة النقل أكبر بكثير من فاتورة المعالجة، وهذا يحدث عندما يشاهد كل فيديو ملايين المرات. أما في منصة تعليمية أو منصة شركة فابدأ بسلم ثابت معقول، ثم أضف قاعدة بسيطة تشبه فكرة Per-Title دون ترميزات تجريبية: رمز بجودة ثابتة CRF مع حد أعلى لمعدل البت Capped CRF، فيأخذ الفيديو البسيط بتات أقل تلقائياً، وهذا ما رأيته في التجربة العملية عندما أخذ مقطع الألوان الثابتة قرابة 160 كيلوبت فقط رغم أن السلم طلب له 2500.

كيف تقيس الجودة؟ VMAF

كل ما سبق يحتاج رقماً يقول إن هذه النسخة جيدة وتلك سيئة، ومقياس PSNR القديم يقيس الفرق الرياضي بين الصورتين ولا يعكس دائماً ما تراه العين، لذلك طورت Netflix مع جامعة University of Southern California مقياساً سمته VMAF، وشرحته في تدوينة Toward a practical perceptual video quality metric، وهو يجمع عدة مقاييس بنموذج تعلم آلي مدرب على تقييمات بشر حقيقيين، ونشرته مفتوح المصدر في مستودع Netflix/vmaf برخصة BSD-2-Clause-Patent، ويمكن استخدامه من ffmpeg مباشرة عندما يبنى معه. واستخدامه العملي في منصتك أن تقيس سلم الجودات مرة على عينة من فيديوهاتك قبل أن تعتمده، بدل أن تختار الأرقام بالإحساس.

التجزئة Packaging: نسخة واحدة من المقاطع لكل الأجهزة

بعد التحويل لديك أربعة ملفات فيديو كاملة وملف صوت، والمشغل لا يحمل ملفاً كاملاً، وإنما يحمل مقاطع Segments مدة كل منها ثوان قليلة، ويقرأ قائمتها من ملف تشغيل Manifest، وهذه هي وظيفة ال Packager. وهنا ظهرت مشكلة تاريخية: أجهزة Apple تفهم HLS الموصوف في RFC 8216، وكثير من الأجهزة الأخرى تفضل MPEG-DASH الذي ترعاه DASH Industry Forum، وكانت المنصات تخزن الفيديو مرتين بصيغتي مقاطع مختلفتين. والحل الحديث هو CMAF، وهو معيار من MPEG يوحد شكل المقاطع على fMP4، فتكتب المقاطع مرة واحدة ويشير إليها ملف HLS وملف DASH معاً، والصورة التالية تبين ذلك:

أربعة ملفات مدخلة 1080p و720p و480p وصوت تدخل إلى Packager، فيخرج شبكة من مقاطع CMAF لكل جودة ملف init ثم 1.m4s و2.m4s و3.m4s، ومنها سهم برتقالي إلى ملف master.m3u8 بصيغة HLS لأجهزة Apple وhls.js، وسهم أزرق إلى ملف manifest.mpd بصيغة MPEG-DASH لأجهزة Android والتلفزيونات وdash.js وShaka Player، وتحتها عبارة نسخة واحدة من الوسائط
مقاطع fMP4 تكتب مرة واحدة، وملفا تشغيل يشيران إليها

ولكي يستطيع المشغل الانتقال من جودة إلى أخرى في منتصف الفيديو دون أن تتقطع الصورة، يجب أن تبدأ المقاطع في كل الجودات من نفس اللحظة، وأن يبدأ كل مقطع بإطار مفتاحي Keyframe، لذلك نثبت في ffmpeg مجموعة الصور GOP بطول ثابت ونمنع الإطارات المفتاحية الإضافية عند تغير المشهد، كما سترى في كود ال Worker. وال Packager الذي نستخدمه هو Shaka Packager، وهو أداة مفتوحة المصدر برخصة BSD-3-Clause كما في ملف الرخصة، تصنع HLS وDASH للفيديو عند الطلب والبث المباشر، وتدعم التشفير بأنظمة Widevine وPlayReady وFairPlay، ويمكنك أيضاً أن تجزئ ب ffmpeg وحده بخيارات الصيغة hls، وهو ما فعلناه في مقال الفيديو القصير، لكن Shaka Packager يكتب الملفين من نفس المقاطع في خطوة واحدة.

التشفير DRM: ما الذي يحميه فعلاً؟

إذا كانت مكتبتك أفلاماً اشتريت حقوق عرضها فالعقد غالباً يلزمك بنظام إدارة الحقوق الرقمية Digital Rights Management، واختصاراً DRM. والفكرة أن المقاطع تشفر مرة واحدة عند التجزئة بمعيار التشفير المشترك Common Encryption واختصاراً CENC، فتصبح الملفات على ال CDN بلا قيمة لمن ينسخها، والمفتاح لا يصل إلا إلى وحدة داخل الجهاز تسمى CDM، أي Content Decryption Module، بعد أن يتحقق خادم التراخيص License Server من اشتراك المستخدم. والمتصفح يتحدث مع هذه الوحدة عبر واجهة Encrypted Media Extensions من W3C، والأنظمة الثلاثة المنتشرة هي Widevine من Google وFairPlay Streaming من Apple وPlayReady من Microsoft، والصورة التالية تبين الخطوات:

ال Packager يشفر المقاطع بمفتاح من خادم المفاتيح، وال CDN يوزع مقاطع مشفرة فقط إلى جهاز المشاهد، وفي الجهاز مشغل يستخدم EME ووحدة CDM تدعم Widevine وFairPlay وPlayReady، والمشغل يرسل طلب رخصة فيه معرف المفتاح إلى خادم التراخيص الذي يتحقق من الاشتراك ويعيد رخصة فيها المفتاح وقيوده مثل الدقة العالية والتشغيل دون اتصال والصلاحية
المقاطع مشفرة في كل مكان، والمفتاح يذهب إلى وحدة CDM في الجهاز المسموح له

وكن صادقاً مع نفسك فيما يحميه هذا النظام: لا توجد حماية آمنة بنسبة 100% لفيديو يعرض على شاشة يراها الإنسان، فمن يصور الشاشة بكاميرا سوف يحصل على نسخة، والهدف الواقعي هو منع النسخ السهل بنقرة واحدة، وتطبيق قيود العقد مثل ألا تعرض الدقة العالية إلا على أجهزة بحماية في العتاد. ولاحظ أن النظامين الأشهر ليسا برنامجاً تثبته على سيرفرك فقط، فاستخدام Widevine وFairPlay يحتاج اتفاقاً مع الشركة المالكة أو خدمة تراخيص تجارية، فإذا كانت مكتبتك محاضرات داخلية لشركتك فلا تقم بإضافة DRM، والسبب أنه يضيف تكلفة وتعقيداً وأعطالاً على بعض الأجهزة، ويكفيك تشفير HLS العادي بمفتاح AES-128 مع روابط موقعة قصيرة العمر وصلاحيات تسجيل الدخول.

التخزين والتوصيل: أين يقف الفيديو عندما يضغط المشاهد زر التشغيل؟

المقاطع وملفات التشغيل ملفات ثابتة لا تتغير بعد كتابتها، وهذا أفضل ما يمكن أن تتمناه لشبكة توصيل المحتوى CDN، فتحفظها النقطة القريبة من المشاهد مرة واحدة وتخدمها لكل من بعده، وقد شرحنا طريقة عمل ال CDN وترويسة Cache-Control في شبكة توصيل المحتوى CDN. والقاعدة في الفيديو بسيطة: المقطع لا يتغير أبداً، فاجعل صلاحيته سنة كاملة مع immutable، وملف التشغيل قد يتغير إذا أعدت التحويل، فاجعل صلاحيته قصيرة، وهذا ما يفعله ال Worker في التجربة.

كيف فعلتها Netflix؟ شبكة Open Connect

بحسب وثيقة Open Connect Overview الرسمية بدأت Netflix برنامج Open Connect سنة 2011 لسببين: أن حركتها صارت جزءاً كبيراً من حركة مزودي الإنترنت فاحتاجت أن تعمل معهم مباشرة، وأن شبكة مصممة لها تستطيع أن تملأ أجهزتها مسبقاً بما سيشاهده الناس بدل أن تنتظر الطلب الأول كما تفعل ال CDN العادية. وأساس الشبكة أجهزة Cache مصممة لهذا الغرض تسمى Open Connect Appliances، واختصاراً OCA، وتوضع بطريقتين: في نقاط تبادل الإنترنت IXP متصلة بالمزودين بتبادل حركة دون مقابل Settlement-free Peering، أو داخل شبكة المزود نفسه، حيث تعطي Netflix الجهاز مجاناً للمزود المؤهل، ويوفر المزود الكهرباء والمكان والاتصال، ويذكر موقع البرنامج أن أكثر من ألف مزود إنترنت يشاركون فيه. والصورة التالية تبين خطوات التشغيل كما تصفها الوثيقة:

في الأعلى سحابة طبقة التحكم في AWS فيها خدمات التشغيل والتوجيه والتحكم بال Cache، وفي اليسار شبكة مزود الإنترنت فيها جهاز OCA مجاني ومشاهدون في البيوت على تلفاز وهاتف، وفي اليمين نقطة تبادل الإنترنت فيها جهاز OCA متصل بموجهات المزودين، وأسهم مرقمة: الجهاز يبلغ طبقة التحكم، والمشاهد يطلب التشغيل، والخدمة تتحقق وتختار الملفات، وخدمة التوجيه تعطي المشغل روابط الأجهزة، والتشغيل من داخل شبكة المزود، وسهم بنفسجي متقطع للتعبئة في غير أوقات الذروة
الفيديو يقف داخل شبكة مزود الإنترنت قبل أن يضغط المشاهد زر التشغيل

ولاحظ ثلاثة أشياء من الوثيقة تهمك حتى لو لم تبن شبكتك الخاصة: أن أجهزة OCA لا تخزن أي بيانات عن المستخدمين، مثل سجل المشاهدة أو معلومات DRM، وإنما تخزن المقاطع فقط، وكل المنطق في طبقة تحكم منفصلة على AWS، وهذا هو الفصل الذي رأيناه بين نظام المعالجة ونظام التوصيل. وأن الأجهزة تملأ ليلاً في نوافذ زمنية خارج أوقات الذروة، لأن Netflix تتوقع بدقة ما سيشاهده الناس ومتى. وأن خدمة التوجيه تختار للمشغل أجهزة محددة بحسب توفر الملف وصحة الجهاز وقربه من المشاهد، ثم تعطيه روابطها مباشرة.

وقد يتساءل البعض: هل أبني شبكة مثلها؟ والإجابة لا، فهذا البرنامج يعمل لأن Netflix تنقل حصة ضخمة من حركة الإنترنت تجعل المزودين يرحبون بأجهزتها، أما منصتك فلها ثلاث مراحل معقولة: سيرفر واحد مع Cache مثل nginx كما في التجربة، ثم CDN تجارية أمام تخزين الكائنات، ثم عدة نقاط Cache في المناطق التي فيها أغلب مشاهديك عندما تصبح فاتورة النقل أكبر بند عندك، وقد شرحنا هذه المراحل في هل تستطيع أن تبني CDN بنفسك؟.

الكتالوج والبحث

الكتالوج Catalog هو قاعدة البيانات التي تصف كل فيديو: العنوان والوصف والتصنيفات والمواسم والحلقات وحقوق العرض في كل دولة والصور والترجمات وحالة المعالجة، وهو جزء عادي من أي تطبيق، فقاعدة PostgreSQL واحدة تكفي منصة متوسطة لسنوات، كما شرحنا في PostgreSQL لكل شيء. وما يميز الكتالوج في منصة الفيديو حقلان يجب أن تفكر فيهما من اليوم الأول: حالة المعالجة، فالفيديو لا يظهر للمشاهدين قبل أن تجهز كل جوداته، والتوفر بحسب الدولة والتاريخ، لأن عقود العرض تحدد أين ومتى يعرض كل عمل، وقد وضعنا الحقلين في جدولي videos وavailability في نموذج البيانات. أما البحث فيحتاج محركاً يفهم العربية، مع أخطاء الكتابة والهمزات والتاء المربوطة، وقد شرحنا الفهرس المعكوس في كيف يعمل البحث، والبحث في النصوص العربية في OpenSearch وMeilisearch.

التوصيات: ما تعلمته Netflix من مليون دولار

في 2006 أعلنت Netflix مسابقة Netflix Prize، وعرضت مليون دولار لمن يحسن دقة نظامها في توقع تقييم المشاهد للفيلم بنسبة 10%، أي أن ينزل الخطأ RMSE من 0.9525 إلى 0.8572 أو أقل، كما تحكي تدوينة Netflix Recommendations: Beyond the 5 stars المنشورة في 6 أبريل 2012. وبعد سنة فاز فريق بجائزة التقدم الأولى بتحسين 8.43%، بعد أكثر من 2000 ساعة عمل وخليط من 107 خوارزميات، فأخذت Netflix أقوى خوارزميتين فيه، وهما تحليل المصفوفة Matrix Factorization الذي سماه المشاركون SVD وشبكة RBM، وعدلتهما لتعملا على أكثر من 5 مليارات تقييم بدل 100 مليون، ووضعتهما في الإنتاج. أما الخليط الذي فاز بالمليون بعد سنتين فلم تضعه في الإنتاج، والسبب كما تقول التدوينة أن زيادة الدقة التي قاستها لم تبرر الجهد الهندسي اللازم لتشغيله، ولأن اهتمامها انتقل إلى شيء آخر.

وهذا الشيء الآخر هو الدرس الحقيقي لأي منصة: مع البث صارت Netflix ترى ما لا تراه في تقييم النجوم، أي هل شاهد المستخدم الفيلم كاملاً أم جزءاً منه، وماذا شاهد بعده، وفي أي وقت، وعلى أي جهاز، وتذكر التدوينة أن 75% مما يشاهده الناس على Netflix في ذلك الوقت جاء من توصية بشكل أو بآخر. لذلك فالتوصية في منصة الفيديو ليست مسألة توقع رقم، وإنما ترتيب الصفوف والعناوين في الصفحة الرئيسية بحسب ما يرجح أن يشاهده الشخص فعلاً، مع التنوع والجديد والسياق، وتجد أبحاث Netflix في هذا المجال في صفحة Recommendations على موقع أبحاثها.

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

تحليلات التشغيل وجودة التجربة QoE

السيرفر يستطيع أن يخبرك أن المقاطع خرجت بنجاح، لكنه لا يعرف هل رأى المشاهد دائرة التحميل، لذلك تقاس جودة التجربة Quality of Experience، واختصاراً QoE، من المشغل نفسه، فيرسل أحداثاً صغيرة كل بضع ثوان إلى نقطة جمع تضعها في طابور ثم في قاعدة تحليلات، وهي جامع الأحداث وKafka وClickHouse في المعمارية أعلاه. وأهم المقاييس التي يجب أن تبدأ بها:

  • زمن البدء Startup Time: من ضغط الزر إلى أول إطار، وهو أكثر ما يشعر به المشاهد.
  • نسبة التوقف للتحميل Rebuffering Ratio: الوقت الذي توقفت فيه الصورة مقسوماً على وقت المشاهدة، وعدد مرات التوقف.
  • متوسط معدل البت ومرات تبديل الجودة: هل يشاهد أغلب الناس بالجودة المنخفضة؟ ربما سلمك أو ال CDN في منطقة معينة هو المشكلة.
  • أخطاء التشغيل وفشل البدء Exit Before Video Start: من ضغط الزر ثم غادر قبل أن يرى شيئاً.

وقسم هذه الأرقام دائماً بحسب الدولة ومزود الإنترنت ونوع الجهاز ونسخة التطبيق، والسبب أن المتوسط العام يخفي المشكلة، فقد يكون كل شيء ممتازاً إلا لمشتركي مزود واحد في مدينة واحدة، وهذا بالضبط ما تحتاجه لتقرر أين تضع نقطة Cache جديدة. وقد شرحنا السجلات وأنواعها في السجلات Logging للمطورين.

التكلفة: البند الذي يقرر مصير المنصة

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

  • اجعل أعلى درجة في السلم مناسبة للمحتوى، فمحاضرة بشرائح لا تحتاج 1080p بمعدل 4.5 ميجابت، وهذا وحده قد يخفض النقل إلى النصف.
  • لا تبدأ التشغيل بأعلى جودة دائماً، واترك المشغل يختار، وحدد أعلى جودة بحسب حجم الشاشة، فلا معنى ل 1080p على شاشة هاتف صغيرة.
  • ترميز أحدث مثل HEVC أو AV1 يعطي نفس الجودة ببتات أقل، لكنه يحتاج أن تبقي H.264 للأجهزة القديمة، أي معالجة وتخزين أكثر مقابل نقل أقل، فاحسب أين تقع نقطة التعادل عندك.
  • مزود بسعر نقل ثابت أو حزمة نقل كبيرة مع السيرفر، كما شرحنا في السحابة ليست دائماً الأرخص، ثم CDN تحسب السعر بحسب المنطقة.

منصة MVP بأدوات مفتوحة المصدر

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

الطبقةالأداةالرخصةملاحظة
التحويلFFmpegLGPL 2.1، وGPL عند بنائه مع مكونات مثل libx264يحول ويقص ويصنع الصور والجودات
التجزئة والتشفيرShaka PackagerBSD-3-ClauseCMAF مع HLS وDASH وCENC
تخزين الكائناتGarageAGPL-3.0متوافق مع S3، ويخدم Bucket كموقع ثابت
الطابورValkeyBSD-3-Clauseقائمتان للمهام وواحدة للمهام الميتة، أو أي وسيط من مقال الطوابير
المشغلhls.js وShaka PlayerApache-2.0الأول ل HLS، والثاني ل HLS وDASH وDRM
منصة جاهزةPeerTubeAGPL-3.0رفع وتحويل وقنوات وبث مباشر وتوزيع

وقبل أن تكتب سطراً واحداً اسأل نفسك هل تحتاج منصة خاصة أصلاً، فإذا كانت حاجتك مكتبة فيديو لمؤسستك أو جامعتك فالأنسب أن تبدأ ب PeerTube، وهو منصة فيديو مفتوحة المصدر تحول الملفات المرفوعة إلى HLS، وتدعم البث المباشر، ويمكن أن تتبادل الخوادم المحتوى فيما بينها بمعيار ActivityPub، وتخفف الحمل عن السيرفر بأن يشارك المشاهدون المقاطع بينهم عبر WebRTC كما يذكر دليل الأسئلة الشائعة للمشروع، وسوف نشرح تثبيته بإذن الله في دليل مستقل ضمن هذا القسم. أما إذا كانت المنصة هي منتجك، بتجربة مستخدم خاصة وكتالوج وحقوق عرض واشتراكات، فالقطع في الجدول هي ما ستبنيه حولها، وابدأ بالشكل الذي في التجربة التالية، وهو عمود MVP في جدول مراحل النمو: تطبيق واحد للكتالوج والرفع، وطابور، وعدد من ال Workers يزيد مع الحاجة، وتخزين كائنات، وCDN. ولا تقم بتقسيم المنصة إلى خدمات مصغرة من اليوم الأول، والسبب شرحناه في Monolith أم Microservices، فال Worker وحده يستحق أن يكون منفصلاً لأنه يحتاج معالجاً قوياً ويكبر بشكل مختلف عن الموقع.

تجربة عملية: من ملف مرفوع إلى أربع جودات تعمل في المتصفح

الآن سوف نبني المنصة المصغرة على جهاز واحد بخمس خدمات في Docker Compose: واجهة رفع صغيرة بلغة Python، وتخزين Garage فيه Bucket للملفات الأصلية وآخر للمقاطع، وValkey كطابور، واثنان من ال Workers فيهما ffmpeg وShaka Packager، وnginx يعمل كنقطة Cache أمام التخزين ويخدم صفحة مشغل hls.js. والصورة التالية تبين شكل التجربة:

مخطط التجربة: أمر curl يرفع ملفاً إلى lab-vod-api على المنفذ 38981، فيحفظه في Bucket اسمه uploads على Garage ويضع مهمة في طابور Valkey، ثم يأخذها أحد عاملين فيهما ffmpeg وShaka Packager ويكتب أربع جودات CMAF إلى Bucket اسمه vod، ومنه إلى nginx على المنفذ 38980 ثم مشغل hls.js في المتصفح
خمس خدمات على جهاز واحد، وكل منفذ على 127.0.0.1 فقط

ملفات التجربة

أنشئ مجلداً فارغاً وضع فيه الملفات التالية، أولها Dockerfile يبني Image واحدة لواجهة الرفع وال Worker، فيها Python 3.13 وffmpeg من Alpine 3.24، وينسخ Shaka Packager من Image المشروع الرسمية:

FROM google/shaka-packager:v3.9.3 AS packager

FROM python:3.13.16-alpine3.24
RUN apk add --no-cache ffmpeg libstdc++
COPY --from=packager /usr/bin/packager /usr/local/bin/packager
RUN pip install --no-cache-dir boto3==1.43.110 redis==8.1.0
WORKDIR /app
COPY api.py worker.py ./

ثم واجهة الرفع api.py، وهي لا تفعل إلا ثلاثة أشياء: تحفظ الملف في Garage، وتسجل الفيديو في Valkey بحالة queued، وتضع معرفه في الطابور. ولاحظ أن الرفع هنا طلب PUT واحد لتبقى التجربة قصيرة، وفي الإنتاج يكون رفعاً مجزأً مباشرة إلى التخزين كما شرحنا في قسم الرفع:

# Upload API: stores the original in object storage, records the video and puts a job on the queue.
import json, os, tempfile, time, uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import boto3, redis

s3 = boto3.client("s3", endpoint_url=os.environ["S3_ENDPOINT"], region_name="garage")
db = redis.Redis(host=os.environ["VALKEY_HOST"], decode_responses=True)
MAX_BYTES = 2 * 1024**3          # refuse anything above 2 GB

class Handler(BaseHTTPRequestHandler):
    def reply(self, code, body):
        data = json.dumps(body, ensure_ascii=False).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def do_PUT(self):                      # PUT /upload/<file name>
        size = int(self.headers.get("Content-Length", 0))
        if not self.path.startswith("/upload/") or not 0 < size <= MAX_BYTES:
            return self.reply(400, {"error": "PUT /upload/<name> with a body up to 2 GB"})
        vid = uuid.uuid4().hex[:12]
        with tempfile.SpooledTemporaryFile(max_size=64 * 1024**2) as tmp:
            left = size
            while left:
                chunk = self.rfile.read(min(left, 1024**2))
                if not chunk:
                    return self.reply(400, {"error": "upload interrupted"})
                tmp.write(chunk); left -= len(chunk)
            tmp.seek(0)
            s3.upload_fileobj(tmp, "uploads", f"{vid}/source")
        db.hset(f"video:{vid}", mapping={"status": "queued", "name": self.path[8:],
                                          "bytes": size, "queued_at": time.time()})
        db.lpush("queue:transcode", vid)   # the job is only the id; the details live in the hash
        self.reply(202, {"id": vid, "status": "queued"})

    def do_GET(self):                      # GET /videos/<id>
        info = db.hgetall(f"video:{self.path.rsplit('/', 1)[-1]}")
        self.reply(200 if info else 404, info or {"error": "not found"})

ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever()

ثم ال Worker worker.py، وهو قلب المنصة:

# Transcoding worker: takes a job from the queue, encodes a bitrate ladder with ffmpeg,
# packages CMAF segments with HLS and DASH manifests (Shaka Packager), and uploads them.
import json, os, pathlib, shutil, subprocess, time, traceback
import boto3, redis

s3 = boto3.client("s3", endpoint_url=os.environ["S3_ENDPOINT"], region_name="garage")
db = redis.Redis(host=os.environ["VALKEY_HOST"], decode_responses=True, socket_timeout=30)
WORKER = os.environ.get("HOSTNAME", "worker")

# height, video bitrate, max rate (kbps): an illustrative fixed ladder, not a recommendation
LADDER = [(1080, 4500, 4800), (720, 2500, 2700), (480, 1000, 1100), (360, 600, 650)]
TYPES = {".m3u8": "application/vnd.apple.mpegurl", ".mpd": "application/dash+xml",
         ".m4s": "video/iso.segment", ".mp4": "video/mp4", ".jpg": "image/jpeg"}

def run(cmd):
    subprocess.run(cmd, check=True, capture_output=True)

def probe(path):
    out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
                          "stream=width,height:format=duration", "-of", "json", path],
                         check=True, capture_output=True, text=True).stdout
    j = json.loads(out)
    return j["streams"][0]["height"], float(j["format"]["duration"])

def transcode(vid, work):
    src = str(work / "source")
    s3.download_file("uploads", f"{vid}/source", src)
    height, duration = probe(src)
    ladder = [r for r in LADDER if r[0] <= height] or [LADDER[-1]]   # never upscale
    n = len(ladder)
    # one decode, split into n scaled copies; a fixed GOP of 2 s keeps the segments of all renditions aligned
    graph = f"[0:v]split={n}" + "".join(f"[s{i}]" for i in range(n)) + ";" + ";".join(
        f"[s{i}]scale=-2:{h},setsar=1[v{i}]" for i, (h, _, _) in enumerate(ladder))
    cmd = ["ffmpeg", "-y", "-i", src, "-filter_complex", graph]
    for i, (h, rate, maxrate) in enumerate(ladder):
        cmd += ["-map", f"[v{i}]", "-c:v", "libx264", "-preset", "veryfast", "-profile:v", "high",
                "-b:v", f"{rate}k", "-maxrate", f"{maxrate}k", "-bufsize", f"{2 * rate}k",
                "-g", "60", "-keyint_min", "60", "-sc_threshold", "0", "-an", str(work / f"v{h}.mp4")]
    cmd += ["-map", "0:a:0", "-c:a", "aac", "-b:a", "128k", "-vn", str(work / "audio.mp4")]
    t0 = time.time(); run(cmd); encode_s = time.time() - t0

    out = work / "out"
    pkg = ["packager"]
    for h, _, _ in ladder:
        pkg.append(f"in={work}/v{h}.mp4,stream=video,init_segment={out}/{h}p/init.mp4,"
                   f"segment_template={out}/{h}p/$Number$.m4s,playlist_name={h}p/main.m3u8")
    pkg.append(f"in={work}/audio.mp4,stream=audio,init_segment={out}/audio/init.mp4,"
               f"segment_template={out}/audio/$Number$.m4s,playlist_name=audio/main.m3u8,hls_group_id=audio,hls_name=main")
    pkg += ["--segment_duration", "4", "--hls_playlist_type", "VOD",
            "--generate_static_live_mpd",
            "--hls_master_playlist_output", f"{out}/master.m3u8", "--mpd_output", f"{out}/manifest.mpd"]
    t0 = time.time(); run(pkg); package_s = time.time() - t0
    run(["ffmpeg", "-y", "-ss", "3", "-i", src, "-frames:v", "1", "-vf", "scale=-2:360", str(out / "poster.jpg")])

    files = 0
    for f in out.rglob("*"):
        if f.is_file():
            playlist = f.suffix in (".m3u8", ".mpd")
            s3.upload_file(str(f), "vod", f"{vid}/{f.relative_to(out).as_posix()}", ExtraArgs={
                "ContentType": TYPES.get(f.suffix, "application/octet-stream"),
                # segments never change, so CDNs may keep them for a year; manifests stay short
                "CacheControl": "public, max-age=60" if playlist else "public, max-age=31536000, immutable"})
            files += 1
    return {"duration": round(duration, 1), "renditions": ",".join(f"{h}p" for h, _, _ in ladder),
            "encode_seconds": round(encode_s, 1), "package_seconds": round(package_s, 1), "files": files}

print(f"{WORKER} waiting for jobs", flush=True)
while True:
    # BLMOVE keeps the job in queue:processing until it finishes, so a crash does not lose it
    vid = db.blmove("queue:transcode", "queue:processing", 5, "RIGHT", "LEFT")
    if not vid:
        continue
    key = f"video:{vid}"
    db.hset(key, mapping={"status": "processing", "worker": WORKER, "started_at": time.time()})
    work = pathlib.Path("/tmp") / vid
    work.mkdir(parents=True, exist_ok=True)
    try:
        result = transcode(vid, work)
        db.hset(key, mapping={"status": "ready", "finished_at": time.time(),
                              "hls": f"/v/{vid}/master.m3u8", "dash": f"/v/{vid}/manifest.mpd", **result})
        print(f"{vid} ready {result}", flush=True)
    except Exception as e:
        attempts = db.hincrby(key, "attempts", 1)
        final = attempts >= 3
        db.hset(key, mapping={"status": "failed" if final else "retrying", "error": str(e)[:300]})
        db.lpush("queue:dead" if final else "queue:transcode", vid)
        traceback.print_exc()
    finally:
        db.lrem("queue:processing", 1, vid)
        shutil.rmtree(work, ignore_errors=True)

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

  • الأمر BLMOVE ينقل المهمة من queue:transcode إلى queue:processing في خطوة واحدة، فإذا توقف ال Worker في منتصف العمل بقيت المهمة في القائمة الثانية ولم تضع، وهي نفس فكرة التأكيد Ack التي شرحناها في مفاهيم الطوابير.
  • ال Worker لا يكبر الفيديو أبداً، فملف 720p لا يصنع منه 1080p، والسبب أن التكبير يضيف بايتات دون أي تفاصيل جديدة.
  • القيمة -g 60 مع معدل 30 إطاراً تضع إطاراً مفتاحياً كل ثانيتين في كل الجودات، و -sc_threshold 0 يمنع ffmpeg من إضافة إطارات مفتاحية عند تغير المشهد، فتبدأ مقاطع كل الجودات من نفس اللحظة ويستطيع المشغل التبديل بينها.
  • فك ترميز الفيديو يحدث مرة واحدة، ثم يقسم المرشح split الصورة إلى عدة نسخ تصغر كل منها، وهذا أوفر من تشغيل ffmpeg أربع مرات على نفس الملف.
  • المقاطع ترفع بصلاحية سنة و immutable، وملفات التشغيل بصلاحية دقيقة، كما شرحنا في قسم التوصيل.
  • بعد ثلاث محاولات فاشلة تذهب المهمة إلى queue:dead بدل أن تعاد بلا نهاية، وهذا هو ال Dead Letter Queue الذي شرحناه في نفس المقال.

ثم nginx.conf، وهو يمرر الطلبات إلى نقطة الموقع الثابت في Garage مع اسم ال Bucket في ترويسة Host، ويحفظ نسخة من كل ملف:

proxy_cache_path /var/cache/nginx/vod levels=1:2 keys_zone=vod:10m max_size=2g inactive=7d use_temp_path=off;

server {
    listen 80;
    root /usr/share/nginx/html;          # player.html

    location /v/ {                       # /v/<id>/master.m3u8 -> bucket "vod" through the Garage web endpoint
        rewrite ^/v/(.*)$ /$1 break;
        proxy_pass http://garage:3902;
        proxy_set_header Host vod.web.garage;
        proxy_cache vod;                 # a tiny CDN edge: honours the Cache-Control set by the worker
        add_header X-Cache-Status $upstream_cache_status always;
    }
}

ثم صفحة المشغل player.html، وهي تحمل hls.js وتعرض الجودات المتاحة والجودة الحالية وتقدير سرعة الاتصال. ولاحظ أننا صغرنا ذاكرة المشغل المؤقتة Buffer إلى 8 ثوان حتى نرى تبديل الجودة في فيديو قصير، والقيمة الافتراضية أكبر من ذلك:

<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>lab-vod player</title>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js"></script>
<style>body{font:15px system-ui;margin:16px;background:#111;color:#eee} video{width:960px;background:#000} #log{white-space:pre;font:13px Consolas,monospace}</style>
</head>
<body>
<video id="v" controls muted playsinline></video>
<div id="log"></div>
<script>
const id = new URLSearchParams(location.search).get('id');
const video = document.getElementById('v'), log = document.getElementById('log');
const state = window.labState = { levels: [], switches: [], errors: [] };
const hls = window.hls = new Hls({ maxBufferLength: 8, maxMaxBufferLength: 8 });   // small buffer so the lab shows switching
hls.on(Hls.Events.MANIFEST_PARSED, (_, d) => {
  state.levels = d.levels.map(l => `${l.height}p@${Math.round(l.bitrate / 1000)}k`);
  video.play();
});
hls.on(Hls.Events.LEVEL_SWITCHED, (_, d) => {
  state.switches.push({ t: +video.currentTime.toFixed(1), level: state.levels[d.level] });
});
hls.on(Hls.Events.ERROR, (_, d) => state.errors.push(d.details));
hls.loadSource(`/v/${id}/master.m3u8`);
hls.attachMedia(video);
setInterval(() => {
  log.textContent = `levels: ${state.levels.join('  ')}\n` +
    `playing: ${state.levels[hls.currentLevel] || '-'}   time: ${video.currentTime.toFixed(1)} s   ` +
    `bandwidth estimate: ${Math.round(hls.bandwidthEstimate / 1000)} kbps\n` +
    `switches: ${state.switches.map(s => s.t + 's→' + s.level).join('  ')}`;
}, 250);
</script>
</body>
</html>

وأخيراً compose.yml، وكل المنافذ فيه على 127.0.0.1 فقط، وال Worker يعمل بنسختين:

name: lab-vod
services:
  garage:
    image: dxflrs/garage:v2.4.1
    command: ["/garage", "server", "--single-node"]
    volumes:
      - ./garage.toml:/etc/garage.toml:ro
      - garage:/var/lib/garage
  valkey:
    image: valkey/valkey:9.1.2-alpine
  api:
    build: .
    image: lab-vod-app:1
    command: ["python", "api.py"]
    env_file: s3.env
    environment: { S3_ENDPOINT: "http://garage:3900", VALKEY_HOST: valkey }
    ports: ["127.0.0.1:38981:8000"]
  worker:
    image: lab-vod-app:1
    command: ["python", "-u", "worker.py"]
    env_file: s3.env
    environment: { S3_ENDPOINT: "http://garage:3900", VALKEY_HOST: valkey }
    deploy: { replicas: 2 }
  edge:
    image: nginx:1.31.6-alpine
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./player.html:/usr/share/nginx/html/player.html:ro
    ports: ["127.0.0.1:38980:80"]
volumes:
  garage:

تجهيز Garage

ملف إعداد Garage يشبه ما شرحناه في تجربة Garage، مع إضافة قسم [s3_web] الذي يخدم أي Bucket مسموح له كموقع ثابت، ويحدد ال Bucket من ترويسة Host:

cat > garage.toml <<EOT
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "$(openssl rand -hex 32)"

[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"

[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"

[admin]
api_bind_addr = "[::]:3903"
admin_token = "$(openssl rand -base64 32)"
EOT

ثم نشغل Garage وValkey، وننشئ ال Bucket الأول للملفات الأصلية والثاني للمقاطع، ومفتاحاً له صلاحية القراءة والكتابة عليهما، ثم نسمح لل Bucket الثاني فقط بأن يخدم كموقع:

docker compose up -d garage valkey
G="docker exec lab-vod-garage-1 /garage"
$G bucket create uploads
$G bucket create vod
$G key create vod-app
$G bucket allow --read --write uploads --key vod-app
$G bucket allow --read --write vod --key vod-app
$G bucket website --allow vod

والمخرج المهم هو المفتاح، وقد أخفينا السر:

Key ID:              GK79ba14432b3a7f5f74101d54
Key name:            vod-app
Secret key:          32750c22…
Can create buckets:  false

ضع المفتاح في ملف s3.env بجانب compose.yml، ثم ابن ال Image وشغل بقية الخدمات:

cat > s3.env <<'EOF'
AWS_ACCESS_KEY_ID=GK79ba14432b3a7f5f74101d54
AWS_SECRET_ACCESS_KEY=ضع-السر-هنا
EOF
chmod 600 s3.env
docker compose up -d --build
docker compose ps --format '{{.Name}} {{.Status}}'

والمخرج سوف يكون كما يلي:

lab-vod-api-1 Up 16 seconds
lab-vod-edge-1 Up About a minute
lab-vod-garage-1 Up About a minute
lab-vod-valkey-1 Up About a minute
lab-vod-worker-1 Up 13 seconds
lab-vod-worker-2 Up 11 seconds

رفع ملفين ومراقبة الطابور

نحتاج ملفين للتجربة، والأمر التالي يولد بال ffmpeg الموجود في ال Image محاضرة وهمية بدقة 1080p مدتها 30 ثانية، ومقطعاً مصوراً من هاتف بدقة 720p مدته 20 ثانية:

mkdir -p media
docker run --rm -v "$PWD/media:/m" --entrypoint ffmpeg lab-vod-app:1 -hide_banner -loglevel error -y \
  -f lavfi -i testsrc2=size=1920x1080:rate=30:duration=30 -f lavfi -i sine=frequency=440:duration=30 \
  -c:v libx264 -preset fast -crf 18 -pix_fmt yuv420p -c:a aac -b:a 192k -shortest /m/lecture-01.mp4
docker run --rm -v "$PWD/media:/m" --entrypoint ffmpeg lab-vod-app:1 -hide_banner -loglevel error -y \
  -f lavfi -i smptehdbars=size=1280x720:rate=30:duration=20 -f lavfi -i sine=frequency=330:duration=20 \
  -c:v libx264 -preset fast -crf 20 -pix_fmt yuv420p -c:a aac -b:a 128k -shortest /m/phone-clip.mp4

الآن نرفع الملفين متتاليين، ثم ننظر بعد ثانيتين إلى قائمة المهام قيد التنفيذ وطول الطابور، ثم حالة الفيديو الأول:

curl -s -X PUT --data-binary @media/lecture-01.mp4 http://127.0.0.1:38981/upload/lecture-01.mp4; echo
curl -s -X PUT --data-binary @media/phone-clip.mp4 http://127.0.0.1:38981/upload/phone-clip.mp4; echo
sleep 2
docker exec lab-vod-valkey-1 valkey-cli LRANGE queue:processing 0 -1
docker exec lab-vod-valkey-1 valkey-cli LLEN queue:transcode
curl -s http://127.0.0.1:38981/videos/89eaa239e7d3; echo

والمخرج سوف يكون كما يلي:

{"id": "89eaa239e7d3", "status": "queued"}
{"id": "f459421f5300", "status": "queued"}
f459421f5300
89eaa239e7d3
0
{"status": "processing", "name": "lecture-01.mp4", "bytes": "36382607", "queued_at": "1791509936.9446902", "worker": "3714292e1af3", "started_at": "1791509936.9651227"}

لاحظ أن الرد وصل فوراً بالرمز 202 وحالة queued، فالمستخدم لا ينتظر التحويل، وأن المهمتين صارتا في queue:processing والطابور فارغ، أي أن كل Worker أخذ مهمة. وبعد قرابة دقيقة تكون الحالة كما يلي:

curl -s http://127.0.0.1:38981/videos/89eaa239e7d3 | python -m json.tool
docker compose logs worker | grep ready
{
    "status": "ready",
    "name": "lecture-01.mp4",
    "bytes": "36382607",
    "queued_at": "1791509936.9446902",
    "worker": "3714292e1af3",
    "started_at": "1791509936.9651227",
    "finished_at": "1791510012.3997903",
    "hls": "/v/89eaa239e7d3/master.m3u8",
    "dash": "/v/89eaa239e7d3/manifest.mpd",
    "duration": "30.0",
    "renditions": "1080p,720p,480p,360p",
    "encode_seconds": "67.2",
    "package_seconds": "1.8",
    "files": "53"
}
worker-1  | 89eaa239e7d3 ready {'duration': 30.0, 'renditions': '1080p,720p,480p,360p', 'encode_seconds': 67.2, 'package_seconds': 1.8, 'files': 53}
worker-2  | f459421f5300 ready {'duration': 20.0, 'renditions': '720p,480p,360p', 'encode_seconds': 21.4, 'package_seconds': 1.0, 'files': 31}

في المخرج أعلاه لاحظ التالي:

  • تحويل 30 ثانية إلى أربع جودات أخذ 67 ثانية، أي أكثر من ضعف مدة الفيديو، والسبب أن العاملين يعملان في نفس الوقت على نفس الجهاز ويتقاسمان المعالج، وهذا الرقم هو ما تحسب به عدد ال Workers: ساعات الفيديو المرفوعة يومياً مضروبة في زمن التحويل لكل ساعة.
  • التجزئة أخذت أقل من ثانيتين، فهي نسخ ونقل لبايتات جاهزة وليست ترميزاً.
  • المقطع المصور بدقة 720p حصل على ثلاث جودات فقط، لأن ال Worker لا يكبر الفيديو.

ماذا كتب ال Worker؟

نقرأ ملف التشغيل الرئيسي عبر nginx، ثم ملف جودة 720p:

curl -s http://127.0.0.1:38980/v/89eaa239e7d3/master.m3u8
curl -s http://127.0.0.1:38980/v/89eaa239e7d3/720p/main.m3u8 | head -12
#EXTM3U
## Generated with https://github.com/shaka-project/shaka-packager version v3.9.3-0a8ba4f-debug

#EXT-X-INDEPENDENT-SEGMENTS

#EXT-X-MEDIA:TYPE=AUDIO,URI="audio/main.m3u8",GROUP-ID="audio",NAME="main",DEFAULT=NO,AUTOSELECT=YES,CHANNELS="1"

#EXT-X-STREAM-INF:BANDWIDTH=4713516,AVERAGE-BANDWIDTH=4646609,CODECS="avc1.640028,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=30.000,AUDIO="audio",CLOSED-CAPTIONS=NONE
1080p/main.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2739410,AVERAGE-BANDWIDTH=2635785,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=1280x720,FRAME-RATE=30.000,AUDIO="audio",CLOSED-CAPTIONS=NONE
720p/main.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1176558,AVERAGE-BANDWIDTH=1132152,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=854x480,FRAME-RATE=30.000,AUDIO="audio",CLOSED-CAPTIONS=NONE
480p/main.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=755588,AVERAGE-BANDWIDTH=734098,CODECS="avc1.64001e,mp4a.40.2",RESOLUTION=640x360,FRAME-RATE=30.000,AUDIO="audio",CLOSED-CAPTIONS=NONE
360p/main.m3u8
#EXTM3U
#EXT-X-VERSION:6
## Generated with https://github.com/shaka-project/shaka-packager version v3.9.3-0a8ba4f-debug
#EXT-X-TARGETDURATION:5
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MAP:URI="init.mp4"
#EXTINF:4.000,
1.m4s
#EXTINF:4.000,
2.m4s
#EXTINF:4.000,
3.m4s

ثم بداية ملف DASH لنفس الفيديو:

curl -s http://127.0.0.1:38980/v/89eaa239e7d3/manifest.mpd | sed -n 3,12p
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" … profiles="urn:mpeg:dash:profile:isoff-live:2011" minBufferTime="PT2S" type="static" mediaPresentationDuration="PT30S">
  <Period id="0">
    <AdaptationSet id="0" contentType="video" maxWidth="1920" maxHeight="1080" frameRate="15360/512" segmentAlignment="true" par="16:9">
      <Representation id="0" bandwidth="4582990" codecs="avc1.640028" mimeType="video/mp4" sar="1:1" width="1920" height="1080">
        <SegmentTemplate timescale="15360" initialization="1080p/init.mp4" media="1080p/$Number$.m4s" startNumber="1">
          <SegmentTimeline>
            <S t="0" d="61440" r="6"/>
            <S t="430080" d="30720"/>
          </SegmentTimeline>
        </SegmentTemplate>

لاحظ أن الملفين يشيران إلى نفس المقاطع 1080p/1.m4s وما بعدها، فلا يوجد إلا نسخة واحدة من الوسائط، وأن ملف DASH يذكر segmentAlignment="true" لأن حدود المقاطع واحدة في كل الجودات. ولاحظ أيضاً أن معدل 1080p الفعلي قرابة 4.6 ميجابت بينما طلبنا 4.5، وهذا طبيعي لأن الترويسات والصوت تضاف إليه. أما المقطع المصور بالهاتف فهذه جوداته الثلاث:

#EXT-X-STREAM-INF:BANDWIDTH=162949,AVERAGE-BANDWIDTH=152725 ... RESOLUTION=1280x720,FRAME-RATE=30.000,AUDIO="audio",CLOSED-CAPTIONS=NONE
#EXT-X-STREAM-INF:BANDWIDTH=173755,AVERAGE-BANDWIDTH=156485 ... RESOLUTION=854x480,FRAME-RATE=30.000,AUDIO="audio",CLOSED-CAPTIONS=NONE
#EXT-X-STREAM-INF:BANDWIDTH=168627,AVERAGE-BANDWIDTH=153592 ... RESOLUTION=640x360,FRAME-RATE=30.000,AUDIO="audio",CLOSED-CAPTIONS=NONE

وهذا هو درس Per-Title بأعيننا: طلبنا لجودة 720p معدل 2500 كيلوبت، فلم يجد المرمز ما يصرفها عليه في أشرطة ألوان ثابتة وأخرج قرابة 160 كيلوبت فقط، وصارت الجودات الثلاث بنفس الحجم تقريباً، فلا فائدة من وجود 480p و360p لهذا الفيديو، وسلم مخصص له كان سيكتفي بدرجة واحدة.

وأخيراً حجم المخرجات للفيديو الأول في ال Bucket، بحسب كل جودة:

1080p       16.94 MB
360p         2.27 MB
480p         3.76 MB
720p         9.40 MB
audio        0.49 MB
manifests    0.02 MB
total       32.88 MB

أي أن الجودات الأربع معاً أصغر قليلاً من الملف الأصلي 36.38 ميجابايت، ونصف الحجم تقريباً في درجة 1080p وحدها، وهذا ما استخدمناه في التقدير في بداية المقال.

نقطة ال Cache أمام التخزين

نطلب نفس المقطع مرتين، ثم ملف التشغيل الرئيسي:

for i in 1 2; do curl -s -o /dev/null -D - http://127.0.0.1:38980/v/89eaa239e7d3/720p/3.m4s \
  | grep -iE "^(HTTP|content-type|cache-control|x-cache)"; done
curl -s -o /dev/null -D - http://127.0.0.1:38980/v/89eaa239e7d3/master.m3u8 | grep -iE "^(content-type|cache-control|x-cache)"
HTTP/1.1 200 OK
Content-Type: video/iso.segment
cache-control: public, max-age=31536000, immutable
X-Cache-Status: MISS
HTTP/1.1 200 OK
Content-Type: video/iso.segment
cache-control: public, max-age=31536000, immutable
X-Cache-Status: HIT
Content-Type: application/vnd.apple.mpegurl
cache-control: public, max-age=60
X-Cache-Status: HIT

الطلب الأول ذهب إلى Garage وكان MISS، والثاني خدمه nginx من القرص دون أن يلمس التخزين، وهذا هو ما تفعله كل نقطة CDN، وملف التشغيل كان HIT لأننا طلبناه في الخطوة السابقة، وسوف ينتهي بعد دقيقة كما حددنا.

التشغيل في المتصفح وتبديل الجودة

افتح http://127.0.0.1:38980/player.html?id=89eaa239e7d3 في المتصفح، وسوف يبدأ الفيديو بأعلى جودة لأن الاتصال المحلي سريع. ولكي نرى ال ABR يعمل، شغلنا الصفحة في Chrome وبعد 6 ثوان خفضنا سرعة الشبكة من أدوات المطور إلى 1.2 ميجابت في الثانية، والصورة التالية من التجربة:

صفحة المشغل في Chrome تعرض صورة الاختبار بالألوان، وتحتها سطر الجودات المتاحة 360p عند 756 كيلوبت و480p عند 1176 و720p عند 2733 و1080p عند 4708، والجودة الحالية 360p عند الثانية 21 وتقدير السرعة 569 كيلوبت، وسطر التبديلات من 1080p في الثانية 0 إلى 360p في الثانية 16.1
بعد خفض السرعة نزل المشغل من 1080p إلى 360p دون أن يتوقف الفيديو

لاحظ أن المشغل لم ينتقل إلى 480p رغم أن السرعة 1.2 ميجابت، والسبب أن تقديره للسرعة كان 569 كيلوبت فقط، فأدوات المطور تحسب الترويسات وزمن الذهاب والعودة، وال hls.js يترك هامش أمان حتى لا يتوقف الفيديو، وهذا ما تريده: صورة أقل وضوحاً أفضل من دائرة تحميل.

نكسر التجربة: Worker يتوقف في منتصف التحويل

نرفع المحاضرة مرة أخرى، وبعد 6 ثوان نوقف ال Workers بالقوة، ثم ننظر إلى القائمة وحالة الفيديو:

curl -s -X PUT --data-binary @media/lecture-01.mp4 http://127.0.0.1:38981/upload/lecture-01.mp4; echo
sleep 6
docker compose kill worker
docker exec lab-vod-valkey-1 valkey-cli LRANGE queue:processing 0 -1
curl -s http://127.0.0.1:38981/videos/53e00b771ff8; echo
53e00b771ff8
 Container lab-vod-worker-1 Killed
 Container lab-vod-worker-2 Killed
53e00b771ff8
{"status": "processing", "name": "lecture-01.mp4", "bytes": "36382607", "queued_at": "1791504034.047006", "worker": "3865eb4da062", "started_at": "1791504034.0487874"}

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

docker exec lab-vod-valkey-1 valkey-cli LMOVE queue:processing queue:transcode RIGHT LEFT
docker compose start worker
curl -s http://127.0.0.1:38981/videos/53e00b771ff8; echo
53e00b771ff8
 Container lab-vod-worker-1 Started
{"status": "ready", "name": "lecture-01.mp4", "bytes": "36382607", "queued_at": "1791504034.047006", "worker": "e5d6e543a614", "started_at": "1791504082.640255", "finished_at": "1791504138.1283057", "hls": "/v/53e00b771ff8/master.m3u8", "dash": "/v/53e00b771ff8/manifest.mpd", "duration": "30.0", "renditions": "1080p,720p,480p,360p", "encode_seconds": "48.5", "package_seconds": "1.6", "files": "53"}

والرد الأخير هو حالة الفيديو بعد انتهاء التحويل من جديد. ولاحظ أن ال Worker الجديد أعاد العمل من البداية، لأن ملفات الجزء الأول كانت في /tmp داخل ال Container الذي توقف، وهذا مقبول لفيديو مدته 30 ثانية، أما لفيلم مدته ساعتان فهذا هو سبب تقسيم Netflix للفيديو إلى أجزاء وحفظ كل لقطة فور انتهائها، كما رأينا في قسم ترميز اللقطات.

حذف المختبر

docker compose down -v
docker image rm lab-vod-app:1

أخطاء شائعة

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

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • منصة الفيديو عند الطلب نظامان: معالجة مرة لكل رفع تتحمل التأخير، وتوصيل مع كل تشغيل يجب أن يكون سريعاً وقريباً من المشاهد، فافصل بينهما من اليوم الأول.
  • الرفع يذهب مباشرة إلى تخزين الكائنات وقابل للاستئناف، والتطبيق يصدر الروابط ويضع المهمة في الطابور فقط، والملف الأصلي يبقى محفوظاً.
  • سلم الجودات الثابت بداية معقولة، وNetflix وفرت 20% وأكثر بسلم لكل فيديو، ثم أكثر من النصف بترميز كل لقطة، وهذا يستحق ثمنه عندما يشاهد كل فيديو مرات كثيرة جداً.
  • CMAF يعني نسخة واحدة من المقاطع لملفي HLS وDASH، والمقاطع تحتاج إطارات مفتاحية في نفس المواضع في كل الجودات.
  • DRM يحمي من النسخ السهل ويطبق قيود العقود، ويحتاج اتفاقات وتكلفة، فلا تضفه لمحتوى لا يحتاجه.
  • النقل الخارج هو البند الذي يقرر التكلفة، وOpen Connect حل Netflix له بوضع الأجهزة داخل شبكات المزودين وملئها ليلاً، وحلك يبدأ بسلم مناسب وCDN وسعر نقل معروف.
  • التوصيات تعتمد على ما يفعله المشاهد فعلاً لا على تقييمه، وجودة التجربة تقاس من المشغل لا من السيرفر.
  • إذا كانت حاجتك مكتبة فيديو لمؤسستك فابدأ ب PeerTube، وإذا كانت المنصة منتجك فالقطع المفتوحة في هذا المقال تكفي لبداية حقيقية.

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

دليلكيف تبني تطبيق بث مباشر مثل Jaco وTwitchكيف يصل بث المذيع إلى آلاف المشاهدين بتأخير ثانية أو ثانيتين، ومعه دردشة وهدايا لا تتوقف عند الذروة.دليلكيف تبني تطبيق فيديو قصير مثل TikTokكيف يجهز تطبيق الفيديو القصير المقطع التالي قبل أن تسحب الشاشة، وكيف يختار ما يناسبك من إشارات المشاهدة.دليلكيف تعمل شبكة توصيل المحتوى CDN وما الذي تحفظهلماذا يبطؤ موقعك على الزائر البعيد، وكيف تقرب شبكة CDN ملفاتك منه، وكيف تمنعها من حفظ الصفحات الخاصة.دليلالتكاليف الخفية في السحابة وكيف تخفضهاالبنود التي لا تظهر في حساب يوم التصميم ثم تملأ فاتورة السحابة بعد أشهر، وكيف تحسب كل بند وتخفضه.دليلالطابور Queue للمبتدئين: لماذا تحتاجه وكيف يعمللماذا تضع العمل البطيء في طابور بدل أن ينتظره المستخدم، وكيف تضمن ألا تضيع الرسالة إذا توقف ال Worker فجأة.دليلأنواع التخزين وواجهة S3: أين تضع بيانات تطبيقك؟أين تضع قاعدة البيانات وملفات المستخدمين والنسخ الاحتياطية، ولماذا يناسب كلاً منها نوع مختلف من التخزين.

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

  • أكتوبر 2026: إضافة قسم المعمارية الكاملة: المخطط والمكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو.
  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة العملية على Docker Engine 29.8.0 وDocker Compose 5.5.1 مع FFmpeg 8.1.2 وShaka Packager 3.9.3 وGarage 2.4.1 وValkey 9.1.2 وnginx 1.31.6 وhls.js 1.7.3.
نشرة عرب رووت | ArabRoot

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

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

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

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