Xử lý video bằng WebCodecs

Sửa đổi các thành phần của luồng video.

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

Công nghệ web hiện đại cung cấp nhiều cách thức để làm việc với video. API Luồng truyền thông, API Ghi nội dung nghe nhìn, API Nguồn nội dung nghe nhìnAPI WebRTC bổ sung vào một bộ công cụ đa dạng để ghi, truyền và phát luồng video. Trong khi giải quyết một số tác vụ cấp cao, các API này không cho phép trình lập trình 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ư các khung hình và các đoạn video hoặc âm thanh chưa mã hoá. Để có quyền truy cập cấp thấp vào các thành phần cơ bản này, các 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, do các trình duyệt hiện đại đã cung cấp 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 chúng dưới dạng WebAssembly có vẻ lãng phí tài nguyên của con người và máy tính.

WebCodecs API loại bỏ tình trạng kém hiệu quả này bằng cách cung cấp cho các lập trình viên 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 hình video thô
  • Bộ giải mã hình ảnh

WebCodecs API hữu ích cho các ứng dụng web yêu cầu toàn quyền kiểm soát 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 là tâm điểm trong 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. Bộ mã hoá video sẽ chuyển đổi khung hình thành các đoạn được mã hoá. Bộ giải mã video làm 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 trở thành CanvasImageSource và có một hàm khởi tạo chấp nhận CanvasImageSource. Do đó, bạn có thể dùng các hàm này trong các hàm như drawImage()texImage2D(). Ngoài ra, hình ảnh cũng có thể được tạo từ canvas, bitmap, các thành phần video và các khung video khác.

API WebCodecs hoạt động tốt song song với các lớp trong API luồng có thể chèn, giúp kết nối WebCodecs với các bản luồng nội dung đa phương tiện.

  • MediaStreamTrackProcessor chia bản nhạc đa phương tiện thành từng khung hình riêng lẻ.
  • MediaStreamTrackGenerator tạo một bản nhạc đa phương tiện từ một luồng khung.

WebCodec và trình thực thi web

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

Để hỗ trợ việc này, 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ừ bản theo dõi nội dung đa phương tiện sang worker. Ví dụ: Bạn có thể sử dụng MediaStreamTrackProcessor để lấy ReadableStream cho bản nhạc luồng đa phương tiện từ máy ảnh web. Sau đó, luồng được chuyển sang một trình thực thi web, trong đó các khung được đọc lần lượt từng khung và đưa vào hàng đợi vào VideoEncoder.

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

WebCodec trong thực tế

Mã hoá

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

Tất cả đều bắt đầu bằng một VideoFrame. Có ba cách để tạo khung hình video.

  • Từ mộ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 để lấy 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 một khung bằng cách 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ể bạn đến từ đâu, khung hình có thể được mã hoá thành đối tượng EncodedVideoChunk bằng VideoEncoder.

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

  • Khởi tạo từ điển với 2 hàm để xử lý các đoạn và lỗi đã mã hoá. 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 vào lúc khác bằng cách gọi configure().

Phương thức configure() sẽ gửi NotSupportedError nếu cấu hình không được trình duyệt hỗ trợ. 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 cấu hình đó.

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 xong, bộ mã hoá đã sẵn sàng chấp nhận khung hình bằng 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. Tính năng này cho phép một số khung hình xếp hàng để mã hoá cùng lúc, trong khi encodeQueueSize cho biết số lượng yêu cầu đang chờ trong hàng đợi để các quá trình mã hoá trước đó hoàn tất. Bạn có thể báo cáo lỗi bằng cách ngay lập tức gửi một ngoại lệ, trong trường hợp các đối số hoặc thứ tự các lệnh 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() đối với các sự cố gặp phải khi 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 đoạn được mã hoá mới làm đối số. Một chi tiết quan trọng khác ở đây là bạn cần thông báo cho các khung hình khi không 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ã hoá bằng cách viết một hàm xử lý các đoạn video đã mã hoá khi chúng thoát ra khỏi bộ mã hoá. Thông thường, hàm này sẽ gửi các phần dữ liệu qua mạng hoặc kết hợp các phần đó 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 vào một thời điểm nào đó, 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, thì bạn có thể gọi flush() và chờ lời hứa của lệnh gọi đó.

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ư cách thiết lập 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 cấp cho configure().

Bộ tham số của bộ mã hoá và giải mã khác nhau tuỳ theo 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 tệp đó được mã hoá theo định dạng Phụ lục 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.
}

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

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

Ngoài ra, mọi đoạn 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à tính 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();

Đây là lúc cho thấy cách hiển thị một khung 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()) sẽ nhanh chóng trả về. Trong ví dụ bên dưới, tuỳ chọn này chỉ thêm một khung vào hàng đợi gồm các 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 hai bước:

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

Khi một khung không còn cần thiết 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 xử lý. Việc này sẽ làm giảm dung 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 Công cụ của Chrome cho nhà phát triển để xem nhật ký nội dung nghe nhìn và gỡ lỗi WebCodec.

Ảnh chụp màn hình Bảng điều khiển nội dung nghe nhìn để gỡ lỗi WebCodec
Bảng điều khiển nội dung nghe nhìn trong Công cụ của Chrome cho nhà phát triển để gỡ lỗi WebCodec.

Bản minh hoạ

Bản minh hoạ dưới đây cho thấy cách thức khung ảnh động từ canvas:

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

Bản minh hoạ khác

Ngoài ra, hãy xem các bản minh hoạ khác của chúng tôi:

Sử dụng API WebCodecs

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

Để kiểm tra xem WebCodecs có hỗ trợ hay không:

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

Xin lưu ý rằng API WebCodecs chỉ hoạt động trong ngữ cảnh bảo mật, vì vậy, việc phát hiện sẽ không thành công nếu self.isSecureContext sai.

Ý kiến phản hồi

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

Cho chúng tôi biết về thiết kế của API

Có điều gì về API không hoạt động như bạn mong đợi không? Hay có phương thức hoặc thuộc tính nào bị thiếu mà bạn cần triển khai không? Bạn có thắc mắc hoặc nhận xét về mô hình bảo mật? Gửi 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 vào vấn đề hiện có.

Báo cáo sự cố với quá trình triển khai

Bạn có phát hiện thấy lỗi khi triển khai Chrome không? Hay cách triển khai có khác với thông số kỹ thuật không? Gửi lỗi tại new.crbug.com. Hãy nhớ cung cấp nhiều thông tin chi tiết nhất có thể, hướng dẫn đơn giản để tái tạo rồi nhập Blink>Media>WebCodecs vào hộp Components (Thành phần). Sự cố rất hữu ích trong việc chia sẻ các bản sao nhanh và dễ dàng.

Hỗ trợ API

Bạn có định sử dụng API WebCodecs không? Sự hỗ trợ 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 tầm quan trọng của việc hỗ trợ họ.

Bạn có thể gửi email đến media-dev@chromium.org hoặc gửi tweet đến @ChromiumDev kèm theo hashtag #WebCodecs, đồng thời cho chúng tôi biết vị trí và cách bạn đang sử dụng.

Hình ảnh chính của Denise Jans trên Unsplash.