기록의 습관화
article thumbnail

이번 포스트에서는 webview 환경에서 카메라를 다루는 법에 대해서 적어볼까 합니다.

요즘 진행 중인 프로젝트에서 원래는 React Native 기반으로만 프로젝트를 진행하려고 하였는데

React Native 환경에서 영상을 촬영 시 화면이 검은색으로 변한 뒤에 영상 촬영이 시작되는데

이 부분에 대해서는 서비스를 출시할 때 매우 크리티컬 한 부분이라고 생각하여서 

차라리 카메라/사진 촬영 쪽을 영상으로 옮기고 문제를 해결하자 하고 webview를 사용하게 되었습니다.

 

23년 9월 15일 기준으로 현재 문제를 확인하고 해결하였다고는 하는데 완전한 해결은 또 아닌가 봅니다 😂

https://github.com/expo/expo/issues/23130

 

[iOS] Preview flickering and darkened videos on iOS when recording video · Issue #23130 · expo/expo

Minimal reproducible example https://snack.expo.dev/@blimpapp/black-frames-example Summary When starting a video recording the preview flickers black and sometimes the first frames of the result vi...

github.com

 

그럼 본론으로 돌아와서 웹 환경에서 사진/영상 촬영 기능을 어떻게 구현하고

사용할 수 있는지 볼까요?

 

Web에서 카메라를 다루기

그렇다면 Web에서는 유저의 핸드폰에 있는 카메라를 어떻게 다룰 수 있을까요?

MDN을 보면 Web RTC를 기반으로 하여 MediaStream API를 미디어를 다룰 수 있게 해주는 API를 지원해주고 있습니다.

https://developer.mozilla.org/en-US/docs/Web/API/Media_Capture_and_Streams_API

 

Media Capture and Streams API (Media Stream) - Web APIs | MDN

The Media Capture and Streams API, often called the Media Streams API or MediaStream API, is an API related to WebRTC which provides support for streaming audio and video data.

developer.mozilla.org

https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API

 

WebRTC API - Web APIs | MDN

WebRTC (Web Real-Time Communication) is a technology that enables Web applications and sites to capture and optionally stream audio and/or video media, as well as to exchange arbitrary data between browsers without requiring an intermediary. The set of sta

developer.mozilla.org

오늘 이를 사용해서 구현을 해 볼 예정입니다.

 

구현 환경은 Next13 기반의 React 프로젝트이며 테스트 환경은 chrome 브라우저를 기준으로 하겠습니다.

React Native 환경은 Expo 환경에서 prebuild로 native 빌드 형태로 변환한 형태이며

상태관리 tool은 zustand를 이용하여 media 파일을 관리하도록 하였습니다.


1. 기본 틀 만들기

일단 화면에 사용자에게 보여줄 촬영 중인 화면을 보여주기 위해서는 video 태그를 이용해서 현재

촬영 중인 화면을 보여줘야 합니다.

<video className={MediaPageCss.camera} ref={videoRef} autoPlay playsInline />

// css
camera: style({
	minWidth: '100svw',
	height: '100svh',
	position: 'fixed',
}),

또한 사진 또는 영상을 촬영했을 때 임시로 담아둘 공간이 필요하기 때문에 canvas 또한 추가해 줍니다.

<canvas className={MediaPageCss.canvas} ref={canvasRef} />

// css
canvas: style({
	display: 'none',
}),

주의하실 점은 video의 스타일에 따라서 내가 직접 화면으로 보고 있는 화면과 

실제 촬영하는 부분의 차이가 있을 수 있으니 이 부분에 대해서는 각자 자신이 구현하고 싶은 카메라의 목적에 따라서

잘 설정해 주시면 될 것 같습니다.

 

2. 카메라 관련 customHook 추가하기

카메라의 모든 기능을 담당하는 부분은 customHook을 통해서 추상화를 시켜주도록 하겠습니다.

추가적으로 저희 앱의 기능 중 하나가 원을 눌러서 사진/영상 촬영을 동시에 할 수 있는 기능을 가져야 하기 때문에

사용자가 버튼을 누른 시간이 500ms 미만이면 사진 촬영

사용자가 버튼을 누른 시간이 500ms 초과하면 영상 촬영으로

사진/영상을 동시에 저장하고 사용할 수 있는 기능을 하도록 하였습니다.

사진/영상 촬영 페이지

또한 주의해야 할 점은 Native Platform에 따라 지원하는 미디어 포맷이 다르다는 점입니다.
이에 따라서 추후에 썸네일 형식으로 촬영한 사진/영상에 보일 때 화면에 제대로 나오지 않는 이슈가 있습니다.

그렇기에 mimeType을 Android에서는

'video/webm; codecs=vp8;

IOS에서는

'video/mp4'

을 사용 하였는데 이 부분에 대해서는 각자 Platform 별 지원 포맷을 참고하시면 좋을 것 같습니다.

https://developer.android.com/guide/topics/media/platform/supported-formats

 

Supported media formats  |  Android Developers

Supported media formats Stay organized with collections Save and categorize content based on your preferences. This document describes the media codec, container, and network protocol support provided by the Android platform. The tables below describe the

developer.android.com

https://support.apple.com/en-in/guide/motion/motn1252ada3/mac

 

Supported media formats in Motion

Motion supports many video, still image, and audio formats.

support.apple.com

 

또한 사용자에게 보이는 video 영상에 있어서 최초에 설정할 수 있는 설정 값들이 있는데
각 부분들에 대해서 본인에 맞는 설정값으로 설정하시면 될 것 같습니다.
주사율의 경우 요즘 핸드폰들은 모두 120 fps를 지원하니 다음과 같이 설정해 주었습니다.
    const constraints: MediaStreamConstraints = {
      video: {
      	// 전면 / 후면 -> 상태관리 필요
        facingMode: isRearCamera ? 'environment' : 'user',
        width: { ideal: 1920 }, // 너비
        height: { ideal: 1080 }, // 높이
        // 화면 주사율
        frameRate: {
          ideal: 60,
          max: 120,
        },
      },
    };​

https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia

 

MediaDevices: getUserMedia() method - Web APIs | MDN

The MediaDevices.getUserMedia() method prompts the user for permission to use a media input which produces a MediaStream with tracks containing the requested types of media.

developer.mozilla.org

import { TouchEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useMediaStore } from '../../store/media';
import { useRouter } from 'next/navigation';
import useOperatingSystem from '@/src/common/hooks/useOperationSystem';

interface CameraProps {
  scrollToEnd: () => void;
}

const useHandleCamera = ({ scrollToEnd }: CameraProps) => {
  const router = useRouter();

  const videoRef = useRef<HTMLVideoElement | null>(null); // 화면에 보여지는 영상
  const canvasRef = useRef<HTMLCanvasElement | null>(null); // 임시 저장
  const [touchStart, setTouchStart] = useState<number | null>(null); // 터치 시작 시간
  const [recording, setRecording] = useState<MediaRecorder | null>(null);
  const [recordingTimeout, setRecordingTimeout] = useState<NodeJS.Timeout | null>(null);
  const [isRearCamera, setIsRearCamera] = useState(true); // 전면 / 후면 카메라
  const [stream, setStream] = useState<MediaStream | null>(null); // 카메라 스트림

  const { media, addMedia, addBlankMedia, removeAllMedia, removeMedia } = useMediaStore();

  const OS = useOperatingSystem();

  const [dimensions, setDimensions] = useState({
    width: 0,
    height: 0,
  });

  // NOTE : 화면 크기에 따라 카메라 크기 조절
  useEffect(() => {
    setDimensions({
      width: window.innerWidth,
      height: window.innerHeight,
    });
  }, []);

  // NOTE : 화면 변경 시 카메라 크기 조절
  useEffect(() => {
    const handleResize = () => {
      setDimensions({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  // NOTE : 카메라 크기 조절
  useEffect(() => {
    const video = videoRef.current;
    const canvas = canvasRef.current;

    if (!video || !canvas) return;

    const handleMetadataLoaded = () => {
      const videoRatio = video.videoWidth / video.videoHeight;
      const windowRatio = dimensions.width / dimensions.height;

      if (windowRatio > videoRatio) {
        video.height = dimensions.height;
        video.width = dimensions.height * videoRatio;
        canvas.height = dimensions.height;
        canvas.width = dimensions.height * videoRatio;
      } else {
        video.width = dimensions.width;
        video.height = dimensions.width / videoRatio;
        canvas.width = dimensions.width;
        canvas.height = dimensions.width / videoRatio;
      }
    };

    video.addEventListener('loadedmetadata', handleMetadataLoaded);
    return () => video.removeEventListener('loadedmetadata', handleMetadataLoaded);
  }, [dimensions, videoRef, canvasRef]);

  // NOTE : 카메라 전면 / 후면 전환
  const switchCamera = () => {
    stopMediaTracks();
    setIsRearCamera((prevIsRearCamera) => !prevIsRearCamera);
  };

  // NOTE : 카메라 스트림 중지
  const stopMediaTracks = useCallback(() => {
    if (!stream) return;
    stream.getTracks().forEach((track) => track.stop());
    setStream(null);
  }, [stream]);

  // NOTE : 라우터 변경 시 카메라 전면 / 후면 전환
  // 라우터를 변경할 경우 초점이 맞지 않는 문제가 발생하여 해결
  useEffect(() => {
    setIsRearCamera((prevIsRearCamera) => !prevIsRearCamera);
    setTimeout(() => {
      setIsRearCamera((prevIsRearCamera) => !prevIsRearCamera);
    }, 100);
  }, [router]);

  // NOTE : 촬영 버튼 터치 이벤트
  const handleTouchStart = (e: TouchEvent<HTMLButtonElement>) => {
    setTouchStart(Date.now());
    const timeoutId = setTimeout(() => {
      startRecording();
    }, 500);
    setRecordingTimeout(timeoutId);
  };

  // NOTE : 촬영 버튼 터치 종료 이벤트
  const handleTouchEnd = () => {
    if (!touchStart) return;
    const pressDuration = Date.now() - touchStart;
    setTouchStart(null);

    if (pressDuration < 500) {
      capturePhoto();
      if (recordingTimeout !== null) {
        clearTimeout(recordingTimeout);
      }
    } else {
      stopRecording();
    }
  };

  // NOTE : 사진 촬영
  const capturePhoto = () => {
    const canvas = canvasRef.current;
    const video = videoRef.current;
    if (!canvas || !video) return;
    const context = canvas.getContext('2d');
    if (!context) return;
    context.drawImage(video, 0, 0, canvas.width, canvas.height);
    canvas.toBlob(
      (blob) => {
        blob &&
          addMedia([
            {
              type: 'image',
              url: URL.createObjectURL(blob),
            },
          ]);
        // NOTE : 현재 zustand의 미디어에 추가되는 딜레이가 존재하여 setTimeout을 사용하여 해결
        setTimeout(() => {
          scrollToEnd();
        }, 100);
      },
      'image/jpeg',
      1,
    );
  };

  // NOTE : 동영상 촬영
  const startRecording = async () => {
    try {
      const chunks: BlobPart[] = [];
      const mimeType = OS === 'Android' ? 'video/webm; codecs=vp8;' : 'video/mp4';
      const mediaRecorder = new MediaRecorder(stream as MediaStream, { mimeType });

      mediaRecorder.ondataavailable = (event) => {
        event.data.size > 0 && chunks.push(event.data);
      };

      mediaRecorder.onstop = () => {
        // NOTE : 영상을 불러오는데 딜레이가 존재하여 의도적으로 빈 미디어를 추가하여 해결
        addBlankMedia();
        const videoType = OS === 'Android' ? 'webm' : 'mp4';
        const url = URL.createObjectURL(new Blob(chunks, { type: `video/${videoType}` }));

        const video = document.createElement('video');
        video.oncanplay = () => {
          // NOTE : 현재 zustand의 미디어에 추가되는 딜레이가 존재하여 setTimeout을 사용하여 해결
          setTimeout(() => {
            scrollToEnd();
          }, 100);
          const canvas = document.createElement('canvas');
          const context = canvas.getContext('2d');

          canvas.width = video.videoWidth;
          canvas.height = video.videoHeight;

          context?.drawImage(video, 0, 0, canvas.width, canvas.height);

          canvas.toBlob(
            (blob) => {
              blob &&
                addMedia([
                  {
                    type: 'video',
                    url,
                  },
                ]);
            },
            'image/jpeg',
            1,
          );
        };

        video.src = url;
        video.load();
      };

      mediaRecorder.start();
      setRecording(mediaRecorder);
    } catch (error) {
      console.error('Failed to start recording:', error);
    }
  };

  // NOTE : 동영상 촬영 종료
  const stopRecording = () => {
    if (!recording) return;
    recording.stop();
    setRecording(null);
  };

  useEffect(() => {
    // 전면 / 후면 카메라 설정
    const constraints: MediaStreamConstraints = {
      video: {
        facingMode: isRearCamera ? 'environment' : 'user',
        width: { ideal: 1920 },
        height: { ideal: 1080 },
        frameRate: {
          ideal: 60,
          max: 120,
        },
      },
    };

    // 카메라 접근
    if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
      navigator.mediaDevices
        .getUserMedia(constraints)
        .then((stream) => {
          setStream(stream);
          if (videoRef.current) videoRef.current.srcObject = stream;
        })
        .catch((error) => {
          console.error('카메라 접근에 실패 했습니다', error);
        });
    }

    return () => {
      stopMediaTracks();
      setStream(null);
    };
  }, [isRearCamera]);

  const onRemoveThumbnail = removeMedia;

  return {
    canvasRef,
    videoRef,
    thumbnails: media,
    handleTouchStart,
    handleTouchEnd,
    onRemoveThumbnail,
    switchCamera,
    removeAllMedia,
  };
};

export default useHandleCamera;

중간 부분에 보면 카메라를 전면 / 후면을 뒤집어서 최초 상태로 돌리는 코드가 있는데

next13 기준으로 라우터를 변경 시에 초점이 최초 카메라 초점과 달라지는 이슈가 있어서

임시로 이와 같이 처리를 하였습니다.(혹시 이 부분에 대해서 해결 방법을 아시는 분이 계시다면 댓글로 적어주시면 감사하겠습니다 🙇‍♂️)

 

3. React Native에서 사진 / 영상 넘겨받기

이제 webview 단에서의 사진 / 영상 촬영 기능은 마무리가 되었습니다.

중요한 것은 사용자 핸드폰에서 사진 / 영상을 넘겨받는 것 이죠.

 

여기서 선택할 수 있는 방법은 여러 가지가 있습니다.

  1. Native 기기에서 사진 / 영상을 서버에 업로드 후 -> 클라이언트에게 첨부파일에 대한 주소만 전달
  2. Native 기기에서 사진 / 영상 파일을 직접 webview bridge로 전달 

1번의 경우를 좀 더 많이 사용하지만 저희 앱에서는 이후에 이 사진 / 영상 파일들에 대해서

모든 파일을 업로드하지 않는 것, 압축이 추후에 진행되어야 할 것을 고려하여 2번을 택하여 구현을 하였습니다.

 

각자 구현하실 bridge 방식과 webview에서 넘겨받는 방식은 다르실 테니 이 부분에 대해서는 자세한 설명은 생략하도록 하겠습니다.

다만 필요하신 분들이 있을 수 있으니 expo-image-picker를 이용해 사진 / 영상 파일에 대해서 base64 인코딩 방식으로 첨부파일을 가져오는 코드만 남겨 두도록 하겠습니다.

 

  • React Native에서 사용자 기기로부터 사진 / 영상 파일 받아오기
더보기
import * as ImagePicker from 'expo-image-picker';
import RNFetchBlob from 'rn-fetch-blob';

import { UploadMedia } from '../../utils/webviewBridge';

interface MediaPicker {
  type: ImagePicker.MediaTypeOptions;
  onSelectedEnd?: (media: UploadMedia) => void;
  onCloseGalleryBS: () => void;
}

const useMediaPicker = ({ type, onSelectedEnd, onCloseGalleryBS }: MediaPicker) => {
  async function fetchVideoAndEncodeToBase64(url: string) {
    try {
      // 영상 다운로드
      const resp = await RNFetchBlob.fetch('GET', url);

      // Base64로 인코딩
      const base64Str = resp.base64() as string;

      return base64Str;
    } catch (error) {
      console.error('Error fetching and encoding video:', error);
      return null;
    }
  }

  const pickMedia = async () => {
    onCloseGalleryBS();

    const result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ImagePicker.MediaTypeOptions[type],
      allowsEditing: true,
      aspect: [4, 3],
      quality: 1,
      base64: true,
      videoMaxDuration: 60,
    });

    const firstMedia = result.assets?.[0];

    if (!firstMedia) return;

    if (firstMedia.type === 'video') {
      const file = (await fetchVideoAndEncodeToBase64(firstMedia.uri)) ?? '';
      const media: UploadMedia = {
        type: firstMedia.type,
        file,
        duration: firstMedia.duration ?? 0,
      };
      onSelectedEnd?.({ ...media });
      return;
    }

    if (firstMedia.base64) {
      const media: UploadMedia = {
        type: firstMedia.type ?? 'image',
        file: firstMedia.base64,
        duration: 0,
      };
      onSelectedEnd?.({ ...media });
      return;
    }
  };

  return { pickMedia };
};

export default useMediaPicker;
  • Webview에서 넘겨준 미디어 파일을 url 형태로 만들어서 저장하기
더보기
  useBridgeCallback('upload', (media) => {
    // NOTE : 최초에 빈 썸네일을 추가해준다.
    addBlankMedia();

    let blob: Blob;
    if (media.type === 'image') {
      blob = base64ToBlob(media?.file!, 'image/jpeg');
    } else {
      blob = base64ToBlob(media?.file!, 'video/mp4');
    }

    const url = URL.createObjectURL(blob);

    addMedia([
      {
        type: media.type,
        url: url,
      },
    ]);
    const progress = media.duration / 3000 / 100;

    addProgressList(media.duration / 3000 / 100);
    setProgress((prev) => prev + progress);
    setTimeout(() => {
      scrollToEnd();
    }, 100);
  });

위 부분에서는 createObjectURL을 통해서 base64 형태의 파일을 저장하도록 하였습니다.

base64 방식으로 인코딩을 하게 되면 기존 바이너리 데이터 보다 33% 정도 크기가 늘어나기 때문에

다시 URL 형태로 변환시켜주고 기존받은 파일에 대해서는 웹뷰 브라우저에 무리가 가지 않도록 삭제시켜 주었습니다.

 


4. 완성

이로써 모든 기능이 완성되었습니다.

테스트 환경은 IOS 시뮬레이터로 Next 기반 Webview를 연동시킨 상태입니다.

  1. 짧게 눌러 사진 촬영
  2. 길게 눌러 영상 촬영
  3. 사용자 기기로부터 사진 받아오기
  4. 사용자 기기로부터 영상 받아오기

기능 모두 정상적으로 동작하시는 걸 확인하실 수 있습니다.

테스트 화면

5. 마무리

Native 기능의 꽃이라고도 볼 수 있는 사진 / 영상 촬영 기능에 대해서 제대로 동작을 하지 않아서 되게 당황했었는데

Web 진영에서도 이와 같이 카메라 기능에 대해서 지원을 잘해주어서 정말 다행이었던 거 같습니다.

React Native 측은 버그를 정말 느리게 해결해 주기도 하고 아예 issue를 올려도 무시당하는 경우가 많아서 🥲

React Native로 모든 코드를 작성하고 Next로 만들었던 기능을 두 번 만드는 게 가장 힘들었던 거 같습니다...

그래도 사용자에게 좀 더 좋은 UX로 서비스를 제공할 수 있다면 그걸로 된 거 아닐까 하면서 포스트를 마무리하도록 하겠습니다. 😃

profile

기록의 습관화

@ww8007

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!