การประมวลผลวิดีโอด้วย WebCodecs

การจัดการคอมโพเนนต์สตรีมวิดีโอ

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 มีประโยชน์สําหรับเว็บแอปพลิเคชันที่ต้องควบคุมวิธีประมวลผลเนื้อหาสื่ออย่างเต็มรูปแบบ เช่น โปรแกรมตัดต่อวิดีโอ การประชุมทางวิดีโอ สตรีมมิงวิดีโอ ฯลฯ

เวิร์กโฟลว์การประมวลผลวิดีโอ

เฟรมเป็นหัวใจสำคัญในการประมวลผลวิดีโอ ดังนั้นใน WebCodecs คลาสส่วนใหญ่จะบริโภคหรือสร้างเฟรม โดยโปรแกรมเปลี่ยนไฟล์วิดีโอจะแปลงเฟรมเป็นชิ้นส่วนที่เข้ารหัส ส่วนโปรแกรมถอดรหัสวิดีโอจะทําตรงกันข้าม

นอกจากนี้ VideoFrame ยังทำงานร่วมกับ Web API อื่นๆ ได้อย่างราบรื่นโดยเป็น CanvasImageSource และมีคอนสตรคเตอร์ที่ยอมรับ CanvasImageSource ดังนั้นจึงใช้ได้ในฟังก์ชันต่างๆ เช่น drawImage() และ texImage2D() นอกจากนี้ยังสามารถสร้างจากแคนวาส บิตแมป องค์ประกอบวิดีโอ และเฟรมวิดีโออื่นๆ

WebCodecs API ทำงานร่วมกับคลาสจาก Insertable Streams API ได้ดีมาก ซึ่งจะเชื่อมต่อ WebCodecs กับแทร็กสตรีมสื่อ

  • MediaStreamTrackProcessor แบ่งแทร็กสื่อออกเป็นเฟรมแต่ละเฟรม
  • MediaStreamTrackGenerator สร้างแทร็กสื่อจากสตรีมเฟรม

WebCodecs และผู้ปฏิบัติงานทำงานบนเว็บ

WebCodecs API ออกแบบมาเพื่อทำงานหนักทั้งหมดแบบไม่พร้อมกันและไม่ได้อยู่ในเธรดหลัก แต่เนื่องจากมักเรียกใช้ Callback ที่เป็นเฟรมและชิ้นส่วนซ้ำได้หลายครั้งต่อวินาที จึงอาจทำให้เทรดหลักรกและส่งผลให้เว็บไซต์ตอบสนองน้อยลง ดังนั้นจึงควรย้ายการจัดการแต่ละเฟรมและกลุ่มที่เข้ารหัสไปยัง Web Worker

ReadableStream จะช่วยแก้ปัญหานี้ด้วยวิธีง่ายๆ ในการโอนเฟรมทั้งหมดที่มาจากแทร็กสื่อไปยังผู้ปฏิบัติงานโดยอัตโนมัติ เช่น MediaStreamTrackProcessor สามารถใช้รับ ReadableStream สำหรับแทร็กสตรีมสื่อที่มาจากเว็บแคม หลังจากนั้น ระบบจะโอนสตรีมไปยังเว็บเวิร์กเกอร์ ซึ่งจะอ่านเฟรมทีละเฟรมและจัดคิวเป็น VideoEncoder

HTMLCanvasElement.transferControlToOffscreen ช่วยให้เรนเดอร์ได้แม้ไม่ได้อยู่ในเธรดหลัก แต่หากเครื่องมือระดับสูงทั้งหมดไม่สะดวก VideoFrame เองก็โอนได้และอาจย้ายไปมาระหว่างผู้ปฏิบัติงาน

การทำงานของตัวแปลงรหัสเว็บ

การเข้ารหัส

เส้นทางจาก Canvas หรือ ImageBitmap ไปยังเครือข่ายหรือพื้นที่เก็บข้อมูล
เส้นทางจาก Canvas หรือ ImageBitmap ไปยังเครือข่ายหรือพื้นที่เก็บข้อมูล

ทุกอย่างเริ่มต้นที่ VideoFrame การสร้างเฟรมวิดีโอทำได้ 3 วิธี

  • จากแหล่งที่มาของรูปภาพ เช่น ภาพพิมพ์แคนวาส บิตแมปรูปภาพ หรือองค์ประกอบวิดีโอ

    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 2 รายการต่อไปนี้

  • เริ่มต้นพจนานุกรมด้วยฟังก์ชัน 2 รายการสำหรับจัดการกับข้อมูลโค้ดและข้อผิดพลาด ฟังก์ชันเหล่านี้จะกำหนดโดยนักพัฒนาซอฟต์แวร์และจะเปลี่ยนแปลงไม่ได้หลังจากส่งไปยังตัวสร้าง VideoEncoder
  • ออบเจ็กต์การกำหนดค่าโปรแกรมเปลี่ยนไฟล์ ซึ่งมีพารามิเตอร์สำหรับสตรีมวิดีโอเอาต์พุต คุณเปลี่ยนพารามิเตอร์เหล่านี้ในภายหลังได้โดยเรียกใช้ configure()

เมธอด configure() จะแสดง NotSupportedError หากเบราว์เซอร์ไม่รองรับการกําหนดค่า เราขอแนะนำให้เรียกใช้เมธอดแบบคงที่ VideoEncoder.isConfigSupported() ด้วย config เพื่อตรวจสอบล่วงหน้าว่าระบบรองรับ config หรือไม่ และรอให้ Promise ดำเนินการ

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 หรือโดยการเรียก 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();

กำลังถอดรหัส

เส้นทางจากเครือข่ายหรือพื้นที่เก็บข้อมูลไปยัง Canvas หรือ ImageBitmap
เส้นทางจากเครือข่ายหรือพื้นที่เก็บข้อมูลไปยัง Canvas หรือ ImageBitmap

การตั้งค่า VideoDecoder คล้ายกับการตั้งค่า VideoEncoder โดยระบบจะส่งผ่านฟังก์ชัน 2 รายการเมื่อสร้างตัวถอดรหัส และส่งพารามิเตอร์ตัวแปลงรหัสไปยัง configure()

ชุดพารามิเตอร์ตัวแปลงรหัสจะแตกต่างกันไปในแต่ละตัวแปลงรหัส เช่น ตัวแปลงรหัส H.264 อาจต้องใช้ Binary 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 หากถอดรหัสข้อมูลได้ก็ต่อเมื่อถอดรหัสข้อมูลก่อนหน้าอย่างน้อย 1 รายการแล้ว

นอกจากนี้ ข้อมูลโค้ดที่ตัวเข้ารหัสส่งออกมาจะพร้อมสําหรับโปรแกรมถอดรหัสโดยสมบูรณ์เช่นกัน สิ่งที่กล่าวไว้ข้างต้นเกี่ยวกับการรายงานข้อผิดพลาดและลักษณะการทำงานแบบไม่สอดคล้องกันของเมธอดตัวเข้ารหัสก็ใช้ได้กับโปรแกรมถอดรหัสด้วยเช่นกัน

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()) แสดงผลอย่างรวดเร็ว ในตัวอย่างด้านล่าง มีเพียงการเพิ่มเฟรมลงในคิวเฟรมที่พร้อมสำหรับการเรนเดอร์เท่านั้น การแสดงผลจะเกิดขึ้นแยกกันและมี 2 ขั้นตอน ดังนี้

  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 เพื่อดูบันทึกสื่อและแก้ไขข้อบกพร่องของ WebCodecs

ภาพหน้าจอของแผงสื่อสำหรับการแก้ไขข้อบกพร่อง WebCodecs
แผงสื่อในเครื่องมือสำหรับนักพัฒนาเว็บของ Chrome สำหรับการแก้ไขข้อบกพร่อง WebCodecs

สาธิต

ตัวอย่างด้านล่างแสดงลักษณะของเฟรมภาพเคลื่อนไหวจากผืนผ้าใบ

  • บันทึกที่ 25 fps ลงใน ReadableStream โดย MediaStreamTrackProcessor
  • โอนไปยัง Web Worker
  • เข้ารหัสเป็นรูปแบบวิดีโอ H.264
  • ถอดรหัสอีกครั้งเป็นลำดับเฟรมวิดีโอ
  • และแสดงผลบนผืนผ้าใบที่ 2 โดยใช้ 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 ในช่องคอมโพเนนต์ ภาพ Glitch เหมาะสำหรับการแชร์ซ้ำที่ง่ายและรวดเร็ว

แสดงการรองรับ API

คุณกำลังวางแผนที่จะใช้ WebCodecs API ใช่ไหม การสนับสนุนแบบสาธารณะของคุณช่วยให้ทีม Chrome จัดลำดับความสำคัญของฟีเจอร์ต่างๆ และแสดงให้ผู้ให้บริการเบราว์เซอร์รายอื่นเห็นว่าการสนับสนุนฟีเจอร์เหล่านี้นั้นสำคัญเพียงใด

ส่งอีเมลไปที่ media-dev@chromium.org หรือทวีตไปที่ @ChromiumDev โดยใช้แฮชแท็ก #WebCodecs และแจ้งให้เราทราบว่าคุณใช้ฟีเจอร์นี้ที่ไหนและอย่างไร

รูปภาพหลักโดย Denise Jans ใน Unsplash