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은 미디어 트랙에서 들어오는 모든 프레임을 작업자(worker)로 자동 전송하는 편리한 방법을 제공합니다. 예를 들어 MediaStreamTrackProcessor를 사용하면 웹 카메라에서 들어오는 미디어 스트림 트랙의 ReadableStream를 가져올 수 있습니다. 그런 다음 스트림이 웹 작업자로 전송되어 프레임이 하나씩 읽히고 VideoEncoder에 큐에 추가됩니다.

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

WebCodecs 작동 방식

인코딩

Canvas 또는 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();

디코딩

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

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

코덱 매개변수 집합은 코덱마다 다릅니다. 예를 들어 H.264 코덱은 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이면 감지가 실패합니다.

의견

Chrome팀은 WebCodecs API 사용 경험에 대해 의견을 듣고자 합니다.

API 설계 설명

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

구현 문제 신고

Chrome 구현에서 버그를 발견했나요? 아니면 구현이 사양과 다른가요? new.crbug.com에서 버그를 신고합니다. 최대한 자세한 내용과 재현을 위한 간단한 안내를 포함하고 구성요소 상자에 Blink>Media>WebCodecs를 입력합니다. Glitch는 빠르고 간편한 재현을 공유하는 데 적합합니다.

API 지원 표시

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

[email protected]로 이메일을 보내거나 해시태그 #WebCodecs를 사용하여 @ChromiumDev에 트윗을 보내고, 사용하는 위치와 방법을 알려주세요.

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