پردازش ویدیو با WebCodecs

دستکاری اجزای جریان ویدئو.

یوجین زمتسوف
Eugene Zemtsov
فرانسوا بوفور
François Beaufort

فن آوری های وب مدرن راه های زیادی برای کار با ویدیو ارائه می دهند. Media Stream API ، Media Recording API ، Media Source API و WebRTC API به مجموعه ابزاری غنی برای ضبط، انتقال و پخش جریان‌های ویدئویی اضافه می‌شوند. در حین حل برخی از وظایف سطح بالا، این APIها به برنامه نویسان وب اجازه نمی دهند با اجزای مجزای یک جریان ویدیویی مانند فریم ها و تکه های غیرمکس شده ویدیو یا صدای رمزگذاری شده کار کنند. برای دسترسی سطح پایین به این مؤلفه‌های اساسی، توسعه‌دهندگان از WebAssembly برای آوردن کدک‌های ویدیویی و صوتی به مرورگر استفاده می‌کنند. اما با توجه به اینکه مرورگرهای مدرن در حال حاضر با انواع کدک ها (که اغلب توسط سخت افزار تسریع می شوند) عرضه می شوند، بسته بندی مجدد آنها به عنوان WebAssembly به نظر اتلاف منابع انسانی و رایانه ای است.

WebCodecs API این ناکارآمدی را با دادن راهی به برنامه نویسان برای استفاده از مؤلفه های رسانه ای که قبلاً در مرورگر وجود دارد، از بین می برد. به طور مشخص:

  • رسیورهای صوتی و تصویری
  • انکودرهای صوتی و تصویری
  • فریم های ویدئویی خام
  • رسیورهای تصویر

WebCodecs API برای برنامه های کاربردی وب که نیاز به کنترل کامل بر نحوه پردازش محتوای رسانه دارند، مانند ویرایشگرهای ویدئو، کنفرانس ویدئویی، پخش ویدئو و غیره مفید است.

گردش کار پردازش ویدیو

فریم ها محور اصلی پردازش ویدیو هستند. بنابراین در WebCodec ها اکثر کلاس ها یا فریم مصرف می کنند یا تولید می کنند. رمزگذارهای ویدیویی فریم ها را به تکه های کدگذاری شده تبدیل می کنند. رمزگشاهای ویدئویی برعکس عمل می کنند.

همچنین VideoFrame از طریق CanvasImageSource بودن و داشتن سازنده ای که CanvasImageSource می پذیرد، به خوبی با سایر API های وب بازی می کند. بنابراین می توان از آن در توابعی مانند drawImage() و texImage2D() استفاده کرد. همچنین می توان آن را از بوم، بیت مپ، عناصر ویدئویی و سایر فریم های ویدئویی ساخت.

WebCodecs API به خوبی در کنار کلاس‌های Insertable Streams API که WebCodecs را به آهنگ‌های جریان رسانه متصل می‌کند، کار می‌کند.

  • MediaStreamTrackProcessor آهنگ های رسانه را به فریم های جداگانه تقسیم می کند.
  • MediaStreamTrackGenerator یک آهنگ رسانه ای را از یک جریان فریم ایجاد می کند.

WebCodec ها و کارگران وب

با طراحی WebCodecs API تمام کارهای سنگین را به صورت ناهمزمان و خارج از رشته اصلی انجام می دهد. اما از آنجایی که اغلب می‌توان تماس‌های فریم و تکه‌ای را چندین بار در ثانیه فراخوانی کرد، ممکن است موضوع اصلی را به هم ریخته و در نتیجه وب‌سایت را کمتر پاسخگو کنند. بنابراین ترجیح داده می‌شود که مدیریت فریم‌ها و تکه‌های کدگذاری‌شده را به یک وب‌کارگر منتقل کنید.

برای کمک به این امر، ReadableStream یک راه راحت برای انتقال خودکار تمام فریم‌هایی که از یک مسیر رسانه‌ای به کارگر می‌آیند، ارائه می‌کند. به عنوان مثال، MediaStreamTrackProcessor می تواند برای به دست آوردن یک ReadableStream برای یک مسیر جریان رسانه ای که از دوربین وب می آید استفاده شود. پس از آن جریان به یک وب کارگر منتقل می شود که در آن فریم ها یکی یکی خوانده می شوند و در یک VideoEncoder در صف قرار می گیرند.

با HTMLCanvasElement.transferControlToOffscreen حتی می توان رندر را خارج از موضوع اصلی انجام داد. اما اگر همه ابزارهای سطح بالا ناخوشایند بودند، VideoFrame خود قابل انتقال است و ممکن است بین کارگران جابجا شود.

WebCodec ها در عمل

رمزگذاری

مسیر از 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);
    

مهم نیست که فریم ها از کجا می آیند، فریم ها را می توان با یک VideoEncoder در اشیاء EncodedVideoChunk کدگذاری کرد.

قبل از رمزگذاری، باید به VideoEncoder دو شی جاوا اسکریپت داده شود:

  • دیکشنری راه اندازی با دو عملکرد برای مدیریت تکه ها و خطاهای رمزگذاری شده. این توابع توسط برنامه‌نویس تعریف شده‌اند و پس از ارسال به سازنده 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 نشان می دهد که چه تعداد درخواست در صف منتظر هستند تا کدهای قبلی تکمیل شوند. خطاها یا با پرتاب فوری یک استثنا، در صورتی که آرگومان‌ها یا ترتیب فراخوانی‌های متد قرارداد API را نقض کند، یا با فراخوانی callback 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();

رمزگشایی

مسیر از شبکه یا ذخیره سازی به Canvas یا 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);
}

نکات برنامه نویس

برای مشاهده گزارش‌های رسانه و اشکال‌زدایی WebCodec‌ها، از پنل رسانه در ابزار توسعه‌دهنده Chrome استفاده کنید.

اسکرین شات از پنل رسانه برای اشکال زدایی وب کدک ها
پنل رسانه در کروم DevTools برای اشکال زدایی WebCodecs.

نسخه ی نمایشی

دمو زیر نشان می دهد که فریم های انیمیشن از یک بوم چگونه هستند:

  • با سرعت 25 فریم بر ثانیه در ReadableStream توسط MediaStreamTrackProcessor گرفته شده است
  • به یک کارمند وب منتقل می شود
  • به فرمت ویدیویی H.264 کدگذاری شده است
  • دوباره به دنباله ای از فریم های ویدئو رمزگشایی شد
  • و با استفاده از transferControlToOffscreen() بر روی بوم دوم رندر می شود.

دموهای دیگر

همچنین دموهای دیگر ما را بررسی کنید:

با استفاده از WebCodecs API

تشخیص ویژگی

برای بررسی پشتیبانی WebCodecs:

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

به خاطر داشته باشید که WebCodecs API فقط در زمینه‌های امن موجود است، بنابراین اگر self.isSecureContext نادرست باشد، شناسایی با شکست مواجه می‌شود.

بازخورد

تیم Chrome می‌خواهد در مورد تجربیات شما با WebCodecs API بشنود.

در مورد طراحی API به ما بگویید

آیا چیزی در مورد API وجود دارد که آنطور که انتظار داشتید کار نمی کند؟ یا آیا روش ها یا ویژگی هایی وجود دارد که برای اجرای ایده خود به آنها نیاز دارید؟ سوال یا نظری در مورد مدل امنیتی دارید؟ یک مشکل مشخصات را در مخزن GitHub مربوطه ثبت کنید یا افکار خود را به یک مشکل موجود اضافه کنید.

گزارش مشکل در اجرا

آیا اشکالی در پیاده سازی کروم پیدا کردید؟ یا اجرا با مشخصات متفاوت است؟ یک اشکال را در new.crbug.com ثبت کنید. اطمینان حاصل کنید که تا جایی که می توانید جزئیات، دستورالعمل های ساده برای بازتولید را وارد کنید و Blink>Media>WebCodecs در کادر Components وارد کنید. Glitch برای به اشتراک گذاری سریع و آسان تکرارها عالی عمل می کند.

پشتیبانی از API را نشان دهید

آیا قصد دارید از WebCodecs API استفاده کنید؟ پشتیبانی عمومی شما به تیم Chrome کمک می‌کند ویژگی‌ها را اولویت‌بندی کند و به سایر فروشندگان مرورگر نشان می‌دهد که چقدر حمایت از آنها ضروری است.

ایمیل‌هایی را به media-dev@chromium.org ارسال کنید یا با استفاده از هشتگ #WebCodecs یک توییت به @ChromiumDev ارسال کنید و به ما اطلاع دهید که کجا و چگونه از آن استفاده می‌کنید.

تصویر قهرمان توسط Denise Jans در Unsplash .