diff --git a/backend/src/aerial/aerialRouter.ts b/backend/src/aerial/aerialRouter.ts index 414955f..113ad87 100644 --- a/backend/src/aerial/aerialRouter.ts +++ b/backend/src/aerial/aerialRouter.ts @@ -87,6 +87,92 @@ router.get('/cctv', requireAuth, requirePermission('aerial', 'READ'), async (req } }); +// ============================================================ +// KBS 재난안전포탈 CCTV HLS 리졸버 +// ============================================================ + +/** KBS cctvId → 실제 HLS m3u8 URL 캐시 (5분 TTL) */ +const kbsHlsCache = new Map(); +const KBS_CACHE_TTL = 5 * 60 * 1000; + +// GET /api/aerial/cctv/kbs-hls/:cctvId/stream.m3u8 — KBS CCTV를 HLS로 리졸브 + 프록시 +router.get('/cctv/kbs-hls/:cctvId/stream.m3u8', async (req, res) => { + try { + const cctvId = req.params.cctvId as string; + if (!/^\d+$/.test(cctvId)) { + res.status(400).json({ error: '유효하지 않은 cctvId' }); + return; + } + + let m3u8Url: string | null = null; + + // 캐시 확인 + const cached = kbsHlsCache.get(cctvId); + if (cached && Date.now() - cached.ts < KBS_CACHE_TTL) { + m3u8Url = cached.url; + } else { + // 1단계: KBS 팝업 API에서 loomex API URL 추출 + const popupRes = await fetch( + `https://d.kbs.co.kr/special/cctv/cctvPopup?type=LIVE&cctvId=${cctvId}`, + { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' } }, + ); + if (!popupRes.ok) { + res.status(502).json({ error: 'KBS 팝업 API 응답 실패' }); + return; + } + const popupHtml = await popupRes.text(); + const urlMatch = popupHtml.match(/id="url"\s+value="([^"]+)"/); + if (!urlMatch) { + res.status(502).json({ error: 'KBS 스트림 URL을 찾을 수 없습니다' }); + return; + } + + // 2단계: loomex API에서 실제 m3u8 URL 획득 + const loomexRes = await fetch(urlMatch[1], { + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' }, + }); + if (!loomexRes.ok) { + res.status(502).json({ error: 'KBS 스트림 서버 응답 실패' }); + return; + } + m3u8Url = (await loomexRes.text()).trim(); + kbsHlsCache.set(cctvId, { url: m3u8Url, ts: Date.now() }); + } + + // 3단계: m3u8 매니페스트를 프록시하여 세그먼트 URL 재작성 + const upstream = await fetch(m3u8Url, { + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' }, + }); + if (!upstream.ok) { + // 캐시 무효화 후 재시도 유도 + kbsHlsCache.delete(cctvId); + res.status(502).json({ error: 'HLS 매니페스트 가져오기 실패' }); + return; + } + + const text = await upstream.text(); + const baseUrl = m3u8Url.substring(0, m3u8Url.lastIndexOf('/') + 1); + const proxyBase = '/api/aerial/cctv/stream-proxy?url='; + + const rewritten = text.replace(/^(?!#)(\S+)/gm, (line) => { + if (line.startsWith('http://') || line.startsWith('https://')) { + return `${proxyBase}${encodeURIComponent(line)}`; + } + return `${proxyBase}${encodeURIComponent(baseUrl + line)}`; + }); + + res.set({ + 'Content-Type': 'application/vnd.apple.mpegurl', + 'Cache-Control': 'no-cache', + 'Access-Control-Allow-Origin': '*', + }); + res.send(rewritten); + } catch (err) { + console.error('[aerial] KBS HLS 리졸버 오류:', err); + res.status(502).json({ error: 'KBS HLS 스트림 리졸브 실패' }); + } +}); + // ============================================================ // CCTV HLS 스트림 프록시 (CORS 우회) // ============================================================ @@ -95,6 +181,7 @@ router.get('/cctv', requireAuth, requirePermission('aerial', 'READ'), async (req const ALLOWED_STREAM_HOSTS = [ 'www.khoa.go.kr', 'kbsapi.loomex.net', + 'kbscctv-cache.loomex.net', ]; // GET /api/aerial/cctv/stream-proxy — HLS 스트림 프록시 (호스트 화이트리스트로 보안) diff --git a/database/migration/021_kbs_cctv_stream_urls.sql b/database/migration/021_kbs_cctv_stream_urls.sql new file mode 100644 index 0000000..5b9b12f --- /dev/null +++ b/database/migration/021_kbs_cctv_stream_urls.sql @@ -0,0 +1,39 @@ +-- KBS 재난안전포탈 CCTV 스트림 URL 추가 마이그레이션 +-- 기존 KBS 카메라 6건 streamUrl + 좌표 업데이트 + 신규 15건 INSERT +-- URL: 백엔드 KBS HLS 리졸버 경유 (/api/aerial/cctv/kbs-hls/:cctvId/stream.m3u8) +-- 좌표: KBS API (d.kbs.co.kr/special/cctv/list) 정확 좌표 반영 + +SET search_path TO wing, public; + +-- 기존 KBS 카메라 streamUrl + 좌표 업데이트 (KBS API 정확 좌표) +UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9981/stream.m3u8', LON = 126.5986, LAT = 37.4541, GEOM = ST_SetSRID(ST_MakePoint(126.5986, 37.4541), 4326), CAMERA_NM = '인천 연안부두' WHERE CCTV_SN = 100; +UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9994/stream.m3u8', LON = 127.7557, LAT = 34.7410, GEOM = ST_SetSRID(ST_MakePoint(127.7557, 34.7410), 4326), CAMERA_NM = '여수 오동도 앞' WHERE CCTV_SN = 97; +UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9984/stream.m3u8', LON = 126.7489, LAT = 34.3209, GEOM = ST_SetSRID(ST_MakePoint(126.7489, 34.3209), 4326) WHERE CCTV_SN = 108; +UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9986/stream.m3u8', LON = 128.6001, LAT = 38.2134, GEOM = ST_SetSRID(ST_MakePoint(128.6001, 38.2134), 4326), CAMERA_NM = '속초 등대전망대' WHERE CCTV_SN = 113; +UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9957/stream.m3u8', LON = 131.8686, LAT = 37.2394, GEOM = ST_SetSRID(ST_MakePoint(131.8686, 37.2394), 4326) WHERE CCTV_SN = 115; +UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9982/stream.m3u8', LON = 126.2684, LAT = 33.1139, GEOM = ST_SetSRID(ST_MakePoint(126.2684, 33.1139), 4326) WHERE CCTV_SN = 116; + +-- 신규 KBS 재난안전포탈 CCTV 추가 (15건) — KBS API 정확 좌표 +INSERT INTO CCTV_CAMERA (CCTV_SN, CAMERA_NM, REGION_NM, LON, LAT, GEOM, LOC_DC, COORD_DC, STTS_CD, PTZ_YN, SOURCE_NM, STREAM_URL) VALUES +-- 서해 +(200, '연평도', '서해', 125.6945, 37.6620, ST_SetSRID(ST_MakePoint(125.6945, 37.6620), 4326), '인천 옹진군 연평면', '37.66°N 125.69°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9958/stream.m3u8'), +(201, '군산 비응항', '서해', 126.5265, 35.9353, ST_SetSRID(ST_MakePoint(126.5265, 35.9353), 4326), '전북 군산시 비응도동', '35.94°N 126.53°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9979/stream.m3u8'), +(202, '태안 신진항', '서해', 126.1365, 36.6779, ST_SetSRID(ST_MakePoint(126.1365, 36.6779), 4326), '충남 태안군 근흥면', '36.68°N 126.14°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9980/stream.m3u8'), +-- 남해 +(203, '창원 마산항', '남해', 128.5760, 35.1979, ST_SetSRID(ST_MakePoint(128.5760, 35.1979), 4326), '경남 창원시 마산합포구', '35.20°N 128.58°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9985/stream.m3u8'), +(204, '부산 민락항', '남해', 129.1312, 35.1538, ST_SetSRID(ST_MakePoint(129.1312, 35.1538), 4326), '부산 수영구 민락동', '35.15°N 129.13°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9991/stream.m3u8'), +(205, '목포 북항', '남해', 126.3652, 34.8042, ST_SetSRID(ST_MakePoint(126.3652, 34.8042), 4326), '전남 목포시 죽교동', '34.80°N 126.37°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9992/stream.m3u8'), +(206, '신안 가거도', '남해', 125.1293, 34.0529, ST_SetSRID(ST_MakePoint(125.1293, 34.0529), 4326), '전남 신안군 흑산면', '34.05°N 125.13°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9983/stream.m3u8'), +(207, '여수 거문도', '남해', 127.3074, 34.0232, ST_SetSRID(ST_MakePoint(127.3074, 34.0232), 4326), '전남 여수시 삼산면', '34.02°N 127.31°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9993/stream.m3u8'), +-- 동해 +(208, '강릉 용강동', '동해', 128.8912, 37.7521, ST_SetSRID(ST_MakePoint(128.8912, 37.7521), 4326), '강원 강릉시 용강동', '37.75°N 128.89°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9952/stream.m3u8'), +(209, '강릉 주문진방파제', '동해', 128.8335, 37.8934, ST_SetSRID(ST_MakePoint(128.8335, 37.8934), 4326), '강원 강릉시 주문진읍', '37.89°N 128.83°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9995/stream.m3u8'), +(210, '대관령', '동해', 128.7553, 37.6980, ST_SetSRID(ST_MakePoint(128.7553, 37.6980), 4326), '강원 평창군 대관령면', '37.70°N 128.76°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9989/stream.m3u8'), +(211, '울릉 저동항', '동해', 130.9122, 37.4913, ST_SetSRID(ST_MakePoint(130.9122, 37.4913), 4326), '경북 울릉군 울릉읍', '37.49°N 130.91°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9987/stream.m3u8'), +(212, '포항 두호동 해안로', '동해', 129.3896, 36.0627, ST_SetSRID(ST_MakePoint(129.3896, 36.0627), 4326), '경북 포항시 북구 두호동', '36.06°N 129.39°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9988/stream.m3u8'), +(213, '울산 달동', '동해', 129.3265, 35.5442, ST_SetSRID(ST_MakePoint(129.3265, 35.5442), 4326), '울산 남구 달동', '35.54°N 129.33°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9955/stream.m3u8'), +-- 제주 +(214, '제주 도남동', '제주', 126.5195, 33.4891, ST_SetSRID(ST_MakePoint(126.5195, 33.4891), 4326), '제주시 도남동', '33.49°N 126.52°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9954/stream.m3u8'); + +-- 시퀀스 리셋 +SELECT setval('cctv_camera_cctv_sn_seq', (SELECT MAX(cctv_sn) FROM cctv_camera)); diff --git a/frontend/src/tabs/aerial/components/CctvView.tsx b/frontend/src/tabs/aerial/components/CctvView.tsx index 538d535..a54da59 100644 --- a/frontend/src/tabs/aerial/components/CctvView.tsx +++ b/frontend/src/tabs/aerial/components/CctvView.tsx @@ -1,4 +1,7 @@ import { useState, useCallback, useEffect, useRef } from 'react' +import { Map, Marker, Popup } from '@vis.gl/react-maplibre' +import type { StyleSpecification } from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' import { fetchCctvCameras } from '../services/aerialApi' import type { CctvCameraItem } from '../services/aerialApi' import { CCTVPlayer } from './CCTVPlayer' @@ -12,6 +15,28 @@ function khoaHlsUrl(siteName: string): string { return `${KHOA_HLS}/${siteName}/s.m3u8`; } +/** KBS 재난안전포탈 CCTV — 백엔드 HLS 리졸버 경유 */ +function kbsCctvUrl(cctvId: number): string { + return `/api/aerial/cctv/kbs-hls/${cctvId}/stream.m3u8`; +} + +/** 지도 스타일 (CartoDB Dark Matter) */ +const MAP_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, + }, + }, + layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }], +} + const cctvFavorites = [ { name: '여수항 해무관측', reason: '유출 사고 인접' }, { name: '부산항 조위관측소', reason: '주요 방제 거점' }, @@ -25,7 +50,10 @@ const FALLBACK_CAMERAS: CctvCameraItem[] = [ { cctvSn: 30, cameraNm: '인천항 해무관측', regionNm: '서해', lon: 126.6161, lat: 37.3797, locDc: '인천광역시 중구 항동', coordDc: 'N 37°22\'47" E 126°36\'58"', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Incheon') }, { cctvSn: 31, cameraNm: '대산항 해무관측', regionNm: '서해', lon: 126.3526, lat: 37.0058, locDc: '충남 서산시 대산읍', coordDc: '37.01°N 126.35°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Daesan') }, { cctvSn: 32, cameraNm: '평택·당진항 해무관측', regionNm: '서해', lon: 126.3936, lat: 37.1131, locDc: '충남 당진시 송악읍', coordDc: 'N 37°06\'47" E 126°23\'37"', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_PTDJ') }, - { cctvSn: 100, cameraNm: '인천 연안부두', regionNm: '서해', lon: 126.6125, lat: 37.4625, locDc: '인천광역시 중구 연안부두', coordDc: '37.46°N 126.61°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null }, + { cctvSn: 100, cameraNm: '인천 연안부두', regionNm: '서해', lon: 126.5986, lat: 37.4541, locDc: '인천광역시 중구 연안부두', coordDc: '37.45°N 126.60°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9981) }, + { cctvSn: 200, cameraNm: '연평도', regionNm: '서해', lon: 125.6945, lat: 37.6620, locDc: '인천 옹진군 연평면', coordDc: '37.66°N 125.69°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9958) }, + { cctvSn: 201, cameraNm: '군산 비응항', regionNm: '서해', lon: 126.5265, lat: 35.9353, locDc: '전북 군산시 비응도동', coordDc: '35.94°N 126.53°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9979) }, + { cctvSn: 202, cameraNm: '태안 신진항', regionNm: '서해', lon: 126.1365, lat: 36.6779, locDc: '충남 태안군 근흥면', coordDc: '36.68°N 126.14°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9980) }, // 남해 { cctvSn: 35, cameraNm: '목포항 해무관측', regionNm: '남해', lon: 126.3780, lat: 34.7780, locDc: '전남 목포시 항동', coordDc: '34.78°N 126.38°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Mokpo') }, { cctvSn: 36, cameraNm: '진도항 조위관측소', regionNm: '남해', lon: 126.3085, lat: 34.4710, locDc: '전남 진도군 진도읍', coordDc: '34.47°N 126.31°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Jindo') }, @@ -34,17 +62,29 @@ const FALLBACK_CAMERAS: CctvCameraItem[] = [ { cctvSn: 39, cameraNm: '부산항 조위관측소', regionNm: '남해', lon: 129.0756, lat: 35.0969, locDc: '부산광역시 중구 중앙동', coordDc: '35.10°N 129.08°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Busan') }, { cctvSn: 40, cameraNm: '부산항 해무관측', regionNm: '남해', lon: 129.0780, lat: 35.0980, locDc: '부산광역시 중구', coordDc: '35.10°N 129.08°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Busan') }, { cctvSn: 41, cameraNm: '해운대 해무관측', regionNm: '남해', lon: 129.1718, lat: 35.1587, locDc: '부산광역시 해운대구', coordDc: '35.16°N 129.17°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Haeundae') }, - { cctvSn: 97, cameraNm: '오동도', regionNm: '남해', lon: 127.7836, lat: 34.7369, locDc: '전남 여수시 수정동', coordDc: '34.74°N 127.78°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null }, - { cctvSn: 108, cameraNm: '완도항', regionNm: '남해', lon: 126.7550, lat: 34.3114, locDc: '전남 완도군 완도읍', coordDc: '34.31°N 126.76°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null }, + { cctvSn: 97, cameraNm: '여수 오동도 앞', regionNm: '남해', lon: 127.7557, lat: 34.7410, locDc: '전남 여수시 수정동', coordDc: '34.74°N 127.76°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9994) }, + { cctvSn: 108, cameraNm: '완도항', regionNm: '남해', lon: 126.7489, lat: 34.3209, locDc: '전남 완도군 완도읍', coordDc: '34.32°N 126.75°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9984) }, + { cctvSn: 203, cameraNm: '창원 마산항', regionNm: '남해', lon: 128.5760, lat: 35.1979, locDc: '경남 창원시 마산합포구', coordDc: '35.20°N 128.58°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9985) }, + { cctvSn: 204, cameraNm: '부산 민락항', regionNm: '남해', lon: 129.1312, lat: 35.1538, locDc: '부산 수영구 민락동', coordDc: '35.15°N 129.13°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9991) }, + { cctvSn: 205, cameraNm: '목포 북항', regionNm: '남해', lon: 126.3652, lat: 34.8042, locDc: '전남 목포시 죽교동', coordDc: '34.80°N 126.37°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9992) }, + { cctvSn: 206, cameraNm: '신안 가거도', regionNm: '남해', lon: 125.1293, lat: 34.0529, locDc: '전남 신안군 흑산면', coordDc: '34.05°N 125.13°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9983) }, + { cctvSn: 207, cameraNm: '여수 거문도', regionNm: '남해', lon: 127.3074, lat: 34.0232, locDc: '전남 여수시 삼산면', coordDc: '34.02°N 127.31°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9993) }, // 동해 { cctvSn: 42, cameraNm: '울산항 해무관측', regionNm: '동해', lon: 129.3870, lat: 35.5000, locDc: '울산광역시 남구', coordDc: '35.50°N 129.39°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Ulsan') }, { cctvSn: 43, cameraNm: '포항항 해무관측', regionNm: '동해', lon: 129.3798, lat: 36.0323, locDc: '경북 포항시 북구', coordDc: '36.03°N 129.38°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Pohang') }, { cctvSn: 44, cameraNm: '묵호항 조위관측소', regionNm: '동해', lon: 129.1146, lat: 37.5500, locDc: '강원 동해시 묵호동', coordDc: '37.55°N 129.11°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Mukho') }, - { cctvSn: 113, cameraNm: '속초등대', regionNm: '동해', lon: 128.5964, lat: 38.2070, locDc: '강원 속초시 영랑동', coordDc: '38.21°N 128.60°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null }, - { cctvSn: 115, cameraNm: '독도', regionNm: '동해', lon: 131.8689, lat: 37.2394, locDc: '경북 울릉군 울릉읍 독도리', coordDc: '37.24°N 131.87°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null }, + { cctvSn: 113, cameraNm: '속초 등대전망대', regionNm: '동해', lon: 128.6001, lat: 38.2134, locDc: '강원 속초시 영랑동', coordDc: '38.21°N 128.60°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9986) }, + { cctvSn: 115, cameraNm: '독도', regionNm: '동해', lon: 131.8686, lat: 37.2394, locDc: '경북 울릉군 울릉읍 독도리', coordDc: '37.24°N 131.87°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9957) }, + { cctvSn: 208, cameraNm: '강릉 용강동', regionNm: '동해', lon: 128.8912, lat: 37.7521, locDc: '강원 강릉시 용강동', coordDc: '37.75°N 128.89°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9952) }, + { cctvSn: 209, cameraNm: '강릉 주문진방파제', regionNm: '동해', lon: 128.8335, lat: 37.8934, locDc: '강원 강릉시 주문진읍', coordDc: '37.89°N 128.83°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9995) }, + { cctvSn: 210, cameraNm: '대관령', regionNm: '동해', lon: 128.7553, lat: 37.6980, locDc: '강원 평창군 대관령면', coordDc: '37.70°N 128.76°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9989) }, + { cctvSn: 211, cameraNm: '울릉 저동항', regionNm: '동해', lon: 130.9122, lat: 37.4913, locDc: '경북 울릉군 울릉읍', coordDc: '37.49°N 130.91°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9987) }, + { cctvSn: 212, cameraNm: '포항 두호동 해안로', regionNm: '동해', lon: 129.3896, lat: 36.0627, locDc: '경북 포항시 북구 두호동', coordDc: '36.06°N 129.39°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9988) }, + { cctvSn: 213, cameraNm: '울산 달동', regionNm: '동해', lon: 129.3265, lat: 35.5442, locDc: '울산 남구 달동', coordDc: '35.54°N 129.33°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9955) }, // 제주 { cctvSn: 45, cameraNm: '모슬포항 조위관측소', regionNm: '제주', lon: 126.2519, lat: 33.2136, locDc: '제주 서귀포시 대정읍', coordDc: '33.21°N 126.25°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Moseulpo') }, - { cctvSn: 116, cameraNm: '마라도', regionNm: '제주', lon: 126.2669, lat: 33.1140, locDc: '제주 서귀포시 대정읍 마라리', coordDc: '33.11°N 126.27°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null }, + { cctvSn: 116, cameraNm: '마라도', regionNm: '제주', lon: 126.2684, lat: 33.1139, locDc: '제주 서귀포시 대정읍 마라리', coordDc: '33.11°N 126.27°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9982) }, + { cctvSn: 214, cameraNm: '제주 도남동', regionNm: '제주', lon: 126.5195, lat: 33.4891, locDc: '제주시 도남동', coordDc: '33.49°N 126.52°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9954) }, ] export function CctvView() { @@ -58,13 +98,24 @@ export function CctvView() { const [oilDetectionEnabled, setOilDetectionEnabled] = useState(false) const [vesselDetectionEnabled, setVesselDetectionEnabled] = useState(false) const [intrusionDetectionEnabled, setIntrusionDetectionEnabled] = useState(false) + const [mapPopup, setMapPopup] = useState(null) const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]) + /** 활성 셀이 비어 있으면 지도를 표시 */ + const showMap = activeCells.length === 0 + const loadData = useCallback(async () => { setLoading(true) try { const items = await fetchCctvCameras() - setCameras(items.length > 0 ? items : FALLBACK_CAMERAS) + if (items.length > 0) { + // DB 데이터에 폴백 전용 카메라(DB 미등록분) 병합 + const dbIds = new Set(items.map(i => i.cctvSn)) + const missing = FALLBACK_CAMERAS.filter(f => !dbIds.has(f.cctvSn)) + setCameras([...items, ...missing]) + } else { + setCameras(FALLBACK_CAMERAS) + } } catch { setCameras(FALLBACK_CAMERAS) } finally { @@ -273,36 +324,104 @@ export function CctvView() { - {/* 영상 그리드 */} -
- {Array.from({ length: totalCells }).map((_, i) => { - const cam = activeCells[i] - return ( -
- {cam ? ( - { playerRefs.current[i] = el }} - cameraNm={cam.cameraNm} - streamUrl={cam.streamUrl} - sttsCd={cam.sttsCd} - coordDc={cam.coordDc} - sourceNm={cam.sourceNm} - cellIndex={i} - oilDetectionEnabled={oilDetectionEnabled && gridMode !== 9} - vesselDetectionEnabled={vesselDetectionEnabled && gridMode !== 9} - intrusionDetectionEnabled={intrusionDetectionEnabled && gridMode !== 9} - /> - ) : ( -
카메라를 선택하세요
- )} -
- ) - })} -
+ {/* 영상 그리드 또는 CCTV 위치 지도 */} + {showMap ? ( +
+ + {filtered.filter(c => c.lon && c.lat).map(cam => ( + { e.originalEvent.stopPropagation(); setMapPopup(cam) }} + > +
+ 📹 +
+
+ ))} + {mapPopup && mapPopup.lon && mapPopup.lat && ( + setMapPopup(null)} + closeOnClick={false} + offset={14} + > +
+
{mapPopup.cameraNm}
+
{mapPopup.locDc ?? ''}
+
+ {mapPopup.sttsCd === 'LIVE' ? '● LIVE' : '● OFF'} + {mapPopup.sourceNm} +
+ +
+
+ )} +
+ {/* 지도 위 안내 배지 */} +
+ 📹 CCTV 마커를 클릭하여 영상을 선택하세요 ({filtered.length}개) +
+
+ ) : ( +
+ {Array.from({ length: totalCells }).map((_, i) => { + const cam = activeCells[i] + return ( +
+ {cam ? ( + { playerRefs.current[i] = el }} + cameraNm={cam.cameraNm} + streamUrl={cam.streamUrl} + sttsCd={cam.sttsCd} + coordDc={cam.coordDc} + sourceNm={cam.sourceNm} + cellIndex={i} + oilDetectionEnabled={oilDetectionEnabled && gridMode !== 9} + vesselDetectionEnabled={vesselDetectionEnabled && gridMode !== 9} + intrusionDetectionEnabled={intrusionDetectionEnabled && gridMode !== 9} + /> + ) : ( +
카메라를 선택하세요
+ )} +
+ ) + })} +
+ )} {/* 하단 정보 바 */}
@@ -320,26 +439,37 @@ export function CctvView() { 🗺 위치 지도 클릭하여 선택
- {/* 미니맵 (placeholder) */} -
-
지도 영역
- {/* 간략 지도 표현 */} -
- {cameras.filter(c => c.sttsCd === 'LIVE').slice(0, 6).map((c, i) => ( -
handleSelectCamera(c)} - /> + {/* 미니맵 */} +
+ + {cameras.filter(c => c.lon && c.lat).map(cam => ( + { e.originalEvent.stopPropagation(); handleSelectCamera(cam) }} + > +
+ ))} -
+
{/* 카메라 정보 */} diff --git a/frontend/src/tabs/aerial/utils/streamUtils.ts b/frontend/src/tabs/aerial/utils/streamUtils.ts index eeeb453..026368c 100644 --- a/frontend/src/tabs/aerial/utils/streamUtils.ts +++ b/frontend/src/tabs/aerial/utils/streamUtils.ts @@ -13,6 +13,11 @@ export function detectStreamType(url: string): StreamType { return 'iframe'; } + // KBS 재난안전포탈 CCTV 공유 페이지 (iframe 임베드) + if (lower.includes('d.kbs.co.kr')) { + return 'iframe'; + } + if (lower.includes('.m3u8') || lower.includes('/hls/')) { return 'hls'; } diff --git a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx index 854a010..ea9c2c3 100644 --- a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx +++ b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx @@ -88,8 +88,7 @@ const PredictionInputSection = ({ name="prdType" checked={inputMode === 'direct'} onChange={() => setInputMode('direct')} - className="m-0 w-[11px] h-[11px]" - className="accent-[var(--cyan)]" + className="m-0 w-[11px] h-[11px] accent-[var(--cyan)]" /> 직접 입력 @@ -99,8 +98,7 @@ const PredictionInputSection = ({ name="prdType" checked={inputMode === 'upload'} onChange={() => setInputMode('upload')} - className="m-0 w-[11px] h-[11px]" - className="accent-[var(--cyan)]" + className="m-0 w-[11px] h-[11px] accent-[var(--cyan)]" /> 이미지 업로드