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 API는 모든 어려운 작업을 기본 스레드 밖에서 비동기식으로 수행합니다. 하지만 프레임 및 청크 콜백은 1초에 여러 번 호출될 수 있으므로 기본 스레드를 복잡하게 하여 웹사이트의 응답성을 떨어뜨릴 수 있습니다. 따라서 개별 프레임과 인코딩된 청크의 처리를 웹 작업자로 옮기는 것이 좋습니다.

이를 위해 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()를 호출하여 구성 지원 여부를 미리 확인하고 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() 콜백을 호출하여 보고됩니다. 인코딩이 성공적으로 완료되면 인코딩된 새 청크를 인수로 사용하여 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();

디코딩

네트워크 또는 저장소에서 캔버스 또는 ImageBitmap까지의 경로입니다.
네트워크 또는 스토리지에서 Canvas 또는 ImageBitmap까지의 경로입니다.

VideoDecoder 설정은 VideoEncoder에서 실행한 작업과 유사합니다. 디코더가 생성될 때 두 함수가 전달되고 코덱 매개변수가 configure()에 제공됩니다.

코덱 매개변수 집합은 코덱에 따라 다릅니다. 예를 들어 H.264 코덱은 소위 부록 B 형식 (encoderConfig.avc = { format: "annexb" })으로 인코딩되지 않는 한 AVCC의 바이너리 blob이 필요할 수 있습니다.

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에 의해 25fps로 ReadableStream에 캡처됨
  • 웹 워커로 전송
  • 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에서 버그를 신고합니다. 가능한 한 많은 세부정보와 간단한 재현 안내를 포함하고 Components 상자에 Blink>Media>WebCodecs를 입력합니다. Glitch는 쉽고 빠른 재현을 공유하는 데 효과적입니다.

API 지원 표시

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

media-dev@chromium.org로 이메일을 보내거나 해시태그 #WebCodecs를 사용하여 @ChromiumDev로 트윗을 보내고 사용 위치와 방법을 알려주세요.

히어로 이미지(Unsplash, 데니스 얀스)