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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

הדגמה (דמו)

ההדגמה הבאה מראה איך מסגרות אנימציה מתוך אזור העריכה:

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

הדגמות נוספות

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

שימוש ב-WebCodecs API

זיהוי תכונות

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

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

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

משוב

צוות Chrome רוצה לשמוע על החוויות שלך בשימוש ב-WebCodecs API.

מתארים את עיצוב ה-API

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

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

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

הצגת תמיכה ב-API

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

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

תמונה ראשית (Hero) מאת דניס ג'נס ב-Un אימיילים.