Skip to content

인생네컷 촬영 기능 개발기

김서진 edited this page Dec 4, 2021 · 2 revisions

개요

인생 네컷 기능을 개발한 동기

1주차에 프로젝트를 기획할 때, 저희 프로젝트에 특별한 기능을 얹고 싶다는 의견이 있었습니다.
음성 변조 기능, 타이머 기능, 음악 공유 기능 등이 있었는데요,
그 중에 저희가 최종적으로 선택한 기능은 화면 캡쳐 기능이었습니다.
화상 회의를 하는 도중 인증샷을 올리려고, 혹은 추억을 남기려고 캡쳐하는 경우가 많은데,
덕스코드에서 이 기능을 제공하면 사용자들에게 덕스코드가 보다 좋은 추억으로 남을 것 같아 이 기능을 개발하기로 마음먹었습니다.

인생 네컷 기능 미리보기

화상회의 중 가운데의 카메라 버튼을 누르면 회의 참여자들의 사진을 촬영하여 인생네컷 형태로 저장하는 기능입니다. ezgif com-gif-maker

 사진 아래에는 덕스코드 로고와 날짜가 표시됩니다.

개발 과정

Related Pull Request

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이 어떤 속성과 메소드를 가졌는지, 서로 어떻게 변환되는지에 대한 공부를 할 수 있게 해주었습니다.

그 모든 과정이 합쳐진 결과가 저 인생네컷 기능입니다.
구현하면서 어려웠던 점이 정말 많지만, 그만큼 많이 성장할 수 있던 기능이었습니다.

무엇보다 저희 서비스에 놀러오시는 캠퍼분들과 멘토님께서 이 기능을 재밌게 사용하셔서 기분이 좋았습니다 ㅎㅎ

Clone this wiki locally