CCTV 오일 유출 감지: - GPU 추론 서버 FastAPI 서비스 (oil_inference_server.py) - Express 프록시 엔드포인트 (POST /api/aerial/oil-detect) - 프론트엔드 API 연동 (oilDetection.ts, useOilDetection.ts) - 4종 유류 클래스별 색상 오버레이 (OilDetectionOverlay.tsx) - 캡처 기능 (비디오+오버레이 합성 PNG 다운로드) - Rate limit HLS 스트리밍 skip + 한도 500 상향 HNS 대기확산: - 초기 핀 포인트 제거 (지도 클릭으로 선택) - 좌표 미선택 시 안내 메시지 표시 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
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;
|
||
}
|
||
|
||
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<CCTVPlayerHandle, CCTVPlayerProps>(({
|
||
cameraNm,
|
||
streamUrl,
|
||
sttsCd,
|
||
coordDc,
|
||
sourceNm,
|
||
cellIndex = 0,
|
||
oilDetectionEnabled = false,
|
||
}, ref) => {
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const hlsRef = useRef<Hls | null>(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<HTMLCanvasElement>('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 (
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18]">
|
||
<div className="text-2xl opacity-30 mb-2">📹</div>
|
||
<div className="text-[11px] font-korean text-text-3 opacity-70">
|
||
{sttsCd === 'MAINT' ? '점검중' : '오프라인'}
|
||
</div>
|
||
<div className="text-[9px] font-korean text-text-3 opacity-40 mt-1">{cameraNm}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// URL 미설정
|
||
if (playerState === 'no-url') {
|
||
return (
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18]">
|
||
<div className="text-2xl opacity-20 mb-2">📹</div>
|
||
<div className="text-[10px] font-korean text-text-3 opacity-50">스트림 URL 미설정</div>
|
||
<div className="text-[9px] font-korean text-text-3 opacity-30 mt-1">{cameraNm}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 에러
|
||
if (playerState === 'error') {
|
||
return (
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18]">
|
||
<div className="text-2xl opacity-30 mb-2">⚠️</div>
|
||
<div className="text-[10px] font-korean text-status-red opacity-70">연결 실패</div>
|
||
<div className="text-[9px] font-korean text-text-3 opacity-40 mt-1">{cameraNm}</div>
|
||
<button
|
||
onClick={() => setRetryKey(k => k + 1)}
|
||
className="mt-2 px-2.5 py-1 rounded text-[9px] font-korean bg-bg-3 border border-border text-text-2 cursor-pointer hover:bg-bg-hover transition-colors"
|
||
>
|
||
재시도
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div ref={containerRef} className="absolute inset-0">
|
||
{/* 로딩 오버레이 */}
|
||
{playerState === 'loading' && (
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18] z-10">
|
||
<div className="text-lg opacity-40 animate-pulse mb-2">📹</div>
|
||
<div className="text-[10px] font-korean text-text-3 opacity-50">연결 중...</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* HLS / MP4 */}
|
||
{(streamType === 'hls' || streamType === 'mp4') && (
|
||
<video
|
||
ref={videoRef}
|
||
key={`video-${cellIndex}-${retryKey}`}
|
||
className="absolute inset-0 w-full h-full object-cover"
|
||
muted
|
||
autoPlay
|
||
playsInline
|
||
loop={streamType === 'mp4'}
|
||
/>
|
||
)}
|
||
|
||
{/* 오일 감지 오버레이 */}
|
||
{oilDetectionEnabled && (
|
||
<OilDetectionOverlay result={oilResult} isAnalyzing={oilAnalyzing} error={oilError} />
|
||
)}
|
||
|
||
{/* MJPEG */}
|
||
{streamType === 'mjpeg' && proxiedUrl && (
|
||
<img
|
||
src={proxiedUrl}
|
||
alt={cameraNm}
|
||
className="absolute inset-0 w-full h-full object-cover"
|
||
onError={() => setHlsPlayerState('error')}
|
||
/>
|
||
)}
|
||
|
||
{/* iframe (원본 URL 사용 — iframe은 자체 CORS) */}
|
||
{streamType === 'iframe' && streamUrl && (
|
||
<iframe
|
||
src={streamUrl}
|
||
title={cameraNm}
|
||
className="absolute inset-0 w-full h-full border-none"
|
||
allow="autoplay; encrypted-media"
|
||
onError={() => setHlsPlayerState('error')}
|
||
/>
|
||
)}
|
||
|
||
{/* OSD 오버레이 */}
|
||
<div className="absolute top-2 left-2 flex items-center gap-1.5 z-20">
|
||
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/70 text-white">
|
||
{cameraNm}
|
||
</span>
|
||
{sttsCd === 'LIVE' && (
|
||
<span
|
||
className="text-[8px] font-bold px-1 py-0.5 rounded text-[#f87171]"
|
||
style={{ background: 'rgba(239,68,68,.3)' }}
|
||
>
|
||
● REC
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded text-text-3 bg-black/70 z-20">
|
||
{coordDc ?? ''}{sourceNm ? ` · ${sourceNm}` : ''}
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
CCTVPlayer.displayName = 'CCTVPlayer';
|