웹뷰 카메라 문의드립니다

안녕하세요.

웹뷰에서 카메라 스트림을 사용하는 서비스를 개발하고 있는데, 토스 콘솔에서 앱 출시 QR 테스트를 하면 iOS 환경에서 카메라 스트림이 연결되지 않습니다.

Android 환경에서는 정상적으로 연결되는 것을 확인했습니다.

npm dev 와 같은 로컬 서버 환경에서는 iOS 환경에서도 정상적으로 연결되는데, 확인 부탁드리겠습니다.

카메라 연결은 await navigator.mediaDevices.getUserMedia(); 사용했습니다.

안녕하세요 :slight_smile:
확인 후 공유드리겠습니다 :man_bowing:

2개의 좋아요

혹시 언제쯤 답변 받을 수 있을까요..?

저희가 런칭이 11월이 목표라서 어서 해결이 되어야해서요 :sob:

1개의 좋아요

안녕하세요 !

확인해보니 allowsInlineMediaPlayback 프로퍼티를 추가로 넘기면 동작하는 것 같습니다.

관련 가이드를 전달드려요

2개의 좋아요

@Dylan 안녕하세요! 말씀하신대로 적용해봤으나.. 여전히 동일한 현상이 반복됩니다.. :sob:

올려주신 가이드라인대로 granite.config.ts에 아래 코드를 추가하였고,

  webViewProps: {

    allowsInlineMediaPlayback: true

  },

video tag 속성에도 webkit-playsinline 을 추가했습니다.

<video 
        ref={videoRef} 
        autoPlay 
        muted 
        webkit-playsinline="true" 
        playsInline 
      />

확인한 단말기 모델은 아이폰17 프로인데 확인했는데, 혹시 해당 기기의 문제일까요?

다른 단말기에서도 동일하게 발생하는 것을 확인했습니다.

테스트한 코드를 공유드려요 :man_bowing:
필요치 않은 부분은 무시부탁드려용

import { useState, useCallback, useRef, useEffect } from 'react'
import './App.css'

function App() {
  const [isVideoStreaming, setIsVideoStreaming] = useState(false);
  const [facingMode, setFacingMode] = useState<'user' | 'environment'>('environment'); 
  const [cameraError, setCameraError] = useState<string>('');
  const videoRef = useRef<HTMLVideoElement>(null);
  const streamRef = useRef<MediaStream | null>(null);

  
  useEffect(() => {
    if (isVideoStreaming && streamRef.current && videoRef.current) {
      const video = videoRef.current;
      const stream = streamRef.current;
      
      video.srcObject = stream;
      
      // 비디오 이벤트 리스너
      video.onloadedmetadata = () => {
        console.log('useEffect: 메타데이터 로드됨');
        video.play().catch(e => console.error('useEffect: play 실패', e));
      };
      
      video.onpause = () => {
        console.warn('비디오가 일시정지됨! 다시 재생 시도...');
        video.play().catch(e => console.error('재생 재시도 실패:', e));
      };
      
      video.onstalled = () => {
        console.warn('비디오 스톨됨');
      };
      
      video.onsuspend = () => {
        console.warn('비디오 서스펜드됨');
      };
      
      // 주기적으로 재생 상태 확인
      const checkInterval = setInterval(() => {
        if (video.paused && streamRef.current) {
          console.log('비디오가 멈춤 감지, 재생 재시도');
          video.play().catch(e => console.error('재생 실패:', e));
        }
      }, 1000);
      
      return () => {
        clearInterval(checkInterval);
      };
    }
  }, [isVideoStreaming]);

  const startVideoStreaming = useCallback(async () => {
    setCameraError('');
    
    try {

      const constraints = { 
        video: {
          width: { ideal: 1280 },
          height: { ideal: 720 },
          facingMode: facingMode 
        },
        audio: false 
      };
      
    
      
      const stream = await navigator.mediaDevices.getUserMedia(constraints);
      
    
      // 스트림 저장 (먼저 상태 업데이트)
      streamRef.current = stream;
      setIsVideoStreaming(true);
      setCameraError('');
      
      // 비디오 엘리먼트가 렌더링될 때까지 약간 대기
      await new Promise(resolve => setTimeout(resolve, 100));
      
      // 비디오 태그에 스트림 연결
      if (videoRef.current) {
        console.log('비디오 엘리먼트에 스트림 연결 중...');
        console.log('videoRef.current:', videoRef.current);
        
        videoRef.current.onloadedmetadata = () => {
          console.log('비디오 메타데이터 로드 완료');
          console.log('비디오 크기:', videoRef.current?.videoWidth, 'x', videoRef.current?.videoHeight);
        };
        
        videoRef.current.onplay = () => {
          console.log('비디오 재생 시작');
        };
        
        videoRef.current.onerror = (e) => {
          console.error('비디오 재생 에러:', e);
        };
        
        // srcObject 설정
        videoRef.current.srcObject = stream;
        console.log('srcObject 설정 완료:', !!videoRef.current.srcObject);
        
        // play 시도
        try {
          await videoRef.current.play();
          
        } catch (playError) {
          
          // 한 번 더 시도
          setTimeout(async () => {
            try {
              await videoRef.current?.play();
              console.log('재시도로 비디오 재생 성공');
            } catch (e) {
              console.error('재시도도 실패:', e);
            }
          }, 500);
        }
      } else {
        console.error('videoRef.current가 여전히 null입니다');
      }
      
      alert('비디오 스트리밍 시작 성공!');
      
    } catch (error: any) {
      console.error('비디오 스트림 연결 실패:', error);
      console.error('에러 타입:', error.constructor.name);
      console.error('에러 이름:', error.name);
      console.error('에러 메시지:', error.message);
      console.error('에러 스택:', error.stack);
      
      setCameraError(`비디오 스트림 실패: ${error.name} - ${error.message}`);
      alert(`비디오 스트리밍 실패: ${error.name} - ${error.message}`);
    }
  }, [facingMode]);

  const stopVideoStreaming = useCallback(() => {
    if (streamRef.current) {
      streamRef.current.getTracks().forEach(track => {
        track.stop();
        console.log('비디오 트랙 정지:', track.label);
      });
      streamRef.current = null;
    }
    
    if (videoRef.current) {
      videoRef.current.srcObject = null;
    }
    
    setIsVideoStreaming(false);
    console.log('비디오 스트림 종료');
  }, []);

  const switchCamera = useCallback(async () => {
    console.log('카메라 전환 시작...');
    
    // 현재 스트림 종료
    if (streamRef.current) {
      streamRef.current.getTracks().forEach(track => track.stop());
      streamRef.current = null;
    }
    
    // 카메라 모드 전환
    const newFacingMode = facingMode === 'user' ? 'environment' : 'user';
    setFacingMode(newFacingMode);
    console.log('카메라 모드:', newFacingMode === 'user' ? '전면' : '후면');
    
    // 약간 대기 후 새 스트림 시작
    await new Promise(resolve => setTimeout(resolve, 100));
    
    try {
      const constraints = { 
        video: {
          width: { ideal: 1280 },
          height: { ideal: 720 },
          facingMode: newFacingMode
        },
        audio: false 
      };
      
      const stream = await navigator.mediaDevices.getUserMedia(constraints);
      streamRef.current = stream;
      
      if (videoRef.current) {
        videoRef.current.srcObject = stream;
        await videoRef.current.play();
      }
    
    } catch (error: any) {
      setCameraError(`카메라 전환 실패: ${error.message}`);
    }
  }, [facingMode]);

  return (
    <>
      <div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
        
        {/* 비디오 스트리밍 상태 */}
        <div style={{ marginBottom: '20px', padding: '15px', backgroundColor: '#f0f0f0', borderRadius: '8px' }}>
          <h3 style={{ margin: '0 0 10px 0' }}>비디오 스트리밍 상태</h3>
          <p style={{ margin: '5px 0', fontSize: '16px', fontWeight: 'bold' }}>
            {isVideoStreaming ? '스트리밍 중' : '스트리밍 안함'}
          </p>
          {isVideoStreaming && (
            <p style={{ margin: '5px 0', fontSize: '14px', color: '#666' }}>
              현재 카메라: {facingMode === 'user' ? '전면' : ' 후면'}
            </p>
          )}
        </div>

        {/* 실시간 비디오 스트림 */}
        <div style={{ 
          width: '100%', 
          maxWidth: '640px', 
          minHeight: '360px',
          border: '3px solid ' + (isVideoStreaming ? '#00ff00' : '#ff0000'),
          borderRadius: '8px',
          overflow: 'hidden',
          backgroundColor: '#000',
          position: 'relative',
          margin: '0 auto 20px auto'
        }}>
          {isVideoStreaming ? (
            <>
              <video 
                ref={videoRef}
                autoPlay
                playsInline
                webkit-playsinline="true"
                x-webkit-airplay="allow"
                muted
                controls={false}
                style={{ 
                  width: '100%', 
                  height: 'auto',
                  minHeight: '360px',
                  display: 'block',
                  transform: facingMode === 'user' ? 'scaleX(-1)' : 'none', 
                  objectFit: 'cover'
                }}
              />
              <div style={{
                position: 'absolute',
                top: '10px',
                left: '10px',
                background: 'rgba(0, 255, 0, 0.8)',
                color: 'white',
                padding: '5px 10px',
                borderRadius: '4px',
                fontSize: '12px',
                fontWeight: 'bold'
              }}>
                🎥 LIVE
              </div>
            </>
          ) : (
            <div style={{
              width: '100%',
              height: '360px',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
              color: 'white',
              fontSize: '16px'
            }}>
              비디오 스트리밍이 꺼져있습니다
            </div>
          )}
        </div>

        {/* 에러 메시지 */}
        {cameraError && (
          <div style={{ 
            padding: '15px', 
            backgroundColor: '#f8d7da', 
            borderRadius: '8px', 
            marginBottom: '20px',
            border: '1px solid #f5c6cb'
          }}>
            <p style={{ margin: 0, color: '#721c24', fontSize: '14px' }}>
               {cameraError}
            </p>
          </div>
        )}

        {/* 컨트롤 버튼들 */}
        <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
          {!isVideoStreaming ? (
            <button 
              onClick={startVideoStreaming}
              style={{
                padding: '15px',
                fontSize: '16px',
                backgroundColor: '#007AFF',
                color: 'white',
                border: 'none',
                borderRadius: '8px',
                cursor: 'pointer',
                fontWeight: 'bold'
              }}
            >
              비디오 스트리밍 시작
            </button>
          ) : (
            <>
              <button 
                onClick={stopVideoStreaming}
                style={{
                  padding: '15px',
                  fontSize: '16px',
                  backgroundColor: '#dc3545',
                  color: 'white',
                  border: 'none',
                  borderRadius: '8px',
                  cursor: 'pointer',
                  fontWeight: 'bold'
                }}
              >
                ⏹️ 비디오 스트리밍 중지
              </button>

              <button 
                onClick={switchCamera}
                style={{
                  padding: '15px',
                  fontSize: '16px',
                  backgroundColor: '#28a745',
                  color: 'white',
                  border: 'none',
                  borderRadius: '8px',
                  cursor: 'pointer',
                  fontWeight: 'bold'
                }}
              >
                🔄 카메라 전환 ({facingMode === 'user' ? '후면으로' : '전면으로'})
              </button>
              
              <button 
                onClick={() => {
                  if (videoRef.current) {
                    const video = videoRef.current;
                    const info = {
                      'srcObject 존재': !!video.srcObject,
                      'paused': video.paused,
                      'readyState': video.readyState,
                      'networkState': video.networkState,
                      'videoWidth': video.videoWidth,
                      'videoHeight': video.videoHeight,
                      'currentTime': video.currentTime,
                      'duration': video.duration,
                      'muted': video.muted,
                      'autoplay': video.autoplay,
                      'playsInline': (video as any).playsInline,
                    };
                    console.log('비디오 엘리먼트 상태:', info);
                    alert(JSON.stringify(info, null, 2));
                  } else {
                    alert('videoRef.current가 null입니다');
                  }
                }}
                style={{
                  padding: '15px',
                  fontSize: '16px',
                  backgroundColor: '#6c757d',
                  color: 'white',
                  border: 'none',
                  borderRadius: '8px',
                  cursor: 'pointer',
                  fontWeight: 'bold'
                }}
              >
                🔍 비디오 상태 확인
              </button>
            </>
          )}
        </div>
      </div>
    </>
  )
}

export default App

2개의 좋아요

감사합니다 해결했습니다! :man_bowing: