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

ביצוע מניפולציות על הרכיבים של שידור הווידאו.

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

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

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

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

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

תהליך עבודה של עיבוד וידאו

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

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

ה-WebCodecs API פועל היטב יחד עם המחלקות מ-Insertable Streams API, שמחברות רכיבי WebCodec לטראקים של סטרימינג של מדיה.

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

רכיבי WebCodec ועובדי אינטרנט

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

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

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

רכיבי WebCodec בפעולה

קידוד

הנתיב מלוח הציור או 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:

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

פענוח הקוד

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

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

קבוצת הפרמטרים של הקודק משתנה מקודק למקודד. לדוגמה, יכול להיות שה-codec 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 אם אפשר לפענח את המקטע רק אחרי פענוח של מקטע אחד לפחות

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

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 Panel בכלי הפיתוח ל-Chrome כדי להציג יומני מדיה ולנפות באגים ב-WebCodecs.

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

הדגמה (דמו)

בהדגמה הבאה אפשר לראות איך פריימים של אנימציה מלוח הציור:

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

הדגמות אחרות

נסה גם את ההדגמות האחרות שלנו:

שימוש ב-WebCodecs API

זיהוי תכונות

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

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

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

משוב

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

לספר לנו על עיצוב ה-API

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

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

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

הבעת תמיכה ב-API

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

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

תמונה ראשית (Hero) מאת דניז ג'נס ב-UnFlood.