-
Notifications
You must be signed in to change notification settings - Fork 7
인생네컷 촬영 기능 개발기
1주차에 프로젝트를 기획할 때, 저희 프로젝트에 특별한 기능을 얹고 싶다는 의견이 있었습니다.
음성 변조 기능, 타이머 기능, 음악 공유 기능 등이 있었는데요,
그 중에 저희가 최종적으로 선택한 기능은 화면 캡쳐 기능이었습니다.
화상 회의를 하는 도중 인증샷을 올리려고, 혹은 추억을 남기려고 캡쳐하는 경우가 많은데,
덕스코드에서 이 기능을 제공하면 사용자들에게 덕스코드가 보다 좋은 추억으로 남을 것 같아 이 기능을 개발하기로 마음먹었습니다.
화상회의 중 가운데의 카메라 버튼을 누르면 회의 참여자들의 사진을 촬영하여 인생네컷 형태로 저장하는 기능입니다.
https://github.com/boostcampwm-2021/web09-Duxcord/pull/331
Try 1 : html2canvas 라이브러리를 이용한 화면 캡쳐
https://html2canvas.hertzen.com/
처음에는 화면 캡쳐 라이브러리인 html2canvas를 사용하려 하였습니다.
그러나 html2canvas에는 이미지, 비디오를 캡쳐하는 기능이 탑재되어 있지 않았습니다.
html2canvas는
(1) document에 있는 내용들을 canvas에 clone한 후
(2) 그 canvas를 bitmap으로 저장합니다.
(1)의 과정에서 "비디오 엘리먼트 속의 스트림"은 클론되지 않고 "비디오 엘리먼트의 껍데기"만 복사되었습니다.
따라서 html2canvas는 화상 회의를 캡쳐해야 하는 저희 기능과 맞지 않았고, 다른 방법을 찾아야 했습니다.
Try 2 : getDisplayMedia API를 이용한 캡쳐
https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia
화면 공유 기능을 구현할 때 쓰인 getDisplayMedia API를 사용하여 화면의 스트림을 얻고,
그 스트림을 이미지로 저장하는 방법을 떠올렸습니다.
위의 html2canvas의 방법대로, 스트림을 canvas에 넣고 canvas로 bitmap을 만들어 저장하는 과정을 구현해보았습니다.
const capture = async (window: Window) => {
try {
const captureStream = await window.navigator.mediaDevices.getDisplayMedia({
audio: false,
video: true,
});
const pseudoVideoElement = document.createElement('video');
pseudoVideoElement.srcObject = captureStream;
setTimeout(async () => {
const imageCapture = new ImageCapture(captureStream.getVideoTracks()[0]);
const imageBitmap = await imageCapture.grabFrame();
downloadImageBitmap(imageBitmap);
const mediaStream = pseudoVideoElement.srcObject as MediaStream;
mediaStream.getTracks().forEach((track) => track.stop());
pseudoVideoElement.srcObject = null;
}, 5000);
} catch (error) {
console.error(error);
}
};
const downloadImageBitmap = (bitmap: ImageBitmap) => {
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);
const context = canvas.getContext('2d');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
context?.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height);
canvas.toBlob(
(blob) => {
const a = document.createElement('a');
a.style.display = 'none';
console.log(blob);
a.href = URL.createObjectURL(blob);
a.download = `화면캡처${new Date()}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
},
'image/jpeg',
0.95,
);
};
그러나 이 방법을 사용할 경우
화면 캡쳐 버튼을 눌렀을 때 "duxcord.kro.kr이 내 화면의 콘텐츠를 공유하려고 합니다."라는 메시지가 담긴 팝업창이 등장합니다.
이는 화면 공유 기능을 사용할 때와 동일한 팝업으로, 사용자에게 혼란을 줄 수 있을 것이라고 판단되었습니다.
따라서 다른 방법을 찾기로 하였습니다.
Try 3 : screenshot-desktop 라이브러리를 이용한 캡쳐
https://www.npmjs.com/package/screenshot-desktop
덕스코드의 React App이 실행되는 브라우저 환경에서는 사용할 수 없는 라이브러리였습니다.
따라서 다른 방법을 찾기로 하였습니다.
Try 4 : 전체 화면 캡쳐 대신, 비디오 스트림만 얻어서 인생 네컷처럼 만들어보자!
전체 화면을 캡쳐하기 위해 했던 모든 시도들이 좌절되고 난 후,
OS에서 제공하는 캡쳐 기능을 그대로 제공하기 보다는
요즘 유행하는 인생 네컷의 형태로 사진을 가공하여 제공하면 더 재미를 줄 수 있을 것 같았습니다.
따라서
(1) 모든 비디오 스트림을 순회하여 영상을 얻어내고
(2) 이를 캔버스에 그려서
(3) 사용자의 컴퓨터로 저장되도록
코드를 짜보기로 마음을 먹었습니다.
주석을 따라가며 코드를 이해해주시면 좋을 것입니다!
const capture = async () => {
// 브라우저 확인
if (['Firefox', 'Internet Explorer', 'Safari'].includes(getBrowser()))
throw TOAST_MESSAGE.ERROR.CAPTURE.NOT_SUPPORTED_BROWSER;
// 비디어 엘리먼트 얻기
const videos = document.querySelectorAll(
'video.user-video',
) as unknown as Array<HTMLVideoElementWithCaptureStream>;
if (videos.length === 0) throw TOAST_MESSAGE.ERROR.CAPTURE.NO_VIDEO;
// 캔버스 초기 세팅
const canvas = document.createElement('canvas');
canvas.width = CAPTURE.CROP_WIDTH + 2 * CAPTURE.PADDING;
canvas.height = (CAPTURE.CROP_HEIGHT + CAPTURE.PADDING) * videos.length + CAPTURE.PADDING_BOTTOM;
const context = canvas.getContext('2d');
// 비디오 스트림을 비트맵으로 변환해주는 함수 정의
const imageLoad = async (video: HTMLVideoElementWithCaptureStream) => {
const videoStream = video.captureStream().getTracks()[1];
if (!videoStream) return;
const imageCapture = new ImageCapture(videoStream);
const imageBitmap = await imageCapture.grabFrame();
return imageBitmap;
};
// 비디오를 캔버스 위에 그리는 함수 정의
const drawImageOnCanvas = (imageBitmap: ImageBitmap, index: number) => {
const isWide = isWideImage(imageBitmap);
const { width: bitmapWidth, height: bitmapHeight } = imageBitmap;
// 비디오를 그리기 위해 이미지를 자르는 과정
// 상수로 정의해둔 가로/세로 비율보다 더 긴 이미지는 세로로 자르고, 그렇지 않은 이미지는 가로로 자르는 과정
context?.drawImage(
imageBitmap,
isWide ? (bitmapWidth - bitmapHeight * (CAPTURE.CROP_WIDTH / CAPTURE.CROP_HEIGHT)) / 2 : 0,
isWide ? 0 : (bitmapHeight - bitmapWidth * (CAPTURE.CROP_HEIGHT / CAPTURE.CROP_WIDTH)) / 2,
isWide ? bitmapHeight * (CAPTURE.CROP_WIDTH / CAPTURE.CROP_HEIGHT) : bitmapWidth,
isWide ? bitmapHeight : bitmapWidth * (CAPTURE.CROP_HEIGHT / CAPTURE.CROP_WIDTH),
CAPTURE.PADDING,
index * (CAPTURE.PADDING + CAPTURE.CROP_HEIGHT) + CAPTURE.PADDING,
CAPTURE.CROP_WIDTH,
CAPTURE.CROP_HEIGHT,
);
};
// Promise.all로 모든 비디오들에게 imageLoad 함수와 drawImageOnCanvas 함수 적용
(await Promise.all([...videos].map((video) => imageLoad(video)))).forEach(
(imageBitmap, index) => imageBitmap && drawImageOnCanvas(imageBitmap, index),
);
// 덕스코드 로고 이미지 로드
const logoImage = new Image();
logoImage.src = '/images/duxcord_logo.png';
// 덕스코드 로고 이미지 로드 이후 날짜와 이미지를 작성하는 과정
logoImage.onload = () => {
if (!context) return;
const TODAY_DATE = new Date().toLocaleDateString();
context.drawImage(
logoImage,
canvas.width - CAPTURE.IMAGE_SIZE - CAPTURE.PADDING,
canvas.height - CAPTURE.IMAGE_SIZE - CAPTURE.PADDING,
CAPTURE.IMAGE_SIZE,
CAPTURE.IMAGE_SIZE,
);
context.fillStyle = 'rgba(255,255,255)';
context.font = `${CAPTURE.FONT_SIZE}pt 'Pretendard Variable'`;
context.fillText(
TODAY_DATE,
CAPTURE.PADDING,
canvas.height - CAPTURE.PADDING - CAPTURE.FONT_SIZE,
);
// canvas에 적힌 내용을 blob으로 만든 이후 다운로드받는 과정
canvas.toBlob(
(blob) => {
const a = document.createElement('a');
a.style.display = 'none';
a.href = URL.createObjectURL(blob);
a.download = `Duxcord화면캡처-${TODAY_DATE}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
},
'image/jpeg',
1,
);
};
};
canvas 위의 좌표를 계산한 로직은 알아 보기 쉽게 그림으로 첨부하겠습니다!!
구현 과정에서 마주친 html2canvas와 getDisplayMedia API는 구현에 직접적인 도움은 주지 못했지만
(1) HTML 요소를 클론해서 새 캔버스를 구성한다는 아이디어를 주었고,
(2) 웹 API 내에서 stream, blob, bitmap이 어떤 속성과 메소드를 가졌는지, 서로 어떻게 변환되는지에 대한 공부를 할 수 있게 해주었습니다.
그 모든 과정이 합쳐진 결과가 저 인생네컷 기능입니다.
구현하면서 어려웠던 점이 정말 많지만, 그만큼 많이 성장할 수 있던 기능이었습니다.
무엇보다 저희 서비스에 놀러오시는 캠퍼분들과 멘토님께서 이 기능을 재밌게 사용하셔서 기분이 좋았습니다 ㅎㅎ