การประมวลผลวิดีโอด้วย 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 และ Web Worker

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

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

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

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

การเข้ารหัส

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

ทั้งหมดเริ่มต้นที่ VideoFrame การสร้างเฟรมวิดีโอมี 3 วิธี

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

    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() ที่มีการกำหนดค่าเพื่อตรวจสอบล่วงหน้าว่าระบบรองรับการกำหนดค่าดังกล่าวหรือไม่และรอให้ระบบสัญญาไว้

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() พร้อมส่วนที่เข้ารหัสใหม่เป็นอาร์กิวเมนต์ รายละเอียดที่สำคัญอีกอย่างคือคุณต้องบอกเฟรมเมื่อไม่จำเป็นต้องใช้แล้วโดยเรียกใช้ 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 อาจต้องใช้ 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();

ตอนนี้ได้เวลาแสดงวิธีแสดงเฟรมที่ถอดรหัสใหม่ในหน้าเว็บแล้ว แต่ควรตรวจสอบว่าโค้ดเรียกกลับเอาต์พุตตัวถอดรหัส (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 DevTools สำหรับการแก้ไขข้อบกพร่องของ 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