From d339078cb1f079e585cf11f988e08a4cd5301e39 Mon Sep 17 00:00:00 2001 From: jmj Date: Thu, 4 Jun 2026 14:21:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(interview):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=20=EC=8A=A4=ED=85=8C=EC=9D=B4=EC=A7=80=20=EC=9B=B9=EC=BA=A0=20?= =?UTF-8?q?=EC=85=80=ED=94=84=EB=B7=B0(PiP)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 면접 중 본인 모습을 우하단 PiP 로 확인할 수 있게 함. 카메라 켜기/끄기 토글, 권한 거부·미지원 시 안내 placeholder. 언마운트 시 트랙 정리. 실제 스트림 전송·분석은 범위 밖(로컬 미리보기만). Co-Authored-By: Claude Opus 4.8 --- .../interview/lib/media/useWebcamPreview.ts | 52 +++++++++++++++ .../interview/ui/live/InterviewStage.tsx | 5 ++ .../interview/ui/live/WebcamSelfView.test.tsx | 65 +++++++++++++++++++ .../interview/ui/live/WebcamSelfView.tsx | 51 +++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 frontend/src/features/interview/lib/media/useWebcamPreview.ts create mode 100644 frontend/src/features/interview/ui/live/WebcamSelfView.test.tsx create mode 100644 frontend/src/features/interview/ui/live/WebcamSelfView.tsx diff --git a/frontend/src/features/interview/lib/media/useWebcamPreview.ts b/frontend/src/features/interview/lib/media/useWebcamPreview.ts new file mode 100644 index 00000000..b4e9397e --- /dev/null +++ b/frontend/src/features/interview/lib/media/useWebcamPreview.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +export type WebcamState = 'idle' | 'requesting' | 'live' | 'denied' | 'unsupported' + +// 라이브 면접 중 본인 카메라 미리보기(PiP) 전용 훅. +// 실제 스트림 전송·분석은 범위 밖 — 로컬 미리보기만 담당한다. +export function useWebcamPreview() { + const videoRef = useRef(null) + const streamRef = useRef(null) + const [state, setState] = useState('idle') + + const stop = useCallback(() => { + streamRef.current?.getTracks().forEach((t) => t.stop()) + streamRef.current = null + if (videoRef.current) { + videoRef.current.srcObject = null + } + setState('idle') + }, []) + + const start = useCallback(async () => { + if (!navigator.mediaDevices?.getUserMedia) { + setState('unsupported') + return + } + setState('requesting') + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: true }) + streamRef.current = stream + setState('live') + } catch { + setState('denied') + } + }, []) + + // 스트림 준비 후 비디오 엘리먼트가 마운트되는 경우까지 커버해 srcObject 바인딩. + useEffect(() => { + if (state === 'live' && videoRef.current && streamRef.current) { + videoRef.current.srcObject = streamRef.current + } + }, [state]) + + // 언마운트 시 트랙 정리 (카메라 LED 가 계속 켜지는 것 방지 — 필수). + useEffect( + () => () => { + streamRef.current?.getTracks().forEach((t) => t.stop()) + }, + [], + ) + + return { videoRef, state, start, stop } +} diff --git a/frontend/src/features/interview/ui/live/InterviewStage.tsx b/frontend/src/features/interview/ui/live/InterviewStage.tsx index d34f7cac..01da3ddb 100644 --- a/frontend/src/features/interview/ui/live/InterviewStage.tsx +++ b/frontend/src/features/interview/ui/live/InterviewStage.tsx @@ -8,6 +8,7 @@ import { ConnectionBanner } from './ConnectionBanner' import { AnswerComposer } from './AnswerComposer' import { StageQuestion } from './StageQuestion' import { InterviewerAvatar } from './InterviewerAvatar' +import { WebcamSelfView } from './WebcamSelfView' import { TranscriptDrawer } from './TranscriptDrawer' const BG = '/interview-session-background.png' @@ -118,6 +119,10 @@ export function InterviewStage({ )} +
+ +
+
) { + Object.defineProperty(navigator, 'mediaDevices', { + value: { getUserMedia }, + configurable: true, + }) +} + +describe('WebcamSelfView', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('켜기 클릭 시 스트림을 받아 live 가 되고, 끄기 클릭 시 트랙을 정지한다', async () => { + const stop = vi.fn() + const stream = { getTracks: () => [{ stop }] } + mockMediaDevices(vi.fn().mockResolvedValue(stream)) + + render() + await userEvent.click(screen.getByRole('button', { name: '카메라 켜기' })) + + const off = await screen.findByRole('button', { name: '카메라 끄기' }) + await userEvent.click(off) + expect(stop).toHaveBeenCalled() + }) + + it('권한 거부 시 안내를 보여준다', async () => { + mockMediaDevices(vi.fn().mockRejectedValue(new Error('denied'))) + + render() + await userEvent.click(screen.getByRole('button', { name: '카메라 켜기' })) + + expect(await screen.findByText('권한 거부됨')).toBeInTheDocument() + }) + + it('언마운트 시 트랙을 정지한다', async () => { + const stop = vi.fn() + const stream = { getTracks: () => [{ stop }] } + mockMediaDevices(vi.fn().mockResolvedValue(stream)) + + const { unmount } = render() + await userEvent.click(screen.getByRole('button', { name: '카메라 켜기' })) + await screen.findByRole('button', { name: '카메라 끄기' }) + + unmount() + expect(stop).toHaveBeenCalled() + }) +}) + +beforeEach(() => { + // jsdom 은 HTMLVideoElement.srcObject 를 구현하지 않으므로 setter 를 무해하게 stub. + if (!Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'srcObject')) { + Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', { + configurable: true, + get() { + return null + }, + set() {}, + }) + } +}) diff --git a/frontend/src/features/interview/ui/live/WebcamSelfView.tsx b/frontend/src/features/interview/ui/live/WebcamSelfView.tsx new file mode 100644 index 00000000..f6934675 --- /dev/null +++ b/frontend/src/features/interview/ui/live/WebcamSelfView.tsx @@ -0,0 +1,51 @@ +import { useWebcamPreview } from '../../lib/media/useWebcamPreview' + +function CameraOffIcon() { + return ( + + + + ) +} + +const PLACEHOLDER: Record = { + idle: '카메라 꺼짐', + requesting: '연결 중…', + denied: '권한 거부됨', + unsupported: '미지원 기기', +} + +// 면접 스테이지에 떠 있는 본인 카메라 미리보기 카드. 위치는 호출부(InterviewStage)가 결정. +export function WebcamSelfView() { + const { videoRef, state, start, stop } = useWebcamPreview() + const live = state === 'live' + + return ( +
+
+
+ +
+ ) +}