3.3 이미지와 비디오 저장하기
이 튜토리얼에서는 렌더링된 콘텐츠를 이미지와 비디오로 저장하는 방법을 다룹니다. 이 기능은 영상 및 이미지 편집이 필요한 응용 프로그램에서 유용하게 사용됩니다.
플레이그라운드 실행 - 3_03_save_image_and_video이미지를 저장하는 것은 캔버스 요소의 내장 메소드 덕분에 매우 간단합니다. 데모에서는 사용자가 "이미지 저장"을 클릭했을 때 최신 콘텐츠가 저장되도록 렌더링 함수의 마지막 부분에 이미지 저장 로직을 추가했습니다. 즉, 저장 전에 장면을 다시 렌더링하고, 렌더링이 끝난 후 이미지를 저장합니다.
let takeScreenshot = false;
screenshotButton.onclick = () => {
takeScreenshot = true;
}
if (takeScreenshot) {
takeScreenshot = false;
canvas.toBlob((blob) => {
saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`);
});
}
하지만 blob은 직접적으로 이미지를 보여주는 형태가 아니므로, 파일로 저장해 주어야 합니다. 이를 위해 도우미 함수를 사용합니다:
const saveBlob = (function () {
const a = document.createElement('a');
document.body.appendChild(a);
a.style.display = 'none';
return function saveData(blob, fileName) {
const url = window.URL.createObjectURL(blob);
a.href = url;
a.download = fileName;
a.click();
a.parentNode.removeChild(a);
};
}());
이 함수는 숨겨진 링크 요소를 만들고, blob을 window.URL.createObjectURL(blob)로 객체 URL로 변환하여, 링크를 프로그래밍적으로 클릭해 다운로드를 실행한 후 DOM에서 제거하는 방식으로 동작합니다.
위 예제는 캔버스를 이미지로 저장하는 방법을 보여줍니다. 렌더링 과정에서 생성된 텍스처나 중간 버퍼를 디버깅용으로 저장해야 할 때도 있습니다. 이런 내용은 뒷부분에서 다룹니다.
비디오 저장은 좀 더 복잡합니다. 이 데모에서는 WebCodecs라는 새로운 API를 사용해 브라우저 내에서 직접 비디오 프레임을 인코딩합니다.
대부분의 컴퓨팅 관련 API는 계층적으로 구성되어, 저수준 API는 다루기 어렵지만 유연성이 높고, 고수준 API는 사용이 쉬우나 특정 용도에 한정되는 경우가 많습니다.
하지만 웹 표준에서는 한 가지 기능만을 수행하도록 설계된 API가 많습니다. 예를 들어 WebCodecs 이전에는 <video> 태그로 영상을 재생하고, WebRTC로 화상통신을 할 수 있었지만, 프레임 인코딩 같은 저수준 비디오 API는 제공하지 않았습니다.
클라우드 게이밍의 등장과 함께 상황이 달라졌습니다. 클라우드 게이밍은 저지연 비디오 스트림 제어를 요구했고, 구글은 WebCodecs로 저수준 비디오 API를 제공하게 되었습니다.
여기서는 동일한 버튼으로 비디오 녹화 시작과 중지를 처리합니다.
recordButton.onclick = async () => {
if (encoder === null) {
const options = {
suggestedName: 'video.webm',
types: [
{
description: 'Webm video',
accept: {
'video/webm': ['.webm']
}
}
],
};
let fileHandle = await window.showSaveFilePicker(options);
fileWritableStream = await fileHandle.createWritable();
// 이 WebMWriter는 서드파티 라이브러리에서 제공합니다.
webmWriter = new WebMWriter({
fileWriter: fileWritableStream,
codec: 'VP9',
width: canvas.width,
height: canvas.height
});
encoder = new VideoEncoder({
output: chunk => webmWriter.addFrame(chunk),
error: e => console.error(e)
});
// 환경에 맞게 설정하세요
encoder.configure({
codec: "vp09.00.10.08",
width: canvas.width,
height: canvas.height,
bitrate: 2_000_000,
latencyMode: 'realtime',
framerate: 25
});
frameCount = 0;
beginTime = window.performance.now();
recordButton.innerText = '중지';
} else {
encoder.flush();
await webmWriter.complete();
fileWritableStream.close();
webmWriter = null;
fileWritableStream = null;
encoder = null;
recordButton.innerText = '녹화';
}
}
encoder가 아직 생성되지 않았다면 녹화를 시작합니다. 첫 단계는 사용자가 비디오 파일 이름을 지정할 수 있도록 파일 선택 창을 띄우는 것입니다.
const options = {
suggestedName: 'video.webm',
types: [
{
description: 'Webm video',
accept: {
'video/webm': ['.webm']
}
}
],
};
let fileHandle = await window.showSaveFilePicker(options);
fileWritableStream = await fileHandle.createWritable();
파일 포맷으로는 구글에서 개발한 무료 비디오 포맷인 WebM을 사용합니다. 비디오 포맷이 생소하다면, 대부분의 비디오 파일은 여러 미디어 스트림(트랙)을 담는 컨테이너 포맷입니다. 예를 들어 영화 파일에는 비디오 트랙과 오디오 트랙이 포함될 수 있고, 각각은 다른 코덱으로 인코딩됩니다.
WebCodecs로 인코딩할 때는 개별 프레임만 인코딩할 수 있습니다. 인코딩된 프레임을 모아 트랙으로 만들고, 다시 컨테이너 포맷에 넣어야 비디오 파일로 쓸 수 있습니다. 하지만 muxing(컨테이너 조립)은 WebCodecs 표준에 포함되어 있지 않으므로, webm-writer2.js 같은 서드파티 라이브러리를 사용해야 합니다.
쓰기 가능한 스트림을 얻으면, 이제 뮤저 객체를 만듭니다:
webmWriter = new WebMWriter({
fileWriter: fileWritableStream,
codec: 'VP9',
width: canvas.width,
height: canvas.height
});
VP9 코덱을 사용하고, 캔버스와 동일한 크기로 설정합니다.
encoder = new VideoEncoder({
output: chunk => webmWriter.addFrame(chunk),
error: e => console.error(e)
});
// 환경에 맞게 설정하세요
encoder.configure({
codec: "vp09.00.10.08",
width: canvas.width,
height: canvas.height,
bitrate: 2_000_000,
latencyMode: 'realtime',
framerate: 25
});
마지막으로 인코더를 생성 및 설정합니다. 인코더는 두 개의 콜백(output, error)을 사용합니다. output 콜백은 프레임이 인코딩될 때 호출되고, error 콜백은 에러 발생 시 호출됩니다. 인코딩된 데이터는 webmWriter에 전달하여 비디오 파일로 조립합니다.
인코더는 VP9 코덱, bitrate, framerate 등 여러 파라미터를 지정하여 설정합니다. frameCount는 0으로 초기화하며, 고정밀 타임스탬프를 저장합니다. frameCount는 keyframe과 intra-frame의 인코딩 시점을 판단할 때 사용합니다. keyframe은 독립적으로 디코딩 가능하며, intra-frame은 더 높은 압축률을 가지지만 이전 keyframe 및 사이 intra-frame들이 있어야 디코딩이 가능합니다. 압축 오류가 누적되기 때문에 일정 간격마다 keyframe을 생성합니다.
프레임 인코딩에는 타임스탬프가 필요하므로, 녹화 시작 시각을 기록합니다.
frameCount = 0;
beginTime = window.performance.now();
이미지 저장 코드와 마찬가지로, 비디오 인코딩 코드도 프레임이 렌더링된 후에 실행합니다. encoder가 초기화되어 있다면, 현재 시간을 측정한 후 VideoFrame을 생성하고, 경과 시간을 타임스탬프로 사용합니다. encode 함수를 호출하고, 10 프레임마다 keyframe을 인코딩합니다.
async function render() {
if (encoder !== null) {
let currentTime = window.performance.now();
const frame = new VideoFrame(canvas, { timestamp: (currentTime - beginTime) * 1000 });
encoder.encode(frame, { keyFrame: frameCount % 10 == 0 });
frame.close();
frameCount++;
}
requestAnimationFrame(render);
}
사용자가 녹화 버튼을 다시 클릭하면 녹화를 중지하고 비디오 파일을 저장합니다:
encoder.flush();
await webmWriter.complete();
fileWritableStream.close();
webmWriter = null;
fileWritableStream = null;
encoder = null;
recordButton.innerText = '녹화';
먼저 encoder를 flush하여 인코딩되지 않은 프레임을 마무리합니다. 그리고 webmWriter와 파일 스트림을 닫으면, 저장된 파일은 비디오 플레이어로 재생이 가능합니다.