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에서 네트워크 또는 스토리지로 이어지는 경로
에서 네트워크 또는 저장소로의 경로
CanvasImageBitmap

모든 것은 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 객체를 제공해야 합니다.

  • 인코딩된 청크와 오류를 처리하는 두 개의 함수가 있는 init 사전 이러한 함수는 개발자가 정의하며 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 코덱 바이너리 blob 은 Annex 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에 의해 ReadableStream으로 25fps로 캡처됨
  • 웹 작업자로 전송됨
  • H.264 동영상 형식으로 인코딩됨
  • 동영상 프레임 시퀀스로 다시 디코딩됨
  • transferControlToOffscreen()을 사용하여 두 번째 캔버스에 렌더링됨

기타 데모

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

WebCodecs API 사용

기능 감지

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

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

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

자세히 알아보기

WebCodecs를 처음 사용하는 경우 WebCodecs 기본사항에서 자세한 도움을 받을 수 있는 다양한 예시가 포함된 심층 분석 아티클을 제공합니다.

의견

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

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

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

구현 문제 신고

Chrome 구현에서 버그를 발견하셨나요? 또는 구현이 사양과 다른가요? new.crbug.com에서 버그를 제출하세요. 가능한 한 많은 세부정보, 재현을 위한 간단한 안내를 포함하고 Blink>Media>WebCodecs구성요소 상자에 입력하세요.

API 지원 표시

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

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