포토부스 이미지를 브라우저에서 처리한 과정
Canvas API 이미지 처리와 포맷 선택, ffmpeg WASM 영상 압축까지
들어가며
포토부스 키오스크를 만들면서 촬영한 이미지를 합성하고, 영상을 서버에 전송하는 과정이 필요했다. 브라우저에서 이미지와 영상을 어떻게 처리했는지, 그리고 포맷을 선택하면서 부딪힌 문제를 정리한다.
브라우저에서 이미지를 처리해야 했던 이유
촬영한 사진 4장을 하나의 이미지로 합성하고, 프레임 배경을 씌우고, QR코드를 올리는 작업이 필요했다. 이 과정을 서버에서 처리하면 원본 4장을 모두 업로드한 뒤 합성 결과를 다시 받아야 한다. 키오스크 환경에서 여러 사용자가 동시에 촬영하면 업로드 대기 시간이 길어졌고, 사용자가 결과를 받기까지 체감이 느려지는 문제로 이어졌다.
브라우저의 Canvas API를 사용하면 이미지 합성을 클라이언트에서 직접 처리할 수 있다. 서버 왕복 없이 촬영 직후 바로 결과물을 만들 수 있다는 점이 이 환경에 적합했다.
Canvas API로 이미지 처리하기
canvas.toBlob()과 canvas.toDataURL()
브라우저에서 이미지를 Canvas로 처리한 결과를 내보내는 네이티브 API는 두 가지다.
https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob
https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toDataURL
둘 다 canvas에 그린 내용을 원하는 포맷으로 인코딩하지만 반환 형태가 다르다.
toDataURL()은 Base64 인코딩된 문자열을 동기적으로 반환한다.- Base64는 바이너리를 텍스트로 변환하기 때문에 원본 대비 약 33% 크기가 증가한다.
- 중간 처리 단계에서 이미지를 문자열로 다룰 때 유용하다.
toBlob()은 바이너리 Blob을 비동기 콜백으로 반환한다.- 바이너리 그대로 다루기 때문에 크기 증가가 없고, File 객체로 변환하기도 간편하다.
- 서버에 전송할 최종 파일을 만들 때 적합하다.
실제로는 두 API를 용도에 따라 섞어 사용했다. 좌우반전이나 4컷 합성처럼 중간 단계에서는 toDataURL()로 이미지를 문자열로 넘기고, 서버에 업로드할 최종 파일을 만들 때는 toBlob()을 사용했다.
이미지 합성 흐름
drawImage()는 canvas 위에 이미지를 그리는 Canvas 2D API 메서드다. 4컷 합성은 이 메서드를 호출하는 순서를 조절하는 것만으로 해결했다.
// 4장의 사진을 각 위치에 배치
for (const [index, photo] of photos.entries()) {
const img = await loadImage(photo);
ctx.drawImage(img, positions[index].left, positions[index].top, photoWidth, photoHeight);
}
// 프레임 배경을 위에 덮어씌우기
const bg = await loadImage(backgroundImage);
ctx.drawImage(bg, 0, 0, baseWidth, baseHeight);
Canvas는 나중에 그린 것이 위에 온다. 사진 4장을 먼저 깔고, 프레임 배경을 그 위에 덮으면 프레임의 투명 영역을 통해 아래의 사진이 보이는 구조가 된다. 합성이 끝나면 canvas.toDataURL("image/png", 1.0)으로 결과를 뽑아낸다.
devicePixelRatio(DPR, 디스플레이의 물리 픽셀과 CSS 픽셀의 비율)를 곱해 canvas 해상도를 높이는 시도도 했다. DPR이 2인 화면에서 canvas 내부 픽셀 수를 2배로 늘리면 이론적으로 더 선명한 이미지를 얻을 수 있다. 하지만 고해상도 캔버스에 저해상도 이미지를 크게 그리는 것뿐이었기 때문에 실제 인쇄 결과는 달라지지 않았다. 코드로 할 수 있는 조치는 다 했지만, 원본 해상도의 한계는 소프트웨어로 넘을 수 없었다.
리사이징
촬영된 이미지의 해상도가 목표 크기와 다른 경우 리사이징이 필요하다. 단순히 크기를 줄이면 이미지가 찌그러질 수 있기 때문에, 비율을 유지하면서 중앙 크롭하는 방식을 사용했다.
// 원본과 목표의 가로/세로 비율을 비교하여 크롭 방향을 결정
const imgRatio = img.width / img.height;
const targetRatio = targetWidth / targetHeight;
if (imgRatio > targetRatio) {
// 원본이 목표보다 가로가 넓으면 좌우를 자른다
sw = img.height * targetRatio;
sx = (img.width - sw) / 2;
} else {
// 원본이 목표보다 세로가 높으면 상하를 자른다
sh = img.width / targetRatio;
sy = (img.height - sh) / 2;
}
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, targetWidth, targetHeight);
drawImage의 9개 인자 버전은 원본에서 잘라낼 영역과 canvas에 그릴 영역을 각각 지정한다. sx, sy는 원본에서 잘라내기 시작할 좌표, sw, sh는 잘라낼 영역의 크기다. 비율 비교로 어느 방향을 잘라야 하는지 판단한 뒤, 중앙을 기준으로 크롭하여 이미지가 찌그러지지 않도록 했다.
WASM 기반 FFmpeg 라이브러리로 영상 압축하기
브라우저에서 ffmpeg를 실행하는 방법
브라우저에서 영상을 다루는 네이티브 API로 MediaRecorder가 있다. 웹캠 스트림을 실시간으로 녹화할 때 사용하며, videoBitsPerSecond 옵션으로 비트레이트 힌트를 줄 수 있다. 하지만 이 값은 브라우저가 정확히 지키는 것이 보장되지 않고, 코덱도 브라우저가 지원하는 것만 선택할 수 있다. 무엇보다 MediaRecorder는 녹화 시점에 동작하는 API라서, 이미 녹화된 영상을 다시 압축하는 용도로는 쓸 수 없다.
@ffmpeg/ffmpeg는 C로 작성된 ffmpeg를 WebAssembly(WASM, 브라우저에서 네이티브에 가까운 속도로 실행되는 바이너리 포맷)로 컴파일해 브라우저에서 실행할 수 있게 만든 패키지다. 실제 파일시스템 대신 메모리(Emscripten FS)를 사용한다는 점만 다르고, ffmpeg 명령어를 그대로 쓸 수 있다.
구현
const ffmpeg = createFFmpeg({ log: false });
async function compressVideo(file: File): Promise<File> {
if (!ffmpeg.isLoaded()) await ffmpeg.load();
ffmpeg.FS("writeFile", file.name, await fetchFile(file));
await ffmpeg.run("-i", file.name, "-c:v", "libvpx", "-b:v", "1M", "-c:a", "libvorbis", "output.webm");
const data = ffmpeg.FS("readFile", "output.webm");
// 메모리 누수 방지
ffmpeg.FS("unlink", file.name);
ffmpeg.FS("unlink", "output.webm");
return new File([data.buffer], "output.webm", { type: "video/webm" });
}
프로덕션에 적용하지 못한 이유
영상 압축 로직은 구현까지 완료했지만, 실제 서비스에 적용하지는 못했다. 2주라는 짧은 외주 기간 안에 결과물을 내야 했고, 같은 날 다양한 기능들을 통합해서 구현하는 상황이었다. 원본 그대로 올려도 동작은 했기 때문에, 압축 연결보다 다음 기능 구현이 우선순위에서 앞섰다. 결과적으로 압축 유틸은 만들어둔 채로 다시 돌아오지 못했다.
정리
- Canvas API의
toBlob()과toDataURL()은 용도에 따라 사용처가 다르다. 중간 처리에는toDataURL(), 최종 파일 생성에는toBlob()이 적합하다. drawImage()의 호출 순서로 이미지 합성을 구현했다. Canvas는 나중에 그린 것이 위에 오는 특성을 이용해 사진 위에 프레임을 덮는 구조를 만들었다.- 이미지 리사이징 시 비율을 유지하면서 중앙 크롭하는 방식으로 찌그러짐을 방지했다.
@ffmpeg/ffmpegWASM으로 브라우저 영상 압축을 구현했지만, 제한된 일정 안에서 우선순위가 밀려 프로덕션에 적용하지 못했다.