동영상 스트림 구성요소 조작
최신 웹 기술을 사용하면 동영상을 다양한 방식으로 처리할 수 있습니다. Media Stream API, Media Recording API, Media Source API, WebRTC API를 함께 사용하면 동영상 스트림을 녹화, 전송, 재생하는 데 필요한 다양한 도구 세트를 사용할 수 있습니다. 이러한 API는 특정 상위 수준의 작업을 해결하는 동안 웹 프로그래머가 프레임, 인코딩된 동영상 또는 오디오의 뮤싱되지 않은 청크와 같은 동영상 스트림의 개별 구성요소를 사용할 수 없습니다. 이러한 기본 구성요소에 대한 하위 수준 액세스를 얻기 위해 개발자는 WebAssembly를 사용하여 동영상 및 오디오 코덱을 브라우저로 가져왔습니다. 하지만 최신 브라우저에는 이미 다양한 코덱 (하드웨어로 가속되는 경우가 많음)이 제공되므로 이를 WebAssembly로 리패키징하는 것은 인력과 컴퓨터 리소스를 낭비하는 것처럼 보입니다.
WebCodecs API는 프로그래머에게 브라우저에 이미 있는 미디어 구성요소를 사용하는 방법을 제공하여 이러한 비효율성을 제거합니다. 특히 다음에 주의해야 합니다.
- 동영상 및 오디오 디코더
- 동영상 및 오디오 인코더
- 원시 동영상 프레임
- 이미지 디코더
WebCodecs API는 동영상 편집기, 화상 회의, 동영상 스트리밍 등 미디어 콘텐츠가 처리되는 방식을 완전히 제어해야 하는 웹 애플리케이션에 유용합니다.
동영상 처리 워크플로
프레임은 동영상 처리의 핵심입니다. 따라서 WebCodecs에서 대부분의 클래스는 프레임을 소비하거나 생성합니다. 동영상 인코더는 프레임을 인코딩된 청크로 변환합니다. 동영상 디코더는 그 반대로 작동합니다.
또한 VideoFrame
는 CanvasImageSource
이고 CanvasImageSource
를 허용하는 생성자를 갖음으로써 다른 웹 API와 잘 작동합니다.
따라서 drawImage()
및 texImage2D()
와 같은 함수에서 사용할 수 있습니다. 또한 캔버스, 비트맵, 동영상 요소, 기타 동영상 프레임으로 구성할 수 있습니다.
WebCodecs API는 WebCodecs를 미디어 스트림 트랙에 연결하는 Insertable Streams API의 클래스와 함께 잘 작동합니다.
MediaStreamTrackProcessor
는 미디어 트랙을 개별 프레임으로 분할합니다.MediaStreamTrackGenerator
는 프레임 스트림에서 미디어 트랙을 만듭니다.
WebCodecs 및 웹 작업자
WebCodecs API는 설계상 모든 까다로운 작업을 비동기식으로 기본 스레드 외부에서 실행합니다. 하지만 프레임 및 청크 콜백은 초당 여러 번 호출될 수 있으므로 기본 스레드가 혼잡해져 웹사이트의 응답 속도가 느려질 수 있습니다. 따라서 개별 프레임 및 인코딩된 청크의 처리를 웹 작업자로 이동하는 것이 좋습니다.
이를 위해 ReadableStream은 미디어 트랙에서 들어오는 모든 프레임을 작업자(worker)로 자동 전송하는 편리한 방법을 제공합니다. 예를 들어 MediaStreamTrackProcessor
를 사용하여 웹캠에서 들어오는 미디어 스트림 트랙의 ReadableStream
를 가져올 수 있습니다. 그런 다음 스트림이 웹 작업자로 전송되어 프레임이 하나씩 읽히고 VideoEncoder
에 큐에 추가됩니다.
HTMLCanvasElement.transferControlToOffscreen
를 사용하면 렌더링도 기본 스레드 외부에서 실행할 수 있습니다. 하지만 모든 고급 도구가 불편하다면 VideoFrame
자체는 전송할 수 있으며 작업자 간에 이동할 수 있습니다.
WebCodecs 작동 방식
인코딩
모든 것은 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();
디코딩
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()
)이 빠르게 반환되도록 하는 것이 좋습니다. 아래 예에서는 렌더링 준비가 완료된 프레임의 큐에 프레임만 추가합니다.
렌더링은 별도로 이루어지며 두 단계로 구성됩니다.
- 프레임을 표시할 적절한 시기를 기다리는 중입니다.
- 캔버스에 프레임을 그립니다.
프레임이 더 이상 필요하지 않으면 가비지 컬렉터가 이를 처리하기 전에 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를 디버그합니다.
데모
아래 데모는 캔버스의 애니메이션 프레임이 다음과 같이 표시되는 방식을 보여줍니다.
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팀에서 기능의 우선순위를 지정하는 데 도움이 되며 다른 브라우저 공급업체에 기능을 지원하는 것이 얼마나 중요한지 보여줍니다.
media-dev@chromium.org에 이메일을 보내거나 #WebCodecs
해시태그를 사용하여 @ChromiumDev에 트윗을 보내 사용 위치와 사용 방법을 알려주세요.
Unsplash의 Denise Jans님 제공 히어로 이미지