wing-ops/frontend/src/tabs/aerial/components/CCTVPlayer.tsx
Nan Kyung Lee 626fea4c75 feat(aerial): CCTV 오일 감지 GPU 추론 연동 및 HNS 초기 핀 제거
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>
2026-03-06 13:31:02 +09:00

334 lines
11 KiB
TypeScript
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';