Xử lý video bằng WebCodecs

Thao tác với các thành phần của luồng video.

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

Các công nghệ web hiện đại cung cấp nhiều cách để làm việc với video. Media Stream API, Media Recording API, Media Source API, và WebRTC API tạo thành một bộ công cụ phong phú để ghi, truyền và phát các luồng video. Mặc dù giải quyết một số tác vụ cấp cao nhất định, nhưng các API này không cho phép lập trình viên web làm việc với các thành phần riêng lẻ của luồng video, chẳng hạn như khung hình và các khối video hoặc âm thanh đã mã hoá không được ghép kênh. Để có quyền truy cập cấp thấp vào các thành phần cơ bản này, nhà phát triển đã sử dụng WebAssembly để đưa bộ mã hoá và giải mã video và âm thanh vào trình duyệt. Tuy nhiên, vì các trình duyệt hiện đại đã được trang bị nhiều bộ mã hoá và giải mã (thường được tăng tốc bằng phần cứng), nên việc đóng gói lại các bộ mã hoá và giải mã này dưới dạng WebAssembly có vẻ như là một sự lãng phí tài nguyên của con người và máy tính.

WebCodecs API loại bỏ sự kém hiệu quả này bằng cách cung cấp cho lập trình viên một cách để sử dụng các thành phần nội dung nghe nhìn đã có trong trình duyệt. Cụ thể:

  • Bộ giải mã video và âm thanh
  • Bộ mã hoá video và âm thanh
  • Khung video thô
  • Bộ giải mã hình ảnh

WebCodecs API rất hữu ích cho các ứng dụng web yêu cầu kiểm soát hoàn toàn cách xử lý nội dung nghe nhìn, chẳng hạn như trình chỉnh sửa video, hội nghị truyền hình, phát trực tuyến video, v.v.

Quy trình xử lý video

Khung hình là trung tâm của quá trình xử lý video. Do đó, trong WebCodecs, hầu hết các lớp đều sử dụng hoặc tạo khung hình. Bộ mã hoá video chuyển đổi khung hình thành các khối đã mã hoá. Bộ giải mã video thực hiện điều ngược lại.

Ngoài ra, VideoFrame hoạt động tốt với các API Web khác bằng cách là CanvasImageSource và có một hàm khởi tạo chấp nhận CanvasImageSource. Vì vậy, bạn có thể sử dụng hàm này trong các hàm như drawImage()texImage2D(). Ngoài ra, bạn có thể tạo hàm này từ canvas, bitmap, phần tử video và các khung video khác.

WebCodecs API hoạt động tốt cùng với các lớp từ Insertable Streams API kết nối WebCodecs với các bản nhạc luồng nội dung nghe nhìn.

  • MediaStreamTrackProcessor chia các bản nhạc nội dung nghe nhìn thành các khung hình riêng lẻ.
  • MediaStreamTrackGenerator tạo một bản nhạc nội dung nghe nhìn từ một luồng khung hình.

WebCodecs và web worker

Theo thiết kế, WebCodecs API thực hiện tất cả các thao tác nặng một cách không đồng bộ và ngoài luồng chính. Tuy nhiên, vì lệnh gọi lại khung hình và khối thường có thể được gọi nhiều lần mỗi giây, nên chúng có thể làm lộn xộn luồng chính và do đó làm cho trang web ít phản hồi hơn. Do đó, bạn nên chuyển việc xử lý các khung hình riêng lẻ và các khối đã mã hoá vào một web worker.

Để giúp bạn thực hiện việc đó, ReadableStream cung cấp một cách thuận tiện để tự động chuyển tất cả các khung hình đến từ một bản nhạc nội dung nghe nhìn sang worker. Ví dụ: bạn có thể sử dụng MediaStreamTrackProcessor để lấy ReadableStream cho một bản nhạc luồng nội dung nghe nhìn đến từ webcam. Sau đó, luồng này được chuyển đến một web worker, nơi các khung hình được đọc từng khung hình một và được xếp vào hàng đợi trong VideoEncoder.

Với HTMLCanvasElement.transferControlToOffscreen, ngay cả quá trình kết xuất cũng có thể được thực hiện ngoài luồng chính. Tuy nhiên, nếu tất cả các công cụ cấp cao đều không thuận tiện, thì bản thân VideoFrame có thể chuyển được và có thể được di chuyển giữa các worker.

WebCodecs trong thực tế

Mã hóa

Đường dẫn từ Canvas hoặc ImageBitmap đến mạng hoặc bộ nhớ
Đường dẫn từ Canvas hoặc ImageBitmap đến mạng hoặc đến bộ nhớ

Tất cả bắt đầu bằng VideoFrame. Có 3 cách để tạo khung video.

  • Từ nguồn hình ảnh như canvas, bitmap hình ảnh hoặc phần tử video.

    const canvas = document.createElement("canvas");
    // Draw something on the canvas...
    
    const frameFromCanvas = new VideoFrame(canvas, { timestamp: 0 });
    
  • Sử dụng MediaStreamTrackProcessor để kéo khung hình từ 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;
    }
    
  • Tạo khung hình từ biểu diễn pixel nhị phân trong 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);
    

Bất kể chúng đến từ đâu, khung hình có thể được mã hoá thành các đối tượng EncodedVideoChunk bằng VideoEncoder.

Trước khi mã hoá, VideoEncoder cần được cung cấp 2 đối tượng JavaScript:

  • Từ điển khởi động với 2 hàm để xử lý các khối đã mã hoá và lỗi. Các hàm này do nhà phát triển xác định và không thể thay đổi sau khi được truyền đến hàm khởi tạo VideoEncoder.
  • Đối tượng cấu hình bộ mã hoá, chứa các tham số cho luồng video đầu ra. Bạn có thể thay đổi các tham số này sau bằng cách gọi configure().

Phương thức configure() sẽ gửi NotSupportedError nếu trình duyệt không hỗ trợ cấu hình. Bạn nên gọi phương thức tĩnh VideoEncoder.isConfigSupported() bằng cấu hình để kiểm tra trước xem cấu hình có được hỗ trợ hay không và chờ lời hứa của phương thức đó.

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.
}

Sau khi thiết lập, bộ mã hoá đã sẵn sàng chấp nhận khung hình thông qua phương thức encode(). Cả configure()encode() đều trả về ngay lập tức mà không cần chờ công việc thực tế hoàn tất. Điều này cho phép một số khung hình xếp hàng đợi để mã hoá cùng một lúc, trong khi encodeQueueSize cho biết có bao nhiêu yêu cầu đang chờ trong hàng đợi để các mã hoá trước đó hoàn tất. Lỗi được báo cáo bằng cách gửi một ngoại lệ ngay lập tức (trong trường hợp các đối số hoặc thứ tự gọi phương thức vi phạm hợp đồng API) hoặc bằng cách gọi lệnh gọi lại error() cho các vấn đề gặp phải trong quá trình triển khai bộ mã hoá và giải mã. Nếu quá trình mã hoá hoàn tất thành công, lệnh gọi lại output() sẽ được gọi với một khối đã mã hoá mới làm đối số. Một chi tiết quan trọng khác ở đây là bạn cần cho khung hình biết khi nào không còn cần nữa bằng cách gọi 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();
  }
}

Cuối cùng, đã đến lúc hoàn tất mã mã hoá bằng cách viết một hàm xử lý các khối video đã mã hoá khi chúng xuất hiện từ bộ mã hoá. Thông thường, hàm này sẽ gửi các khối dữ liệu qua mạng hoặc ghép kênh chúng vào một vùng chứa nội dung nghe nhìn để lưu trữ.

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

Nếu tại một thời điểm nào đó, bạn cần đảm bảo rằng tất cả các yêu cầu mã hoá đang chờ xử lý đã hoàn tất, bạn có thể gọi flush() và chờ lời hứa của phương thức đó.

await encoder.flush();

Giải mã

Đường dẫn từ mạng hoặc bộ nhớ đến Canvas hoặc ImageBitmap.
Đường dẫn từ mạng hoặc bộ nhớ đến Canvas hoặc ImageBitmap.

Việc thiết lập VideoDecoder tương tự như những gì đã thực hiện cho VideoEncoder: 2 hàm được truyền khi bộ giải mã được tạo và các tham số bộ mã hoá và giải mã được cung cấp cho configure().

Tập hợp các tham số bộ mã hoá và giải mã khác nhau giữa các bộ mã hoá và giải mã. Ví dụ: bộ mã hoá và giải mã H.264 có thể cần một BLOB nhị phân của AVCC, trừ phi blob đó được mã hoá ở định dạng Phụ lục B (được gọi là 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.
}

Sau khi khởi chạy bộ giải mã, bạn có thể bắt đầu cung cấp cho bộ giải mã các đối tượng EncodedVideoChunk. Để tạo một khối, bạn cần:

  • A BufferSource của dữ liệu video đã mã hoá
  • dấu thời gian bắt đầu của khối tính bằng micrô giây (thời gian nội dung nghe nhìn của khung hình đã mã hoá đầu tiên trong khối)
  • loại của khối, một trong các loại sau:
    • key nếu khối có thể được giải mã độc lập với các khối trước đó
    • delta nếu khối chỉ có thể được giải mã sau khi một hoặc nhiều khối trước đó đã được giải mã

Ngoài ra, mọi khối do bộ mã hoá phát ra đều sẵn sàng cho bộ giải mã. Tất cả những điều đã nói ở trên về việc báo cáo lỗi và bản chất không đồng bộ của các phương thức của bộ mã hoá cũng đúng đối với bộ giải mã.

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

Bây giờ, đã đến lúc cho thấy cách hiển thị một khung hình mới được giải mã trên trang. Bạn nên đảm bảo rằng lệnh gọi lại đầu ra của bộ giải mã (handleFrame()) trả về nhanh chóng. Trong ví dụ bên dưới, lệnh gọi lại này chỉ thêm một khung hình vào hàng đợi khung hình đã sẵn sàng để kết xuất. Quá trình kết xuất diễn ra riêng biệt và bao gồm 2 bước:

  1. Chờ thời điểm thích hợp để hiển thị khung hình.
  2. Vẽ khung hình trên canvas.

Khi không còn cần khung hình nữa, hãy gọi close() để giải phóng bộ nhớ cơ bản trước khi trình thu gom rác truy cập vào khung hình đó. Điều này sẽ giảm lượng bộ nhớ trung bình mà ứng dụng web sử dụng.

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

Mẹo cho nhà phát triển

Sử dụng Bảng điều khiển nội dung nghe nhìn trong Chrome DevTools để xem nhật ký nội dung nghe nhìn và gỡ lỗi WebCodecs.

Ảnh chụp màn hình của Bảng điều khiển nội dung nghe nhìn để gỡ lỗi WebCodecs
Bảng điều khiển nội dung nghe nhìn trong Chrome DevTools để gỡ lỗi WebCodecs.

Bản minh hoạ

Bản minh hoạ cho thấy cách các khung hình ảnh động từ canvas:

  • được MediaStreamTrackProcessor thu thập ở tốc độ 25 khung hình/giây vào ReadableStream
  • được chuyển đến một web worker
  • được mã hoá thành định dạng video H.264
  • được giải mã lại thành một chuỗi khung video
  • và được kết xuất trên canvas thứ hai bằng transferControlToOffscreen()

Các bản minh hoạ khác

Bạn cũng có thể xem các bản minh hoạ khác của chúng tôi:

Sử dụng WebCodecs API

Phát hiện tính năng

Cách kiểm tra xem WebCodecs có được hỗ trợ hay không:

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

Xin lưu ý rằng WebCodecs API chỉ có trong các ngữ cảnh an toàn, vì vậy, quá trình phát hiện sẽ không thành công nếu self.isSecureContext là false.

Tìm hiểu thêm

Nếu bạn mới làm quen với WebCodecs, thì WebCodecs Fundamentals cung cấp các bài viết chuyên sâu với nhiều ví dụ để giúp bạn tìm hiểu thêm.

Phản hồi

Nhóm Chrome muốn biết trải nghiệm của bạn về WebCodecs API.

Hãy cho chúng tôi biết về thiết kế API

Có điều gì đó về API không hoạt động như bạn mong đợi không? Hoặc có phương thức hoặc thuộc tính nào bị thiếu mà bạn cần để triển khai ý tưởng của mình không? Bạn có câu hỏi hoặc nhận xét về mô hình bảo mật không? Báo cáo vấn đề về thông số kỹ thuật trên kho lưu trữ GitHub tương ứng hoặc thêm ý kiến của bạn vào một vấn đề hiện có.

Báo cáo vấn đề về quá trình triển khai

Bạn có phát hiện thấy lỗi trong quá trình triển khai của Chrome không? Hoặc quá trình triển khai có khác với thông số kỹ thuật không? Báo cáo lỗi tại new.crbug.com. Hãy nhớ cung cấp càng nhiều thông tin chi tiết càng tốt, hướng dẫn đơn giản để tái tạo và nhập Blink>Media>WebCodecs vào hộp Components (Thành phần).

Thể hiện sự ủng hộ dành cho API

Bạn có dự định sử dụng WebCodecs API không? Sự ủng hộ công khai của bạn giúp nhóm Chrome ưu tiên các tính năng và cho các nhà cung cấp trình duyệt khác biết mức độ quan trọng của việc hỗ trợ các tính năng đó.

Gửi email đến media-dev@chromium.org hoặc gửi một tweet đến @ChromiumDev bằng hashtag #WebCodecs và cho chúng tôi biết bạn đang sử dụng API này ở đâu và như thế nào.