import { useRef, useEffect, useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'; import Hls from 'hls.js'; import { detectStreamType } from '../utils/streamUtils'; import { useOilDetection } from '../hooks/useOilDetection'; import OilDetectionOverlay from './OilDetectionOverlay'; interface CCTVPlayerProps { cameraNm: string; streamUrl: string | null; sttsCd: string; coordDc?: string | null; sourceNm?: string | null; cellIndex?: number; oilDetectionEnabled?: boolean; vesselDetectionEnabled?: boolean; intrusionDetectionEnabled?: boolean; } export interface CCTVPlayerHandle { capture: () => void; } type PlayerState = 'loading' | 'playing' | 'error' | 'offline' | 'no-url'; /** 외부 HLS URL을 백엔드 프록시 경유 URL로 변환 */ function toProxyUrl(url: string): string { if (url.startsWith('http://') || url.startsWith('https://')) { return `/api/aerial/cctv/stream-proxy?url=${encodeURIComponent(url)}`; } return url; } export const CCTVPlayer = forwardRef(({ cameraNm, streamUrl, sttsCd, coordDc, sourceNm, cellIndex = 0, oilDetectionEnabled = false, vesselDetectionEnabled = false, intrusionDetectionEnabled = false, }, ref) => { const videoRef = useRef(null); const containerRef = useRef(null); const hlsRef = useRef(null); const [hlsPlayerState, setHlsPlayerState] = useState<'loading' | 'playing' | 'error'>('loading'); const [retryKey, setRetryKey] = useState(0); /** 원본 URL 기반으로 타입 감지, 재생은 프록시 URL 사용 */ const proxiedUrl = useMemo( () => (streamUrl ? toProxyUrl(streamUrl) : null), [streamUrl], ); /** props 기반으로 상태를 동기적으로 파생 */ const isOffline = sttsCd === 'OFFLINE' || sttsCd === 'MAINT'; const hasNoUrl = !isOffline && (!streamUrl || !proxiedUrl); const streamType = useMemo( () => (streamUrl && !isOffline ? detectStreamType(streamUrl) : null), [streamUrl, isOffline], ); const playerState: PlayerState = isOffline ? 'offline' : hasNoUrl ? 'no-url' : (streamType === 'mjpeg' || streamType === 'iframe') ? 'playing' : hlsPlayerState; const { result: oilResult, isAnalyzing: oilAnalyzing, error: oilError } = useOilDetection({ videoRef, enabled: oilDetectionEnabled && playerState === 'playing' && (streamType === 'hls' || streamType === 'mp4'), }); useImperativeHandle(ref, () => ({ capture: () => { const container = containerRef.current; if (!container) return; const w = container.clientWidth; const h = container.clientHeight; const canvas = document.createElement('canvas'); canvas.width = w * 2; canvas.height = h * 2; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.scale(2, 2); // 1) video frame const video = videoRef.current; if (video && video.readyState >= 2) { ctx.drawImage(video, 0, 0, w, h); } // 2) oil detection overlay const overlayCanvas = container.querySelector('canvas'); if (overlayCanvas) { ctx.drawImage(overlayCanvas, 0, 0, w, h); } // 3) OSD: camera name + timestamp ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillRect(8, 8, ctx.measureText(cameraNm).width + 20, 22); ctx.font = 'bold 12px sans-serif'; ctx.fillStyle = '#ffffff'; ctx.fillText(cameraNm, 18, 23); const ts = new Date().toLocaleString('ko-KR'); ctx.font = '10px monospace'; ctx.fillStyle = 'rgba(0,0,0,0.7)'; const tsW = ctx.measureText(ts).width + 16; ctx.fillRect(8, h - 26, tsW, 20); ctx.fillStyle = '#a0aec0'; ctx.fillText(ts, 16, h - 12); // 4) oil detection info if (oilResult && oilResult.regions.length > 0) { const areaText = oilResult.totalAreaM2 >= 1000 ? `오일 감지: ${(oilResult.totalAreaM2 / 1_000_000).toFixed(1)} km² (${oilResult.totalPercentage.toFixed(1)}%)` : `오일 감지: ~${Math.round(oilResult.totalAreaM2)} m² (${oilResult.totalPercentage.toFixed(1)}%)`; ctx.font = 'bold 11px sans-serif'; const atW = ctx.measureText(areaText).width + 16; ctx.fillStyle = 'rgba(239,68,68,0.25)'; ctx.fillRect(8, h - 48, atW, 18); ctx.fillStyle = '#f87171'; ctx.fillText(areaText, 16, h - 34); } // download const link = document.createElement('a'); link.download = `CCTV_${cameraNm}_${new Date().toISOString().slice(0, 19).replace(/:/g, '')}.png`; link.href = canvas.toDataURL('image/png'); link.click(); }, }), [cameraNm, oilResult]); const destroyHls = useCallback(() => { if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } }, []); useEffect(() => { if (isOffline || hasNoUrl || !streamUrl || !proxiedUrl) { destroyHls(); return; } const type = detectStreamType(streamUrl); queueMicrotask(() => setHlsPlayerState('loading')); if (type === 'hls') { const video = videoRef.current; if (!video) return; if (Hls.isSupported()) { destroyHls(); const hls = new Hls({ enableWorker: true, lowLatencyMode: true, maxBufferLength: 10, maxMaxBufferLength: 30, }); hlsRef.current = hls; hls.loadSource(proxiedUrl); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () => { setHlsPlayerState('playing'); video.play().catch(() => {}); }); hls.on(Hls.Events.ERROR, (_event, data) => { if (data.fatal) { setHlsPlayerState('error'); if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { setTimeout(() => hls.startLoad(), 3000); } } }); return () => destroyHls(); } // Safari 네이티브 HLS (프록시 경유) if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = proxiedUrl; const onLoaded = () => setHlsPlayerState('playing'); const onError = () => setHlsPlayerState('error'); video.addEventListener('loadeddata', onLoaded); video.addEventListener('error', onError); video.play().catch(() => {}); return () => { video.removeEventListener('loadeddata', onLoaded); video.removeEventListener('error', onError); }; } queueMicrotask(() => setHlsPlayerState('error')); return; } if (type === 'mp4') { const video = videoRef.current; if (!video) return; video.src = proxiedUrl; const onLoaded = () => setHlsPlayerState('playing'); const onError = () => setHlsPlayerState('error'); video.addEventListener('loadeddata', onLoaded); video.addEventListener('error', onError); video.play().catch(() => {}); return () => { video.removeEventListener('loadeddata', onLoaded); video.removeEventListener('error', onError); }; } if (type === 'mjpeg' || type === 'iframe') { return; } queueMicrotask(() => setHlsPlayerState('error')); return () => destroyHls(); }, [streamUrl, proxiedUrl, isOffline, hasNoUrl, destroyHls, retryKey]); // 오프라인 if (playerState === 'offline') { return (
📹
{sttsCd === 'MAINT' ? '점검중' : '오프라인'}
{cameraNm}
); } // URL 미설정 if (playerState === 'no-url') { return (
📹
스트림 URL 미설정
{cameraNm}
); } // 에러 if (playerState === 'error') { return (
⚠️
연결 실패
{cameraNm}
); } return (
{/* 로딩 오버레이 */} {playerState === 'loading' && (
📹
연결 중...
)} {/* HLS / MP4 */} {(streamType === 'hls' || streamType === 'mp4') && (