diff --git a/backend/src/aerial/aerialRouter.ts b/backend/src/aerial/aerialRouter.ts index a002e77..414955f 100644 --- a/backend/src/aerial/aerialRouter.ts +++ b/backend/src/aerial/aerialRouter.ts @@ -1,4 +1,5 @@ import express from 'express'; +import path from 'path'; import { listMedia, createMedia, @@ -9,6 +10,10 @@ import { isValidSatStatus, requestOilInference, checkInferenceHealth, + listDroneStreams, + startDroneStream, + stopDroneStream, + getHlsDirectory, } from './aerialService.js'; import { isValidNumber } from '../middleware/security.js'; import { requireAuth, requirePermission } from '../auth/authMiddleware.js'; @@ -162,6 +167,87 @@ router.get('/cctv/stream-proxy', async (req, res) => { } }); +// ============================================================ +// DRONE STREAM 라우트 (RTSP → HLS) +// ============================================================ + +// GET /api/aerial/drone/streams — 드론 스트림 목록 + 상태 +router.get('/drone/streams', requireAuth, requirePermission('aerial', 'READ'), async (_req, res) => { + try { + const streams = listDroneStreams(); + res.json(streams); + } catch (err) { + console.error('[aerial] 드론 스트림 목록 오류:', err); + res.status(500).json({ error: '드론 스트림 목록 조회 실패' }); + } +}); + +// POST /api/aerial/drone/streams/:id/start — 드론 스트림 시작 (RTSP→HLS 변환) +router.post('/drone/streams/:id/start', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => { + try { + const result = startDroneStream(req.params.id as string); + if (!result.success) { + res.status(400).json({ error: result.error }); + return; + } + res.json(result); + } catch (err) { + console.error('[aerial] 드론 스트림 시작 오류:', err); + res.status(500).json({ error: '드론 스트림 시작 실패' }); + } +}); + +// POST /api/aerial/drone/streams/:id/stop — 드론 스트림 중지 +router.post('/drone/streams/:id/stop', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => { + try { + const result = stopDroneStream(req.params.id as string); + res.json(result); + } catch (err) { + console.error('[aerial] 드론 스트림 중지 오류:', err); + res.status(500).json({ error: '드론 스트림 중지 실패' }); + } +}); + +// GET /api/aerial/drone/hls/:id/* — HLS 정적 파일 서빙 (.m3u8, .ts) +router.get('/drone/hls/:id/*', async (req, res) => { + try { + const id = req.params.id as string; + const hlsDir = getHlsDirectory(id); + if (!hlsDir) { + res.status(404).json({ error: '스트림을 찾을 수 없습니다' }); + return; + } + + // wildcard: req.params[0] contains the rest of the path + // Cast through unknown because @types/express v5 types the wildcard key as string[] + const rawParams = req.params as unknown as Record; + const wildcardRaw = rawParams['0'] ?? ''; + const wildcardParam = Array.isArray(wildcardRaw) ? wildcardRaw.join('/') : wildcardRaw; + const filePath = path.join(hlsDir, wildcardParam); + + // Security: prevent path traversal + if (!filePath.startsWith(hlsDir)) { + res.status(403).json({ error: '접근 거부' }); + return; + } + + const ext = path.extname(filePath).toLowerCase(); + const contentType = ext === '.m3u8' ? 'application/vnd.apple.mpegurl' + : ext === '.ts' ? 'video/mp2t' + : 'application/octet-stream'; + + res.set({ + 'Content-Type': contentType, + 'Cache-Control': 'no-cache', + 'Access-Control-Allow-Origin': '*', + }); + res.sendFile(filePath); + } catch (err) { + console.error('[aerial] HLS 파일 서빙 오류:', err); + res.status(404).json({ error: 'HLS 파일을 찾을 수 없습니다' }); + } +}); + // ============================================================ // SAT_REQUEST 라우트 // ============================================================ diff --git a/backend/src/aerial/aerialService.ts b/backend/src/aerial/aerialService.ts index aec19b9..8b9318a 100644 --- a/backend/src/aerial/aerialService.ts +++ b/backend/src/aerial/aerialService.ts @@ -1,4 +1,8 @@ import { wingPool } from '../db/wingDb.js'; +import { spawn, type ChildProcess } from 'child_process'; +import { existsSync, mkdirSync, rmSync } from 'fs'; +import { execSync } from 'child_process'; +import path from 'path'; // ============================================================ // AERIAL_MEDIA @@ -398,3 +402,175 @@ export async function checkInferenceHealth(): Promise<{ status: string; device?: return { status: 'unavailable' }; } } + +// ============================================================ +// DRONE STREAM (RTSP → HLS via FFmpeg) +// ============================================================ + +export interface DroneStreamConfig { + id: string; + name: string; + shipName: string; + droneModel: string; + ip: string; + rtspUrl: string; + region: string; +} + +export interface DroneStreamStatus extends DroneStreamConfig { + status: 'idle' | 'starting' | 'streaming' | 'error'; + hlsUrl: string | null; + error: string | null; +} + +const DRONE_STREAMS: DroneStreamConfig[] = [ + { id: 'busan-1501', name: '1501함 드론', shipName: '부산서 1501함', droneModel: 'DJI M300 RTK', ip: '10.26.7.213', rtspUrl: 'rtsp://10.26.7.213:554/stream0', region: '부산' }, + { id: 'incheon-3008', name: '3008함 드론', shipName: '인천서 3008함', droneModel: 'DJI M30T', ip: '10.26.5.21', rtspUrl: 'rtsp://10.26.5.21:554/stream0', region: '인천' }, + { id: 'mokpo-3015', name: '3015함 드론', shipName: '목포서 3015함', droneModel: 'DJI Mavic 3E', ip: '10.26.7.85', rtspUrl: 'rtsp://10.26.7.85:554/stream0', region: '목포' }, +]; + +const HLS_OUTPUT_DIR = '/tmp/wing-drone-hls'; +const activeProcesses = new Map(); + +function getHlsDir(id: string): string { + return path.join(HLS_OUTPUT_DIR, id); +} + +function checkFfmpeg(): boolean { + try { + execSync('which ffmpeg', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +export function listDroneStreams(): DroneStreamStatus[] { + return DRONE_STREAMS.map(ds => { + const active = activeProcesses.get(ds.id); + return { + ...ds, + status: active?.status ?? 'idle', + hlsUrl: active?.status === 'streaming' ? `/api/aerial/drone/hls/${ds.id}/stream.m3u8` : null, + error: active?.error ?? null, + }; + }); +} + +export function startDroneStream(id: string): { success: boolean; error?: string; hlsUrl?: string } { + const config = DRONE_STREAMS.find(d => d.id === id); + if (!config) return { success: false, error: '알 수 없는 드론 스트림 ID' }; + + if (activeProcesses.has(id)) { + const existing = activeProcesses.get(id)!; + if (existing.status === 'streaming' || existing.status === 'starting') { + return { success: true, hlsUrl: `/api/aerial/drone/hls/${id}/stream.m3u8` }; + } + } + + if (!checkFfmpeg()) { + return { success: false, error: 'FFmpeg가 설치되어 있지 않습니다. 서버에 FFmpeg를 설치하세요.' }; + } + + const hlsDir = getHlsDir(id); + if (!existsSync(hlsDir)) { + mkdirSync(hlsDir, { recursive: true }); + } + + const outputPath = path.join(hlsDir, 'stream.m3u8'); + + const ffmpeg = spawn('ffmpeg', [ + '-rtsp_transport', 'tcp', + '-i', config.rtspUrl, + '-c:v', 'copy', + '-c:a', 'aac', + '-f', 'hls', + '-hls_time', '2', + '-hls_list_size', '5', + '-hls_flags', 'delete_segments', + '-y', + outputPath, + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + + const entry = { process: ffmpeg, status: 'starting' as const, error: null as string | null }; + activeProcesses.set(id, entry); + + // Monitor for m3u8 file creation to confirm streaming + const checkInterval = setInterval(() => { + if (existsSync(outputPath)) { + const e = activeProcesses.get(id); + if (e && e.status === 'starting') { + e.status = 'streaming'; + } + clearInterval(checkInterval); + } + }, 500); + + // Timeout after 15 seconds + setTimeout(() => { + clearInterval(checkInterval); + const e = activeProcesses.get(id); + if (e && e.status === 'starting') { + e.status = 'error'; + e.error = 'RTSP 연결 시간 초과 — 내부망에서만 접속 가능합니다.'; + ffmpeg.kill('SIGTERM'); + } + }, 15000); + + let stderrBuf = ''; + ffmpeg.stderr?.on('data', (chunk: Buffer) => { + stderrBuf += chunk.toString(); + }); + + ffmpeg.on('close', (code) => { + clearInterval(checkInterval); + const e = activeProcesses.get(id); + if (e) { + if (e.status !== 'error') { + e.status = 'error'; + e.error = code !== 0 + ? `FFmpeg 종료 (코드: ${code})${stderrBuf.includes('Connection refused') ? ' — RTSP 연결 거부됨' : ''}` + : '스트림 종료'; + } + } + console.log(`[drone] FFmpeg 종료 (${id}): code=${code}`); + }); + + ffmpeg.on('error', (err) => { + clearInterval(checkInterval); + const e = activeProcesses.get(id); + if (e) { + e.status = 'error'; + e.error = `FFmpeg 실행 오류: ${err.message}`; + } + }); + + return { success: true, hlsUrl: `/api/aerial/drone/hls/${id}/stream.m3u8` }; +} + +export function stopDroneStream(id: string): { success: boolean } { + const entry = activeProcesses.get(id); + if (!entry) return { success: true }; + + entry.process.kill('SIGTERM'); + activeProcesses.delete(id); + + // Cleanup HLS files + const hlsDir = getHlsDir(id); + try { + if (existsSync(hlsDir)) { + rmSync(hlsDir, { recursive: true, force: true }); + } + } catch (err) { + console.error(`[drone] HLS 디렉토리 정리 실패 (${id}):`, err); + } + + return { success: true }; +} + +export function getHlsDirectory(id: string): string | null { + const config = DRONE_STREAMS.find(d => d.id === id); + if (!config) return null; + const dir = getHlsDir(id); + return existsSync(dir) ? dir : null; +} diff --git a/frontend/src/tabs/aerial/components/RealtimeDrone.tsx b/frontend/src/tabs/aerial/components/RealtimeDrone.tsx index 3525b2d..c161958 100644 --- a/frontend/src/tabs/aerial/components/RealtimeDrone.tsx +++ b/frontend/src/tabs/aerial/components/RealtimeDrone.tsx @@ -1,625 +1,354 @@ -import { useState, useEffect, useMemo } from 'react' -import { Map, useControl } from '@vis.gl/react-maplibre' -import { MapboxOverlay } from '@deck.gl/mapbox' -import { ScatterplotLayer, PathLayer, TextLayer } from '@deck.gl/layers' -import type { StyleSpecification } from 'maplibre-gl' -import type { PickingInfo } from '@deck.gl/core' -import 'maplibre-gl/dist/maplibre-gl.css' +import { useState, useEffect, useCallback, useRef } from 'react' +import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi' +import type { DroneStreamItem } from '../services/aerialApi' +import { CCTVPlayer } from './CCTVPlayer' +import type { CCTVPlayerHandle } from './CCTVPlayer' -// ── 지도 스타일 ───────────────────────────────────────── -const BASE_STYLE: StyleSpecification = { - version: 8, - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - ], - tileSize: 256, - attribution: '© OSM © CARTO', - }, - }, - layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }], -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function DeckGLOverlay({ layers }: { layers: any[] }) { - const overlay = useControl(() => new MapboxOverlay({ interleaved: true })) - overlay.setProps({ layers }) - return null -} - -// ── Mock 데이터 ───────────────────────────────────────── -interface DroneInfo { - id: string - name: string - status: 'active' | 'returning' | 'standby' | 'charging' - battery: number - altitude: number - speed: number - sensor: string - color: string - lon: number - lat: number -} - -const drones: DroneInfo[] = [ - { id: 'D-01', name: 'DJI M300 #1', status: 'active', battery: 78, altitude: 150, speed: 12, sensor: '광학 4K', color: '#3b82f6', lon: 128.68, lat: 34.72 }, - { id: 'D-02', name: 'DJI M300 #2', status: 'active', battery: 65, altitude: 200, speed: 8, sensor: 'IR 열화상', color: '#ef4444', lon: 128.74, lat: 34.68 }, - { id: 'D-03', name: 'Mavic 3E', status: 'active', battery: 82, altitude: 120, speed: 15, sensor: '광학 4K', color: '#a855f7', lon: 128.88, lat: 34.60 }, - { id: 'D-04', name: 'DJI M30T', status: 'active', battery: 45, altitude: 180, speed: 10, sensor: '다중센서', color: '#22c55e', lon: 128.62, lat: 34.56 }, - { id: 'D-05', name: 'DJI M300 #3', status: 'returning', battery: 12, altitude: 80, speed: 18, sensor: '광학 4K', color: '#f97316', lon: 128.80, lat: 34.75 }, - { id: 'D-06', name: 'Mavic 3E #2', status: 'charging', battery: 35, altitude: 0, speed: 0, sensor: '광학 4K', color: '#6b7280', lon: 128.70, lat: 34.65 }, -] - -interface VesselInfo { - name: string - lon: number - lat: number - aisOff: boolean -} - -const vessels: VesselInfo[] = [ - { name: '영풍호', lon: 128.72, lat: 34.74, aisOff: false }, - { name: '불명-A', lon: 128.82, lat: 34.65, aisOff: true }, - { name: '금성호', lon: 128.60, lat: 34.62, aisOff: false }, - { name: '불명-B', lon: 128.92, lat: 34.70, aisOff: true }, - { name: '태양호', lon: 128.66, lat: 34.58, aisOff: false }, -] - -interface ZoneInfo { - id: string - lon: number - lat: number - radius: number - color: [number, number, number] -} - -const searchZones: ZoneInfo[] = [ - { id: 'A', lon: 128.70, lat: 34.72, radius: 3000, color: [6, 182, 212] }, - { id: 'B', lon: 128.88, lat: 34.60, radius: 2500, color: [249, 115, 22] }, - { id: 'C', lon: 128.62, lat: 34.56, radius: 2000, color: [234, 179, 8] }, -] - -const oilSpill = { lon: 128.85, lat: 34.58 } -const hnsPoint = { lon: 128.58, lat: 34.52 } - -interface AlertItem { - time: string - type: 'warning' | 'info' | 'danger' - message: string -} - -const alerts: AlertItem[] = [ - { time: '15:42', type: 'danger', message: 'D-05 배터리 부족 — 자동 복귀' }, - { time: '15:38', type: 'warning', message: '오염원 신규 탐지 (34.82°N)' }, - { time: '15:35', type: 'info', message: 'D-01~D-03 다시점 융합 완료' }, - { time: '15:30', type: 'warning', message: 'AIS OFF 선박 2척 추가 탐지' }, - { time: '15:25', type: 'info', message: 'D-04 센서 데이터 수집 시작' }, - { time: '15:20', type: 'danger', message: '유류오염 확산 속도 증가 감지' }, - { time: '15:15', type: 'info', message: '3D 재구성 시작 (불명선박-B)' }, -] - -// ── 유틸 ──────────────────────────────────────────────── -function hexToRgba(hex: string): [number, number, number, number] { - const r = parseInt(hex.slice(1, 3), 16) - const g = parseInt(hex.slice(3, 5), 16) - const b = parseInt(hex.slice(5, 7), 16) - return [r, g, b, 255] -} - -// ── 컴포넌트 ──────────────────────────────────────────── export function RealtimeDrone() { - const [reconProgress, setReconProgress] = useState(0) - const [reconDone, setReconDone] = useState(false) - const [selectedDrone, setSelectedDrone] = useState(null) - const [animFrame, setAnimFrame] = useState(0) + const [streams, setStreams] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedStream, setSelectedStream] = useState(null) + const [gridMode, setGridMode] = useState(1) + const [activeCells, setActiveCells] = useState([]) + const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]) - // 3D 재구성 진행률 - useEffect(() => { - if (reconDone) return - const timer = setInterval(() => { - setReconProgress(prev => { - if (prev >= 100) { - clearInterval(timer) - setReconDone(true) - return 100 - } - return prev + 2 - }) - }, 300) - return () => clearInterval(timer) - }, [reconDone]) - - // 애니메이션 루프 (~20fps) - useEffect(() => { - let frame = 0 - let raf: number - const tick = () => { - frame++ - if (frame % 3 === 0) setAnimFrame(f => f + 1) - raf = requestAnimationFrame(tick) + const loadStreams = useCallback(async () => { + try { + const items = await fetchDroneStreams() + setStreams(items) + // Update selected stream and active cells with latest status + setSelectedStream(prev => prev ? items.find(s => s.id === prev.id) ?? prev : prev) + setActiveCells(prev => prev.map(cell => items.find(s => s.id === cell.id) ?? cell)) + } catch { + // Fallback: show configured streams as idle + setStreams([ + { id: 'busan-1501', name: '1501함 드론', shipName: '부산서 1501함', droneModel: 'DJI M300 RTK', ip: '10.26.7.213', rtspUrl: 'rtsp://10.26.7.213:554/stream0', region: '부산', status: 'idle', hlsUrl: null, error: null }, + { id: 'incheon-3008', name: '3008함 드론', shipName: '인천서 3008함', droneModel: 'DJI M30T', ip: '10.26.5.21', rtspUrl: 'rtsp://10.26.5.21:554/stream0', region: '인천', status: 'idle', hlsUrl: null, error: null }, + { id: 'mokpo-3015', name: '3015함 드론', shipName: '목포서 3015함', droneModel: 'DJI Mavic 3E', ip: '10.26.7.85', rtspUrl: 'rtsp://10.26.7.85:554/stream0', region: '목포', status: 'idle', hlsUrl: null, error: null }, + ]) + } finally { + setLoading(false) } - raf = requestAnimationFrame(tick) - return () => cancelAnimationFrame(raf) }, []) - const activeDrones = useMemo(() => drones.filter(d => d.status !== 'charging'), []) + useEffect(() => { + loadStreams() + }, [loadStreams]) - // ── deck.gl 레이어 ────────────────────────────────── - const deckLayers = useMemo(() => { - const t = animFrame * 0.05 + // Poll status every 3 seconds when any stream is starting + useEffect(() => { + const hasStarting = streams.some(s => s.status === 'starting') + if (!hasStarting) return + const timer = setInterval(loadStreams, 3000) + return () => clearInterval(timer) + }, [streams, loadStreams]) - // 탐색 구역 (반투명 원 + 테두리) - const zoneFillLayer = new ScatterplotLayer({ - id: 'search-zones-fill', - data: searchZones, - getPosition: d => [d.lon, d.lat], - getRadius: d => d.radius + Math.sin(t + searchZones.indexOf(d)) * 100, - getFillColor: d => [...d.color, 15], - getLineColor: d => [...d.color, 80], - getLineWidth: 2, - filled: true, - stroked: true, - radiusUnits: 'meters', - radiusMinPixels: 30, - lineWidthMinPixels: 1.5, - }) + const handleStartStream = async (id: string) => { + try { + await startDroneStreamApi(id) + // Immediately update to 'starting' state + setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'starting' as const, error: null } : s)) + // Poll for status update + setTimeout(loadStreams, 2000) + } catch { + setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s)) + } + } - // 구역 라벨 - const zoneLabels = new TextLayer({ - id: 'zone-labels', - data: searchZones, - getPosition: d => [d.lon, d.lat + 0.025], - getText: d => `${d.id}구역`, - getColor: d => [...d.color, 180], - getSize: 12, - fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', - fontWeight: 'bold', - characterSet: 'auto', - outlineWidth: 2, - outlineColor: [15, 21, 36, 180], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - }) + const handleStopStream = async (id: string) => { + try { + await stopDroneStreamApi(id) + setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s)) + setActiveCells(prev => prev.filter(c => c.id !== id)) + } catch { + // ignore + } + } - // 유류 확산 (동심원 3개) - const oilRings = [0, 1, 2].map(ring => - new ScatterplotLayer({ - id: `oil-spill-${ring}`, - data: [oilSpill], - getPosition: () => [oilSpill.lon, oilSpill.lat], - getRadius: 800 + ring * 500 + Math.sin(t * 0.5 + ring) * 80, - getFillColor: [249, 115, 22, Math.max(4, 20 - ring * 6)], - getLineColor: [249, 115, 22, ring === 0 ? 120 : 40], - getLineWidth: ring === 0 ? 2 : 1, - filled: true, - stroked: true, - radiusUnits: 'meters', - radiusMinPixels: 15, - lineWidthMinPixels: ring === 0 ? 1.5 : 0.8, - }), - ) - - // 유류 확산 라벨 - const oilLabel = new TextLayer({ - id: 'oil-label', - data: [oilSpill], - getPosition: () => [oilSpill.lon, oilSpill.lat - 0.015], - getText: () => '유류확산', - getColor: [249, 115, 22, 200], - getSize: 11, - fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', - fontWeight: 'bold', - characterSet: 'auto', - outlineWidth: 2, - outlineColor: [15, 21, 36, 180], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - }) - - // HNS 의심 - const hnsLayer = new ScatterplotLayer({ - id: 'hns-point', - data: [hnsPoint], - getPosition: () => [hnsPoint.lon, hnsPoint.lat], - getRadius: 400 + Math.sin(t * 1.2) * 80, - getFillColor: [234, 179, 8, 50], - getLineColor: [234, 179, 8, 100], - getLineWidth: 1.5, - filled: true, - stroked: true, - radiusUnits: 'meters', - radiusMinPixels: 6, - lineWidthMinPixels: 1, - }) - const hnsCore = new ScatterplotLayer({ - id: 'hns-core', - data: [hnsPoint], - getPosition: () => [hnsPoint.lon, hnsPoint.lat], - getRadius: 150, - getFillColor: [234, 179, 8, 200], - filled: true, - radiusUnits: 'meters', - radiusMinPixels: 4, - }) - const hnsLabel = new TextLayer({ - id: 'hns-label', - data: [hnsPoint], - getPosition: () => [hnsPoint.lon, hnsPoint.lat - 0.008], - getText: () => 'HNS 의심', - getColor: [234, 179, 8, 180], - getSize: 10, - fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', - fontWeight: 'bold', - characterSet: 'auto', - outlineWidth: 2, - outlineColor: [15, 21, 36, 180], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - }) - - // 선박 — AIS OFF는 red 경고 원 - const vesselAlertLayer = new ScatterplotLayer({ - id: 'vessel-alert', - data: vessels.filter(v => v.aisOff), - getPosition: d => [d.lon, d.lat], - getRadius: 600 + Math.sin(t * 1.5) * 150, - getFillColor: [239, 68, 68, 20], - getLineColor: [239, 68, 68, 60], - getLineWidth: 1, - filled: true, - stroked: true, - radiusUnits: 'meters', - radiusMinPixels: 10, - lineWidthMinPixels: 0.8, - }) - const vesselLayer = new ScatterplotLayer({ - id: 'vessels', - data: vessels, - getPosition: d => [d.lon, d.lat], - getRadius: 200, - getFillColor: d => d.aisOff ? [239, 68, 68, 255] : [96, 165, 250, 255], - getLineColor: d => d.aisOff ? [239, 68, 68, 120] : [96, 165, 250, 80], - getLineWidth: 1.5, - filled: true, - stroked: true, - radiusUnits: 'meters', - radiusMinPixels: 5, - lineWidthMinPixels: 1, - }) - const vesselLabels = new TextLayer({ - id: 'vessel-labels', - data: vessels, - getPosition: d => [d.lon, d.lat + 0.005], - getText: d => d.name, - getColor: [255, 255, 255, 190], - getSize: 11, - fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', - fontWeight: 'bold', - characterSet: 'auto', - outlineWidth: 2, - outlineColor: [15, 21, 36, 180], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - }) - - // 드론 간 메시 링크 - const droneLinks: { path: [number, number][] }[] = [] - for (let i = 0; i < activeDrones.length; i++) { - for (let j = i + 1; j < activeDrones.length; j++) { - const a = activeDrones[i] - const b = activeDrones[j] - droneLinks.push({ - path: [ - [a.lon + Math.sin(t + i * 2) * 0.002, a.lat + Math.cos(t * 0.8 + i) * 0.001], - [b.lon + Math.sin(t + j * 2) * 0.002, b.lat + Math.cos(t * 0.8 + j) * 0.001], - ], + const handleSelectStream = (stream: DroneStreamItem) => { + setSelectedStream(stream) + if (stream.status === 'streaming' && stream.hlsUrl) { + if (gridMode === 1) { + setActiveCells([stream]) + } else { + setActiveCells(prev => { + if (prev.length < gridMode && !prev.find(c => c.id === stream.id)) return [...prev, stream] + return prev }) } } - const linkLayer = new PathLayer({ - id: 'drone-links', - data: droneLinks, - getPath: d => d.path, - getColor: [77, 208, 225, 35], - getWidth: 1, - getDashArray: [6, 8], - dashJustified: true, - widthMinPixels: 0.7, - }) - - // 드론 글로우 (뒤쪽 큰 원) - const droneGlowLayer = new ScatterplotLayer({ - id: 'drone-glow', - data: activeDrones, - getPosition: d => { - const i = activeDrones.indexOf(d) - return [ - d.lon + Math.sin(t + i * 2) * 0.002, - d.lat + Math.cos(t * 0.8 + i) * 0.001, - ] - }, - getRadius: d => selectedDrone === d.id ? 500 : 350, - getFillColor: d => { - const [r, g, b] = hexToRgba(d.color) - return [r, g, b, selectedDrone === d.id ? 40 : 20] - }, - filled: true, - radiusUnits: 'meters', - radiusMinPixels: selectedDrone ? 12 : 8, - updateTriggers: { - getPosition: [animFrame], - getFillColor: [selectedDrone], - getRadius: [selectedDrone], - }, - }) - - // 드론 본체 - const droneLayer = new ScatterplotLayer({ - id: 'drones', - data: activeDrones, - getPosition: d => { - const i = activeDrones.indexOf(d) - return [ - d.lon + Math.sin(t + i * 2) * 0.002, - d.lat + Math.cos(t * 0.8 + i) * 0.001, - ] - }, - getRadius: d => selectedDrone === d.id ? 200 : 150, - getFillColor: d => hexToRgba(d.color), - getLineColor: [255, 255, 255, 200], - getLineWidth: d => selectedDrone === d.id ? 2 : 1, - filled: true, - stroked: true, - radiusUnits: 'meters', - radiusMinPixels: selectedDrone ? 6 : 4, - lineWidthMinPixels: 1, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) setSelectedDrone(info.object.id) - }, - updateTriggers: { - getPosition: [animFrame], - getRadius: [selectedDrone], - getLineWidth: [selectedDrone], - }, - }) - - // 드론 라벨 - const droneLabels = new TextLayer({ - id: 'drone-labels', - data: activeDrones, - getPosition: d => { - const i = activeDrones.indexOf(d) - return [ - d.lon + Math.sin(t + i * 2) * 0.002, - d.lat + Math.cos(t * 0.8 + i) * 0.001 + 0.006, - ] - }, - getText: d => d.id, - getColor: d => { - const [r, g, b] = hexToRgba(d.color) - return selectedDrone === d.id ? [255, 255, 255, 255] : [r, g, b, 230] - }, - getSize: d => selectedDrone === d.id ? 13 : 10, - fontFamily: 'Outfit, monospace', - fontWeight: 'bold', - characterSet: 'auto', - outlineWidth: 2, - outlineColor: [15, 21, 36, 200], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - updateTriggers: { - getPosition: [animFrame], - getColor: [selectedDrone], - getSize: [selectedDrone], - }, - }) - - return [ - zoneFillLayer, - zoneLabels, - ...oilRings, - oilLabel, - hnsLayer, - hnsCore, - hnsLabel, - vesselAlertLayer, - vesselLayer, - vesselLabels, - linkLayer, - droneGlowLayer, - droneLayer, - droneLabels, - ] - }, [animFrame, selectedDrone, activeDrones]) - - // ── UI 유틸 ─────────────────────────────────────────── - const statusLabel = (s: string) => { - if (s === 'active') return { text: '비행중', cls: 'text-status-green' } - if (s === 'returning') return { text: '복귀중', cls: 'text-status-orange' } - if (s === 'charging') return { text: '충전중', cls: 'text-text-3' } - return { text: '대기', cls: 'text-text-3' } } - const alertColor = (t: string) => - t === 'danger' ? 'border-l-status-red bg-[rgba(239,68,68,0.05)]' - : t === 'warning' ? 'border-l-status-orange bg-[rgba(249,115,22,0.05)]' - : 'border-l-primary-blue bg-[rgba(59,130,246,0.05)]' + const statusInfo = (status: string) => { + switch (status) { + case 'streaming': return { label: '송출중', color: 'var(--green)', bg: 'rgba(34,197,94,.12)' } + case 'starting': return { label: '연결중', color: 'var(--cyan)', bg: 'rgba(6,182,212,.12)' } + case 'error': return { label: '오류', color: 'var(--red)', bg: 'rgba(239,68,68,.12)' } + default: return { label: '대기', color: 'var(--t3)', bg: 'rgba(255,255,255,.06)' } + } + } + + const gridCols = gridMode === 1 ? 1 : 2 + const totalCells = gridMode return (
- {/* 지도 영역 */} -
- - - - - {/* 오버레이 통계 */} -
- {[ - { label: '탐지 객체', value: '847', unit: '건', color: 'text-primary-blue' }, - { label: '식별 선박', value: '312', unit: '척', color: 'text-primary-cyan' }, - { label: 'AIS OFF', value: '14', unit: '척', color: 'text-status-red' }, - { label: '오염 탐지', value: '3', unit: '건', color: 'text-status-orange' }, - ].map((s, i) => ( -
-
{s.label}
-
- {s.value} - {s.unit} -
+ {/* 좌측: 드론 스트림 목록 */} +
+ {/* 헤더 */} +
+
+
+ s.status === 'streaming') ? 'var(--green)' : 'var(--t3)' }} /> + 실시간 드론 영상
- ))} + +
+
ViewLink RTSP 스트림 · 내부망 전용
- {/* 3D 재구성 진행률 */} -
-
- 🧊 3D 재구성 - {reconProgress}% -
-
-
-
- {!reconDone ? ( -
D-01~D-03 다각도 영상 융합중...
- ) : ( -
✅ 완료 — 클릭하여 정밀분석
- )} -
- - {/* 실시간 영상 패널 */} - {selectedDrone && (() => { - const drone = drones.find(d => d.id === selectedDrone) - if (!drone) return null - return ( -
-
-
-
- {drone.id} 실시간 영상 -
- -
-
-
-
-
LIVE FEED
-
-
- {drone.id} - {drone.sensor} -
{drone.lat.toFixed(2)}°N, {drone.lon.toFixed(2)}°E
-
-
-
REC -
-
- ALT {drone.altitude}m · SPD {drone.speed}m/s · HDG 045° -
-
-
-
비행 정보
- {[ - ['드론 ID', drone.id], - ['기체', drone.name], - ['배터리', `${drone.battery}%`], - ['고도', `${drone.altitude}m`], - ['속도', `${drone.speed}m/s`], - ['센서', drone.sensor], - ['상태', statusLabel(drone.status).text], - ].map(([k, v], i) => ( -
- {k} - {v} + {/* 드론 스트림 카드 */} +
+ {loading ? ( +
불러오는 중...
+ ) : streams.map(stream => { + const si = statusInfo(stream.status) + const isSelected = selectedStream?.id === stream.id + return ( +
handleSelectStream(stream)} + className="px-3.5 py-3 border-b cursor-pointer transition-colors" + style={{ + borderColor: 'rgba(255,255,255,.04)', + background: isSelected ? 'rgba(6,182,212,.08)' : 'transparent', + }} + > +
+
+
🚁
+
+
{stream.shipName} ({stream.droneModel})
+
{stream.ip}
- ))} +
+ {si.label} +
+ +
+ {stream.region} + RTSP :554 +
+ + {stream.error && ( +
+ {stream.error} +
+ )} + + {/* 시작/중지 버튼 */} +
+ {stream.status === 'idle' || stream.status === 'error' ? ( + + ) : ( + + )}
-
- ) - })()} + ) + })} +
+ + {/* 하단 안내 */} +
+
+ RTSP 스트림은 해양경찰 내부망에서만 접속 가능합니다. + ViewLink 프로그램과 연동됩니다. +
+
- {/* 우측 사이드바 */} -
- {/* 군집 드론 현황 */} -
-
군집 드론 현황 · {activeDrones.length}/{drones.length} 운용
-
- {drones.map(d => { - const st = statusLabel(d.status) - return ( -
d.status !== 'charging' && setSelectedDrone(d.id)} - className={`flex items-center gap-2 px-2 py-1.5 rounded-sm cursor-pointer transition-colors ${ - selectedDrone === d.id ? 'bg-[rgba(6,182,212,0.08)] border border-primary-cyan/20' : 'hover:bg-white/[0.02] border border-transparent' - }`} - > -
-
-
{d.id}
-
{d.name}
+ {/* 중앙: 영상 뷰어 */} +
+ {/* 툴바 */} +
+
+
+ {selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'} +
+ {selectedStream?.status === 'streaming' && ( +
+ LIVE +
+ )} + {selectedStream?.status === 'starting' && ( +
+ 연결중 +
+ )} +
+
+ {/* 분할 모드 */} +
+ {[ + { mode: 1, icon: '▣', label: '1화면' }, + { mode: 4, icon: '⊞', label: '4분할' }, + ].map(g => ( + + ))} +
+ +
+
+ + {/* 영상 그리드 */} +
+ {Array.from({ length: totalCells }).map((_, i) => { + const stream = activeCells[i] + return ( +
+ {stream && stream.status === 'streaming' && stream.hlsUrl ? ( + { playerRefs.current[i] = el }} + cameraNm={stream.shipName} + streamUrl={stream.hlsUrl} + sttsCd="LIVE" + coordDc={`${stream.ip} · RTSP`} + sourceNm="ViewLink" + cellIndex={i} + /> + ) : stream && stream.status === 'starting' ? ( +
+
🚁
+
RTSP 스트림 연결 중...
+
{stream.ip}:554
-
-
{st.text}
-
{d.battery}%
+ ) : stream && stream.status === 'error' ? ( +
+
⚠️
+
연결 실패
+
{stream.error}
+
+ ) : ( +
+ {streams.length > 0 ? '스트림을 시작하고 선택하세요' : '드론 스트림을 선택하세요'} +
+ )} +
+ ) + })} +
+ + {/* 하단 정보 바 */} +
+
선택: {selectedStream?.shipName ?? '–'}
+
IP: {selectedStream?.ip ?? '–'}
+
지역: {selectedStream?.region ?? '–'}
+
RTSP → HLS · ViewLink 연동
+
+
+ + {/* 우측: 정보 패널 */} +
+ {/* 헤더 */} +
+ 📋 스트림 정보 +
+ +
+ {selectedStream ? ( +
+ {[ + ['함정명', selectedStream.shipName], + ['드론명', selectedStream.name], + ['기체모델', selectedStream.droneModel], + ['IP 주소', selectedStream.ip], + ['RTSP 포트', '554'], + ['지역', selectedStream.region], + ['프로토콜', 'RTSP → HLS'], + ['상태', statusInfo(selectedStream.status).label], + ].map(([k, v], i) => ( +
+ {k} + {v}
- ) - })} -
-
+ ))} + {selectedStream.hlsUrl && ( +
+
HLS URL
+
{selectedStream.hlsUrl}
+
+ )} +
+ ) : ( +
드론 스트림을 선택하세요
+ )} - {/* 다각화 분석 */} -
-
다각화 분석
-
- {[ - { icon: '🎯', label: '다시점 융합', value: '28건', sub: '360° 식별' }, - { icon: '🧊', label: '3D 재구성', value: '12건', sub: '선박+오염원' }, - { icon: '📡', label: '다센서 융합', value: '45건', sub: '광학+IR+SAR' }, - { icon: '🛢️', label: '오염원 3D', value: '3건', sub: '유류+HNS' }, - ].map((a, i) => ( -
-
{a.icon}
-
{a.label}
-
{a.value}
-
{a.sub}
+ {/* 연동 시스템 */} +
+
🔗 연동 시스템
+
+
+ ViewLink 3.5 + ● RTSP
- ))} +
+ FFmpeg 변환 + RTSP→HLS +
+
-
- {/* 실시간 경보 */} -
-
실시간 경보
-
- {alerts.map((a, i) => ( -
- {a.time} - {a.message} -
- ))} + {/* 전체 상태 요약 */} +
+
📊 스트림 현황
+
+ {[ + { label: '전체', value: streams.length, color: 'text-text-1' }, + { label: '송출중', value: streams.filter(s => s.status === 'streaming').length, color: 'text-status-green' }, + { label: '연결중', value: streams.filter(s => s.status === 'starting').length, color: 'text-primary-cyan' }, + { label: '오류', value: streams.filter(s => s.status === 'error').length, color: 'text-status-red' }, + ].map((item, i) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
diff --git a/frontend/src/tabs/aerial/services/aerialApi.ts b/frontend/src/tabs/aerial/services/aerialApi.ts index 90b8ed6..51eed32 100644 --- a/frontend/src/tabs/aerial/services/aerialApi.ts +++ b/frontend/src/tabs/aerial/services/aerialApi.ts @@ -103,3 +103,32 @@ export async function createSatRequest( const response = await api.post<{ satReqSn: number }>('/aerial/sat-requests', input); return response.data; } + +// === DRONE STREAM === +export interface DroneStreamItem { + id: string; + name: string; + shipName: string; + droneModel: string; + ip: string; + rtspUrl: string; + region: string; + status: 'idle' | 'starting' | 'streaming' | 'error'; + hlsUrl: string | null; + error: string | null; +} + +export async function fetchDroneStreams(): Promise { + const response = await api.get('/aerial/drone/streams'); + return response.data; +} + +export async function startDroneStreamApi(id: string): Promise<{ success: boolean; hlsUrl?: string; error?: string }> { + const response = await api.post<{ success: boolean; hlsUrl?: string; error?: string }>(`/aerial/drone/streams/${id}/start`); + return response.data; +} + +export async function stopDroneStreamApi(id: string): Promise<{ success: boolean }> { + const response = await api.post<{ success: boolean }>(`/aerial/drone/streams/${id}/stop`); + return response.data; +}