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에서 대부분의 클래스는 프레임을 소비하거나 생성합니다. 동영상 인코더는 프레임을 인코딩된 청크로 변환합니다. 동영상 디코더는 그 반대입니다.

또한 VideoFrameCanvasImageSource이고 CanvasImageSource을 허용하는 생성자가 있어 다른 웹 API와 잘 작동합니다. 따라서 drawImage()texImage2D()과 같은 함수에서 사용할 수 있습니다. 캔버스, 비트맵, 동영상 요소, 기타 동영상 프레임으로 구성할 수도 있습니다.

WebCodecs API는 WebCodecs를 미디어 스트림 트랙에 연결하는 Insertable Streams API의 클래스와 함께 잘 작동합니다.

  • MediaStreamTrackProcessor는 미디어 트랙을 개별 프레임으로 나눕니다.
  • MediaStreamTrackGenerator는 프레임 스트림에서 미디어 트랙을 만듭니다.

WebCodecs 및 웹 작업자

설계상 WebCodecs API는 기본 스레드에서 비동기적으로 모든 작업을 처리합니다. 하지만 프레임 및 청크 콜백은 초당 여러 번 호출될 수 있으므로 기본 스레드를 어수선하게 만들어 웹사이트의 응답성을 떨어뜨릴 수 있습니다. 따라서 개별 프레임과 인코딩된 청크의 처리를 웹 작업자로 이동하는 것이 좋습니다.

이를 위해 ReadableStream은 미디어 트랙에서 오는 모든 프레임을 워커로 자동 전송하는 편리한 방법을 제공합니다. 예를 들어 MediaStreamTrackProcessor를 사용하여 웹 카메라에서 오는 미디어 스트림 트랙의 ReadableStream를 가져올 수 있습니다. 그런 다음 스트림이 웹 작업자로 전송되며, 여기서 프레임이 하나씩 읽혀 VideoEncoder에 대기열로 추가됩니다.

HTMLCanvasElement.transferControlToOffscreen를 사용하면 렌더링도 기본 스레드 외부에서 실행할 수 있습니다. 하지만 모든 상위 수준 도구가 불편한 것으로 판명되면 VideoFrame 자체는 전송 가능하며 작업자 간에 이동할 수 있습니다.

WebCodecs 실제 사용

인코딩

캔버스 또는 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에 다음 두 JavaScript 객체를 제공해야 합니다.

  • 인코딩된 청크와 오류를 처리하는 두 함수로 사전 초기화 이러한 함수는 개발자가 정의하며 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() 콜백을 호출하여 보고합니다. 인코딩이 성공적으로 완료되면 새 인코딩된 청크를 인수로 사용하여 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 코덱은 소위 부록 B 형식 (encoderConfig.avc = { format: "annexb" })으로 인코딩되지 않는 한 AVCC의 바이너리 블롭이 필요할 수 있습니다.

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

개발자 팁

Chrome DevTools의 미디어 패널을 사용하여 미디어 로그를 확인하고 WebCodecs를 디버그합니다.

WebCodecs 디버깅을 위한 미디어 패널 스크린샷
WebCodecs 디버깅을 위한 Chrome DevTools의 미디어 패널

데모

데모에서는 캔버스의 애니메이션 프레임이 다음과 같이 처리되는 방법을 보여줍니다.

  • MediaStreamTrackProcessor에 의해 ReadableStream에 25fps로 캡처됨
  • 웹 작업자로 트랜스퍼됨
  • H.264 동영상 형식으로 인코딩
  • 동영상 프레임 시퀀스로 다시 디코딩됩니다.
  • transferControlToOffscreen()를 사용하여 두 번째 캔버스에 렌더링됩니다.

기타 데모

다른 데모도 확인해 보세요.

WebCodecs API 사용

기능 감지

WebCodecs 지원을 확인하려면 다음을 실행하세요.

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

WebCodecs API는 보안 컨텍스트에서만 사용할 수 있으므로 self.isSecureContext이 false이면 감지가 실패합니다.

의견

Chrome팀은 WebCodecs API 사용 경험에 관한 의견을 듣고 싶습니다.

API 설계에 대해 알려주세요.

API가 예상대로 작동하지 않는 부분이 있나요? 아이디어를 구현하는 데 필요한 메서드나 속성이 누락되어 있나요? 보안 모델에 관해 궁금한 점이나 의견이 있으신가요? 해당 GitHub 저장소에 사양 문제를 제출하거나 기존 문제에 의견을 추가합니다.

구현 문제 신고

Chrome 구현에서 버그를 발견하셨나요? 아니면 구현이 사양과 다른가요? new.crbug.com에서 버그를 신고합니다. 최대한 많은 세부정보와 재현을 위한 간단한 안내를 포함하고 구성요소 상자에 Blink>Media>WebCodecs를 입력합니다.

API 지원 표시

WebCodecs API를 사용할 계획인가요? 공개 지원은 Chrome팀이 기능의 우선순위를 지정하는 데 도움이 되며 다른 브라우저 공급업체에 이러한 기능 지원이 얼마나 중요한지 보여줍니다.

[email protected]로 이메일을 보내거나 @ChromiumDev로 트윗을 보내 #WebCodecs 해시태그를 사용하여 어디에서 어떻게 사용하고 있는지 알려주세요.

Unsplash데니스 얀스가 촬영한 히어로 이미지