wing-ops/frontend/src/tabs/aerial/components/CCTVPlayer.tsx

422 lines
14 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;
onClose?: () => void;
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<CCTVPlayerHandle, CCTVPlayerProps>(
(
{
cameraNm,
streamUrl,
sttsCd,
cellIndex = 0,
onClose,
oilDetectionEnabled = false,
vesselDetectionEnabled = false,
intrusionDetectionEnabled = 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-bg-base">
<div className="text-2xl opacity-30 mb-2">📹</div>
<div className="text-label-2 font-korean text-fg-disabled opacity-70">
{sttsCd === 'MAINT' ? '점검중' : '오프라인'}
</div>
<div className="text-caption font-korean text-fg-disabled 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-bg-base">
<div className="text-2xl opacity-20 mb-2">📹</div>
<div className="text-caption font-korean text-fg-disabled opacity-50">
URL
</div>
<div className="text-caption font-korean text-fg-disabled opacity-30 mt-1">
{cameraNm}
</div>
</div>
);
}
// 에러
if (playerState === 'error') {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
<div className="text-2xl opacity-30 mb-2"></div>
<div className="text-caption font-korean text-color-danger opacity-70"> </div>
<div className="text-caption font-korean text-fg-disabled opacity-40 mt-1">
{cameraNm}
</div>
<button
onClick={() => setRetryKey((k) => k + 1)}
className="mt-2 px-2.5 py-1 rounded text-caption font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-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-bg-base z-10">
<div className="text-lg opacity-40 animate-pulse mb-2">📹</div>
<div className="text-caption font-korean text-fg-disabled 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')}
/>
)}
{/* 안전관리 감지 상태 배지 */}
{(vesselDetectionEnabled || intrusionDetectionEnabled) && (
<div className="absolute top-2 right-2 flex flex-col gap-1 z-20">
{vesselDetectionEnabled && (
<div
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-caption font-bold"
style={{
background: 'color-mix(in srgb, var(--color-info) 30%, transparent)',
color: 'var(--color-info)',
}}
>
🚢
</div>
)}
{intrusionDetectionEnabled && (
<div
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-caption font-bold"
style={{
background: 'color-mix(in srgb, var(--color-warning) 30%, transparent)',
color: 'var(--color-warning)',
}}
>
🚨
</div>
)}
</div>
)}
{/* OSD 오버레이 */}
<div className="absolute top-2 left-2 flex items-center gap-1.5 z-20">
<span className="text-caption font-bold px-1.5 py-0.5 rounded bg-black/70 text-white">
{cameraNm}
</span>
{sttsCd === 'LIVE' && (
<span
className="text-caption font-bold px-1 py-0.5 rounded text-color-danger"
style={{ background: 'color-mix(in srgb, var(--color-danger) 30%, transparent)' }}
>
REC
</span>
)}
</div>
{/* 닫기 (지도로 돌아가기) */}
{onClose && (
<button
onClick={onClose}
className="absolute top-2 right-2 w-7 h-7 flex items-center justify-center rounded bg-black/60 hover:bg-black/80 text-white/70 hover:text-white cursor-pointer transition-colors z-20"
title="지도로 돌아가기"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M19 12H5" />
<path d="M12 19l-7-7 7-7" />
</svg>
</button>
)}
{/* <div className="absolute bottom-2 left-2 text-caption font-mono px-1.5 py-0.5 rounded text-fg-disabled bg-black/70 z-20">
{coordDc ?? ''}
{sourceNm ? ` · ${sourceNm}` : ''}
</div> */}
</div>
);
},
);
CCTVPlayer.displayName = 'CCTVPlayer';