עיבוד וידאו באמצעות רכיבי WebCodec

שינוי של רכיבי וידאו בסטרימינג.

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

טכנולוגיות אינטרנט מודרניות מספקות דרכים רבות לעבוד עם סרטונים. ‫Media Stream API,‏ Media Recording API,‏ Media Source API ו-WebRTC API הם קבוצה עשירה של כלים להקלטה, להעברה ולהפעלה של סטרימינג של וידאו. בזמן פתרון משימות מסוימות ברמה גבוהה, ממשקי ה-API האלה לא מאפשרים למתכנתי אינטרנט לעבוד עם רכיבים נפרדים של סטרימינג וידאו, כמו פריימים וקטעים לא מעורבבים של וידאו או אודיו מקודדים. כדי לקבל גישה ברמה נמוכה לרכיבים הבסיסיים האלה, מפתחים השתמשו ב-WebAssembly כדי להוסיף קודקים של וידאו ואודיו לדפדפן. אבל בהתחשב בכך שדפדפנים מודרניים כבר מגיעים עם מגוון רחב של קודקים (שבדרך כלל מואצים על ידי חומרה), אריזה מחדש שלהם כ-WebAssembly נראית כמו בזבוז של משאבים אנושיים ומחשובים.

WebCodecs API מאפשר למתכנתים להשתמש ברכיבי מדיה שכבר קיימים בדפדפן, וכך מייעל את התהליך. באופן ספציפי:

  • מפענחי וידאו ואודיו
  • מפענחי וידאו ואודיו
  • פריימים גולמיים של סרטון
  • פענוח הקוד של התמונה

ממשק WebCodecs API שימושי לאפליקציות אינטרנט שדורשות שליטה מלאה באופן העיבוד של תוכן מדיה, כמו עורכי וידאו, שיחות וידאו, סטרימינג של וידאו וכו'.

תהליך העבודה של עיבוד הסרטון

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

בנוסף, VideoFrame פועל בצורה חלקה עם ממשקי API אחרים לאינטרנט, כי הוא CanvasImageSource ויש לו constructor שמקבל CanvasImageSource. לכן אפשר להשתמש בה בפונקציות כמו drawImage() ו-texImage2D(). בנוסף, אפשר ליצור אותו מלוחות ציור, מפות סיביות, רכיבי וידאו ומסגרות וידאו אחרות.

‫WebCodecs API פועל היטב בשילוב עם המחלקות מ-Insertable Streams API, שמקשרות בין WebCodecs לבין media stream tracks.

  • MediaStreamTrackProcessor מפרק טראקים של מדיה לפריימים נפרדים.
  • MediaStreamTrackGenerator יוצרת טראק מדיה מזרם של פריימים.

‫WebCodecs ו-web workers

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

כדי לעזור בכך, ReadableStream מספק דרך נוחה להעברה אוטומטית של כל הפריים שמגיע ממסלול מדיה אל העובד. לדוגמה, אפשר להשתמש ב-MediaStreamTrackProcessor כדי לקבל ReadableStream עבור טראק של סטרימינג מדיה שמגיע ממצלמת האינטרנט. אחרי זה, הזרם מועבר ל-Web Worker שבו המסגרות נקראות אחת אחרי השנייה ומתווספות לתור ב-VideoEncoder.

עם HTMLCanvasElement.transferControlToOffscreen, אפילו הרינדור יכול להתבצע מחוץ ל-thread הראשי. אבל אם כל הכלים ברמה הגבוהה לא נוחים, אפשר להעביר את VideoFrame בין העובדים.

‫WebCodecs בפעולה

קידוד

הנתיב מ-Canvas או מ-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 אם הדפדפן לא תומך בהגדרה. מומלץ להפעיל את ה-method הסטטית 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 מראה כמה בקשות ממתינות בתור לסיום הקידודים הקודמים. השגיאות מדווחות או על ידי הפעלת חריגה באופן מיידי, במקרה שהארגומנטים או הסדר של קריאות השיטה מפרים את חוזה ה-API, או על ידי קריאה ל-callback של error() לבעיות שנתקלו בהן בהטמעה של codec. אם הקידוד מסתיים בהצלחה, מתבצעת קריאה חוזרת (callback) ל-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();

פענוח

הנתיב מהרשת או מהאחסון אל Canvas או אל ImageBitmap.
הנתיב מהרשת או מהאחסון אל Canvas או אל ImageBitmap.

הגדרת VideoDecoder דומה להגדרה של VideoEncoder: שתי פונקציות מועברות כשיוצרים את המפענח, ופרמטרים של קודק ניתנים ל-configure().

קבוצת הפרמטרים של הקודק משתנה מקודק לקודק. לדוגמה, קודק H.264 עשוי לדרוש blob בינארי של AVCC, אלא אם הוא מקודד בפורמט שנקרא Annex 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 if the chunk can only be decoded after one or more previous chunks have been decoded

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

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();

עכשיו נראה איך אפשר להציג בדף פריים שפוענח לאחרונה. מומלץ לוודא שקריאת החזרה (callback) של פלט המפענח (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);
}

טיפים למפתחים

אפשר להשתמש בחלונית Media בכלי הפיתוח ל-Chrome כדי להציג יומני מדיה ולנפות באגים ב-WebCodecs.

צילום מסך של חלונית המדיה לניפוי באגים ב-WebCodecs
חלונית Media בכלי הפיתוח ל-Chrome לניפוי באגים ב-WebCodecs.

הדגמה (דמו)

ההדגמה מראה איך מסגרות אנימציה מלוח ציור:

  • צולם ב-25fps לתוך ReadableStream על ידי MediaStreamTrackProcessor
  • הועבר ל-web worker
  • מקודד בפורמט וידאו H.264
  • מפוענח שוב לרצף של פריימים של וידאו
  • ומעובד בלוח הציור השני באמצעות transferControlToOffscreen()

הדגמות אחרות

כדאי גם לעיין בהדגמות האחרות שלנו:

שימוש ב-WebCodecs API

זיהוי תכונות

כדי לבדוק אם יש תמיכה ב-WebCodecs:

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

חשוב לזכור ש-WebCodecs API זמין רק בהקשרים מאובטחים, ולכן הזיהוי ייכשל אם הערך של self.isSecureContext הוא false.

מידע נוסף

אם אתם חדשים ב-WebCodecs, במאמר WebCodecs Fundamentals תוכלו למצוא מאמרים מפורטים עם הרבה דוגמאות שיעזרו לכם ללמוד עוד.

משוב

צוות Chrome רוצה לשמוע על החוויות שלכם עם WebCodecs API.

מהו עיצוב ה-API?

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

דיווח על בעיה בהטמעה

מצאתם באג בהטמעה של Chrome? או שההטמעה שונה מהמפרט? מדווחים על הבאג בכתובת new.crbug.com. חשוב לכלול כמה שיותר פרטים, הוראות פשוטות לשחזור הבאג ומזינים Blink>Media>WebCodecs בתיבה Components.

תמיכה ב-API

האם אתם מתכננים להשתמש ב-WebCodecs API? התמיכה הציבורית שלכם עוזרת לצוות Chrome לתעדף תכונות ומראה לספקי דפדפנים אחרים עד כמה חשוב לתמוך בהן.

אפשר לשלוח אימייל לכתובת media-dev@chromium.org או לצייץ לכתובת @ChromiumDev עם ההאשטאג #WebCodecs ולספר לנו איפה ואיך אתם משתמשים בו.

תמונה ראשית (Hero) מאת Denise Jans ב-Unsplash.