معالجة الفيديو باستخدام WebCodecs

معالجة مكوّنات بث الفيديو

Eugene Zemtsov
Eugene Zemtsov
François Beaufort
François Beaufort

توفر تقنيات الويب الحديثة طرقًا عديدة للتعامل مع الفيديو. Media Stream API وMedia Record API وMedia Source API وWebRTC API يضيفان ما يصل إلى مجموعة أدوات تفاعلية لتسجيل الفيديوهات لبثها ونقلها وتشغيلها. أثناء حلّ بعض المهام عالية المستوى، لا تسمح واجهات برمجة التطبيقات هذه للمبرمجين على الويب بالعمل مع المكونات الفردية لبث الفيديو المباشر مثل الإطارات والأجزاء غير المركّبة من الفيديو أو الصوت المشفّر. للحصول على إمكانية وصول منخفض المستوى إلى هذه المكونات الأساسية، كان المطوّرون يستخدمون WebAssembly لإدخال برامج ترميز الفيديو والصوت في المتصفّح. ولكن نظرًا لأن المتصفحات الحديثة تشحن بالفعل مجموعة متنوعة من برامج الترميز (التي غالبًا ما يتم تسريعها بواسطة الأجهزة)، فإن إعادة إنشاء حزمة لها على شكل WebAssembly يبدو وكأنه هدر للموارد البشرية وأجهزة الكمبيوتر.

تقضي WebCodecs API على مشكلة عدم الكفاءة من خلال منح المبرمجين طريقة لاستخدام مكوّنات الوسائط المتوفّرة في المتصفّح. وهذه القيود تحديدًا هي كالآتي:

  • برامج فك ترميز الفيديو والصوت
  • برامج ترميز الفيديو والصوت
  • إطارات الفيديو غير المعدّلة
  • برامج فك ترميز الصور

تعد واجهة برمجة التطبيقات WebCodecs API مفيدة لتطبيقات الويب التي تتطلب تحكُّمًا كاملاً في طريقة معالجة محتوى الوسائط، مثل أدوات تحرير الفيديو ومؤتمرات الفيديو وبث الفيديو وغير ذلك.

سير عمل معالجة الفيديو

الإطارات هي المحور الرئيسي في معالجة الفيديو. وبالتالي في WebCodecs، تستهلك معظم الفئات الإطارات أو تنتجها. تحوّل برامج ترميز الفيديو الإطارات إلى أجزاء مشفرة. عكس برامج فك ترميز الفيديوهات.

تعمل VideoFrame أيضًا بشكل جيد مع واجهات برمجة تطبيقات الويب الأخرى لأنّها CanvasImageSource وتوفّر أداة إنشاء تقبل CanvasImageSource. لذلك يمكن استخدامها في دوال مثل drawImage() وtexImage2D(). كما يمكن إنشاؤها من اللوحات والصور النقطية وعناصر الفيديو وإطارات الفيديو الأخرى.

تعمل واجهة WebCodecs API بشكل جيد جنبًا إلى جنب مع الفئات من Insertable Streams API التي تربط WebCodecs بـ مسارات بث الوسائط.

  • يقسّم MediaStreamTrackProcessor مقاطع الوسائط إلى إطارات فردية.
  • ينشئ MediaStreamTrackGenerator مسار وسائط من مجموعة من الإطارات.

برامج ترميز الويب وعاملو الويب

وحسب التصميم، تنفّذ واجهة برمجة التطبيقات WebCodecs API كل المهام الصعبة بشكلٍ غير متزامن وخارج سلسلة المحادثات الرئيسية. ولكن نظرًا لأنه يمكن غالبًا استدعاء استدعاءات الإطار والمقطع عدة مرات في الثانية، فقد تؤدي إلى فوضى سلسلة التعليمات الرئيسية وبالتالي تجعل موقع الويب أقل استجابة. وبالتالي، يُفضَّل نقل معالجة الإطارات الفردية والأجزاء المشفرة إلى عامل ويب.

للمساعدة في ذلك، توفِّر ReadableStream طريقة ملائمة لنقل جميع الإطارات تلقائيًا من مسار وسائط إلى العامل. على سبيل المثال، يمكن استخدام MediaStreamTrackProcessor للحصول على ReadableStream لمسار بث وسائط صادر من كاميرا الويب. بعد ذلك، يتم نقل البث إلى مشغّل على الويب حيث تتم قراءة الإطارات واحدة تلو الأخرى ووضعها في قائمة الانتظار على VideoEncoder.

ويمكن حتى العرض من سلسلة التعليمات الرئيسية باستخدام HTMLCanvasElement.transferControlToOffscreen. ولكن إذا تبيَّن أن جميع الأدوات عالية المستوى غير ملائمة، فإن VideoFrame بحد ذاته يمكن نقله ويمكن نقله بين العاملين.

WebCodecs عمليًا

الترميز

المسار من لوحة رسم أو ImageBitmap إلى الشبكة أو إلى مساحة التخزين
المسار من Canvas أو ImageBitmap إلى الشبكة أو إلى مساحة التخزين

كلّ شيء يبدأ بـ VideoFrame. هناك ثلاث طرق لإنشاء إطارات الفيديو.

  • من مصدر صورة، مثل لوحة أو صورة نقطية أو عنصر فيديو

    const canvas = document.createElement("canvas");
    // Draw something on the canvas...
    
    const frameFromCanvas = new VideoFrame(canvas, { timestamp: 0 });
    
  • يمكنك استخدام MediaStreamTrackProcessor لسحب الإطارات من MediaStreamTrack.

    const stream = await navigator.mediaDevices.getUserMedia({…});
    const track = stream.getTracks()[0];
    
    const trackProcessor = new MediaStreamTrackProcessor(track);
    
    const reader = trackProcessor.readable.getReader();
    while (true) {
      const result = await reader.read();
      if (result.done) break;
      const frameFromCamera = result.value;
    }
    
  • إنشاء إطار من تمثيل البكسل الثنائي في BufferSource

    const pixelSize = 4;
    const init = {
      timestamp: 0,
      codedWidth: 320,
      codedHeight: 200,
      format: "RGBA",
    };
    const data = new Uint8Array(init.codedWidth * init.codedHeight * pixelSize);
    for (let x = 0; x < init.codedWidth; x++) {
      for (let y = 0; y < init.codedHeight; y++) {
        const offset = (y * init.codedWidth + x) * pixelSize;
        data[offset] = 0x7f;      // Red
        data[offset + 1] = 0xff;  // Green
        data[offset + 2] = 0xd4;  // Blue
        data[offset + 3] = 0x0ff; // Alpha
      }
    }
    const frame = new VideoFrame(data, init);
    

بغضّ النظر عن مصدر الإطارات، يمكن ترميزها في كائنات EncodedVideoChunk باستخدام VideoEncoder.

قبل التشفير، يجب منح VideoEncoder عنصرَي JavaScript:

  • قم بإنشاء القاموس باستخدام دالتين للتعامل مع الأجزاء والأخطاء المشفرة. هذه الدوال يحدّدها المطوّر ولا يمكن تغييرها بعد تمريرها إلى الدالة الإنشائية VideoEncoder.
  • كائن إعداد برنامج الترميز الذي يحتوي على مَعلمات لمصدر الفيديو الناتج. يمكنك تغيير هذه المعلمات لاحقًا عن طريق طلب الرقم configure().

ستطرح الطريقة configure() الخطأ NotSupportedError إذا لم يكن المتصفِّح متوافقًا مع الإعدادات. ننصحك باستخدام الطريقة الثابتة VideoEncoder.isConfigSupported() مع الإعدادات للتأكّد مسبقًا ممّا إذا كانت الإعدادات متوافقة وانتظار وعدها.

const init = {
  output: handleChunk,
  error: (e) => {
    console.log(e.message);
  },
};

const config = {
  codec: "vp8",
  width: 640,
  height: 480,
  bitrate: 2_000_000, // 2 Mbps
  framerate: 30,
};

const { supported } = await VideoEncoder.isConfigSupported(config);
if (supported) {
  const encoder = new VideoEncoder(init);
  encoder.configure(config);
} else {
  // Try another config.
}

بعد إعداد برنامج الترميز، يصبح جاهزًا لقبول اللقطات من خلال طريقة encode(). يعود كل من configure() وencode() على الفور بدون انتظار اكتمال العمل الفعلي. يسمح هذا الترميز لعدّة إطارات بوضعها في قائمة انتظار الترميز في الوقت نفسه، بينما يعرض encodeQueueSize عدد الطلبات المعلّقة في قائمة الانتظار إلى حين انتهاء عمليات الترميز السابقة. يتم الإبلاغ عن الأخطاء إما من خلال طرح استثناء فوري في حال كانت الوسيطات أو ترتيب استدعاءات الطريقة تنتهك عقد واجهة برمجة التطبيقات أو من خلال طلب معاودة الاتصال بـ error() للمشاكل التي تمت مواجهتها في تنفيذ برنامج الترميز. إذا أكمل الترميز بنجاح عملية معاودة الاتصال output()، يتم استدعاء مقطع مشفَّر جديد كوسيطة. تفصيل آخر مهم هنا هو أنّه يجب إخبار الإطارات عند عدم الحاجة إليها من خلال استدعاء close().

let frameCounter = 0;

const track = stream.getVideoTracks()[0];
const trackProcessor = new MediaStreamTrackProcessor(track);

const reader = trackProcessor.readable.getReader();
while (true) {
  const result = await reader.read();
  if (result.done) break;

  const frame = result.value;
  if (encoder.encodeQueueSize > 2) {
    // Too many frames in flight, encoder is overwhelmed
    // let's drop this frame.
    frame.close();
  } else {
    frameCounter++;
    const keyFrame = frameCounter % 150 == 0;
    encoder.encode(frame, { keyFrame });
    frame.close();
  }
}

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

function handleChunk(chunk, metadata) {
  if (metadata.decoderConfig) {
    // Decoder needs to be configured (or reconfigured) with new parameters
    // when metadata has a new decoderConfig.
    // Usually it happens in the beginning or when the encoder has a new
    // codec specific binary configuration. (VideoDecoderConfig.description).
    fetch("/upload_extra_data", {
      method: "POST",
      headers: { "Content-Type": "application/octet-stream" },
      body: metadata.decoderConfig.description,
    });
  }

  // actual bytes of encoded data
  const chunkData = new Uint8Array(chunk.byteLength);
  chunk.copyTo(chunkData);

  fetch(`/upload_chunk?timestamp=${chunk.timestamp}&type=${chunk.type}`, {
    method: "POST",
    headers: { "Content-Type": "application/octet-stream" },
    body: chunkData,
  });
}

إذا أردت في وقت ما التأكد من اكتمال جميع طلبات الترميز التي تنتظر المراجعة، يمكنك طلب الترميز flush() والانتظار إلى حين اكتماله.

await encoder.flush();

فك الترميز

المسار من الشبكة أو مساحة التخزين إلى لوحة رسم أو ImageBitmap.
المسار من الشبكة أو مساحة التخزين إلى Canvas أو ImageBitmap.

يشبه إعداد VideoDecoder ما تم إجراؤه في VideoEncoder: يتم تمرير دالّتَين عند إنشاء برنامج فك الترميز، وتخصيص معلَمات برنامج الترميز إلى configure().

تختلف مجموعة معلمات برنامج الترميز من برنامج ترميز إلى آخر. على سبيل المثال، قد يحتاج برنامج ترميز H.264 إلى كائن ثنائي كبير من برنامج AVCC، ما لم يتم ترميزه بما يُعرف بتنسيق الملحق B (encoderConfig.avc = { format: "annexb" }).

const init = {
  output: handleFrame,
  error: (e) => {
    console.log(e.message);
  },
};

const config = {
  codec: "vp8",
  codedWidth: 640,
  codedHeight: 480,
};

const { supported } = await VideoDecoder.isConfigSupported(config);
if (supported) {
  const decoder = new VideoDecoder(init);
  decoder.configure(config);
} else {
  // Try another config.
}

بعد إعداد برنامج فك الترميز، يمكنك البدء في تزويده بكائنات EncodedVideoChunk. لإنشاء مقطع، ستحتاج إلى:

  • BufferSource من بيانات الفيديو المشفّرة
  • الطابع الزمني لبدء المقطع بالميكرو ثانية (وقت الوسائط للإطار المشفر الأول في المقطع)
  • نوع المقطع، وهو أحد ما يلي:
    • key إذا كان من الممكن فك ترميز المقطع بشكل مستقل عن المقاطع السابقة
    • delta إذا كان بالإمكان فك ترميز المقطع بعد فك ترميز مقطع واحد أو أكثر من المقاطع السابقة

وتكون أي أجزاء يُصدرها برنامج الترميز جاهزة لعملية فك الترميز كما هي. وتنطبق أيضًا جميع النقاط المذكورة أعلاه بخصوص الإبلاغ عن الأخطاء والطبيعة غير المتزامنة لطرق برنامج الترميز على برامج فك الترميز أيضًا.

const responses = await downloadVideoChunksFromServer(timestamp);
for (let i = 0; i < responses.length; i++) {
  const chunk = new EncodedVideoChunk({
    timestamp: responses[i].timestamp,
    type: responses[i].key ? "key" : "delta",
    data: new Uint8Array(responses[i].body),
  });
  decoder.decode(chunk);
}
await decoder.flush();

حان الوقت الآن لإظهار كيف يمكن عرض إطار حديث تم فك ترميزه في الصفحة. ومن الأفضل التأكّد من أنّ معاودة الاتصال بأداة فك الترميز (handleFrame()) تظهر بسرعة. في المثال أدناه، تقوم فقط بإضافة إطار إلى قائمة انتظار الإطارات الجاهزة للعرض. يتم العرض بشكل منفصل ويتألف من خطوتَين:

  1. في انتظار الوقت المناسب لعرض الإطار.
  2. رسم الإطار على اللوحة.

وإذا لم تعُد هناك حاجة إلى إطار، يمكنك استدعاء close() لتحرير الذاكرة الأساسية قبل وصول أداة تجميع البيانات المهملة إليها، سيؤدي ذلك إلى تقليل متوسط مساحة الذاكرة التي يستخدمها تطبيق الويب.

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
let pendingFrames = [];
let underflow = true;
let baseTime = 0;

function handleFrame(frame) {
  pendingFrames.push(frame);
  if (underflow) setTimeout(renderFrame, 0);
}

function calculateTimeUntilNextFrame(timestamp) {
  if (baseTime == 0) baseTime = performance.now();
  let mediaTime = performance.now() - baseTime;
  return Math.max(0, timestamp / 1000 - mediaTime);
}

async function renderFrame() {
  underflow = pendingFrames.length == 0;
  if (underflow) return;

  const frame = pendingFrames.shift();

  // Based on the frame's timestamp calculate how much of real time waiting
  // is needed before showing the next frame.
  const timeUntilNextFrame = calculateTimeUntilNextFrame(frame.timestamp);
  await new Promise((r) => {
    setTimeout(r, timeUntilNextFrame);
  });
  ctx.drawImage(frame, 0, 0);
  frame.close();

  // Immediately schedule rendering of the next frame
  setTimeout(renderFrame, 0);
}

نصائح للمطوّرين

استخدِم لوحة الوسائط في "أدوات مطوري البرامج في Chrome" لعرض سجلات الوسائط وتصحيح أخطاء WebCodecs.

لقطة شاشة للوحة الوسائط لتصحيح أخطاء WebCodecs
لوحة الوسائط في "أدوات مطوري البرامج في Chrome" لتصحيح أخطاء برامج WebCodes

تجريبي

يوضح العرض التوضيحي أدناه كيف تكون إطارات الرسوم المتحركة من لوحة رسم:

  • تم التقاطها بمعدل 25 لقطة في الثانية في ReadableStream بواسطة MediaStreamTrackProcessor
  • تم نقلها إلى عامل ويب
  • مشفَّر إلى تنسيق فيديو H.264
  • فك ترميزه مرة أخرى إلى سلسلة من إطارات الفيديو
  • ويتم عرضه على اللوحة الثانية باستخدام transferControlToOffscreen()

العروض التوضيحية الأخرى

اطلع أيضًا على عروضنا التوضيحية الأخرى:

استخدام WebCodecs API

رصد الميزات

للتأكُّد من توفّر برامج WebCodecs:

if ('VideoEncoder' in window) {
  // WebCodecs API is supported.
}

تذكَّر أنّ واجهة برمجة التطبيقات WebCodecs API لا تتوفّر إلا في السياقات الآمنة، لذلك سيتعذر الاكتشاف إذا كانت قيمة self.isSecureContext خاطئة.

إضافة ملاحظات

يرغب فريق Chrome في التعرف على تجاربك مع WebCodecs API.

أخبرنا عن تصميم واجهة برمجة التطبيقات

هل هناك مشكلة في واجهة برمجة التطبيقات لا تعمل على النحو المتوقَّع؟ أو هل هناك طرق أو خصائص مفقودة تحتاج إلى تنفيذ فكرتك؟ هل لديك سؤال أو تعليق على نموذج الأمان؟ عليك الإبلاغ عن مشكلة في المواصفات على مستودع GitHub المقابل، أو إضافة أفكارك إلى مشكلة حالية.

الإبلاغ عن مشكلة في التنفيذ

هل واجهت خطأً في تنفيذ Chrome؟ أم أن التنفيذ مختلف عن المواصفات؟ أبلِغ عن الخطأ على new.crbug.com. احرِص على تضمين أكبر قدر ممكن من التفاصيل وتعليمات بسيطة لإعادة الإنتاج، وأدخِل Blink>Media>WebCodecs في مربّع المكونات. تعمل ميزة Glitch بشكل رائع لمشاركة عمليات إعادة الإنشاء بسرعة وسهولة.

إظهار الدعم لواجهة برمجة التطبيقات

هل تخطّط لاستخدام WebCodecs API؟ يساعد الدعم العام فريق Chrome في تحديد أولويات الميزات ويُظهر لمورّدي المتصفِّح الآخرين مدى أهمية دعمهم.

يمكنك إرسال رسائل إلكترونية إلى media-dev@chromium.org أو إرسال تغريدة إلى @ChromiumDev باستخدام الهاشتاغ #WebCodecs وإعلامنا بمكان استخدامك لها وكيفية استخدامها.

صورة رئيسية من إعداد دينيس جانز على موقع Unسبلاش