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 // ============================================================ interface AerialMediaItem { aerialMediaSn: number; acdntSn: number | null; fileNm: string; orgnlNm: string | null; filePath: string | null; lon: number | null; lat: number | null; locDc: string | null; equipTpCd: string | null; equipNm: string | null; mediaTpCd: string | null; takngDtm: string | null; fileSz: string | null; resolution: string | null; regDtm: string; } interface ListMediaInput { equipType?: string; mediaType?: string; acdntSn?: number; search?: string; } function rowToMedia(r: Record): AerialMediaItem { return { aerialMediaSn: r.aerial_media_sn as number, acdntSn: r.acdnt_sn as number | null, fileNm: r.file_nm as string, orgnlNm: r.orgnl_nm as string | null, filePath: r.file_path as string | null, lon: r.lon ? parseFloat(r.lon as string) : null, lat: r.lat ? parseFloat(r.lat as string) : null, locDc: r.loc_dc as string | null, equipTpCd: r.equip_tp_cd as string | null, equipNm: r.equip_nm as string | null, mediaTpCd: r.media_tp_cd as string | null, takngDtm: r.takng_dtm as string | null, fileSz: r.file_sz as string | null, resolution: r.resolution as string | null, regDtm: r.reg_dtm as string, }; } export async function getMediaBySn(sn: number): Promise { const { rows } = await wingPool.query( `SELECT AERIAL_MEDIA_SN, ACDNT_SN, FILE_NM, ORGNL_NM, FILE_PATH, LON, LAT, LOC_DC, EQUIP_TP_CD, EQUIP_NM, MEDIA_TP_CD, TAKNG_DTM, FILE_SZ, RESOLUTION, REG_DTM FROM wing.AERIAL_MEDIA WHERE AERIAL_MEDIA_SN = $1 AND USE_YN = 'Y'`, [sn] ); return rows.length > 0 ? rowToMedia(rows[0]) : null; } export async function fetchOriginalImage(camTy: string, fileId: string): Promise { const res = await fetch(`${IMAGE_API_URL}/get-original-image/${camTy}/${fileId}`, { signal: AbortSignal.timeout(30_000), }); if (!res.ok) throw new Error(`이미지 서버 응답: ${res.status}`); const base64 = await res.json() as string; return Buffer.from(base64, 'base64'); } export async function listMedia(input: ListMediaInput): Promise { const conditions: string[] = ["USE_YN = 'Y'"]; const params: (string | number)[] = []; let idx = 1; if (input.equipType) { conditions.push(`EQUIP_TP_CD = $${idx++}`); params.push(input.equipType); } if (input.mediaType) { conditions.push(`MEDIA_TP_CD = $${idx++}`); params.push(input.mediaType); } if (input.acdntSn) { conditions.push(`ACDNT_SN = $${idx++}`); params.push(input.acdntSn); } if (input.search) { conditions.push(`(FILE_NM ILIKE '%' || $${idx} || '%' OR EQUIP_NM ILIKE '%' || $${idx} || '%')`); params.push(input.search); idx++; } const { rows } = await wingPool.query( `SELECT AERIAL_MEDIA_SN, ACDNT_SN, FILE_NM, ORGNL_NM, FILE_PATH, LON, LAT, LOC_DC, EQUIP_TP_CD, EQUIP_NM, MEDIA_TP_CD, TAKNG_DTM, FILE_SZ, RESOLUTION, REG_DTM FROM AERIAL_MEDIA WHERE ${conditions.join(' AND ')} ORDER BY TAKNG_DTM DESC NULLS LAST`, params ); return rows.map((r: Record) => rowToMedia(r)); } export async function createMedia(input: { acdntSn?: number; fileNm: string; orgnlNm?: string; filePath?: string; lon?: number; lat?: number; locDc?: string; equipTpCd?: string; equipNm?: string; mediaTpCd?: string; takngDtm?: string; fileSz?: string; resolution?: string; }): Promise<{ aerialMediaSn: number }> { const { rows } = await wingPool.query( `INSERT INTO AERIAL_MEDIA ( ACDNT_SN, FILE_NM, ORGNL_NM, FILE_PATH, LON, LAT, GEOM, LOC_DC, EQUIP_TP_CD, EQUIP_NM, MEDIA_TP_CD, TAKNG_DTM, FILE_SZ, RESOLUTION ) VALUES ( $1, $2, $3, $4, $5::float8, $6::float8, CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5, $6), 4326) END, $7, $8, $9, $10, $11, $12, $13 ) RETURNING AERIAL_MEDIA_SN`, [ input.acdntSn || null, input.fileNm, input.orgnlNm || null, input.filePath || null, input.lon || null, input.lat || null, input.locDc || null, input.equipTpCd || null, input.equipNm || null, input.mediaTpCd || null, input.takngDtm || null, input.fileSz || null, input.resolution || null, ] ); return { aerialMediaSn: rows[0].aerial_media_sn }; } // ============================================================ // CCTV_CAMERA // ============================================================ interface CctvCameraItem { cctvSn: number; cameraNm: string; regionNm: string | null; lon: number | null; lat: number | null; locDc: string | null; coordDc: string | null; sttsCd: string; ptzYn: string; sourceNm: string | null; streamUrl: string | null; regDtm: string; } interface ListCctvInput { region?: string; status?: string; } function rowToCctv(r: Record): CctvCameraItem { return { cctvSn: r.cctv_sn as number, cameraNm: r.camera_nm as string, regionNm: r.region_nm as string | null, lon: r.lon ? parseFloat(r.lon as string) : null, lat: r.lat ? parseFloat(r.lat as string) : null, locDc: r.loc_dc as string | null, coordDc: r.coord_dc as string | null, sttsCd: r.stts_cd as string, ptzYn: r.ptz_yn as string, sourceNm: r.source_nm as string | null, streamUrl: r.stream_url as string | null, regDtm: r.reg_dtm as string, }; } export async function listCctv(input: ListCctvInput): Promise { const conditions: string[] = ["USE_YN = 'Y'"]; const params: string[] = []; let idx = 1; if (input.region) { conditions.push(`REGION_NM = $${idx++}`); params.push(input.region); } if (input.status) { conditions.push(`STTS_CD = $${idx++}`); params.push(input.status); } const { rows } = await wingPool.query( `SELECT CCTV_SN, CAMERA_NM, REGION_NM, LON, LAT, LOC_DC, COORD_DC, STTS_CD, PTZ_YN, SOURCE_NM, STREAM_URL, REG_DTM FROM CCTV_CAMERA WHERE ${conditions.join(' AND ')} ORDER BY REGION_NM, CAMERA_NM`, params ); return rows.map((r: Record) => rowToCctv(r)); } // ============================================================ // SAT_REQUEST // ============================================================ interface SatRequestItem { satReqSn: number; reqCd: string; acdntSn: number | null; lon: number | null; lat: number | null; zoneDc: string | null; coordDc: string | null; zoneAreaKm2: number | null; satNm: string | null; providerNm: string | null; resolution: string | null; purposeDc: string | null; reqstrNm: string | null; reqDtm: string | null; expectedRcvDtm: string | null; sttsCd: string; regDtm: string; } interface ListSatRequestsInput { status?: string; } function rowToSatRequest(r: Record): SatRequestItem { return { satReqSn: r.sat_req_sn as number, reqCd: r.req_cd as string, acdntSn: r.acdnt_sn as number | null, lon: r.lon ? parseFloat(r.lon as string) : null, lat: r.lat ? parseFloat(r.lat as string) : null, zoneDc: r.zone_dc as string | null, coordDc: r.coord_dc as string | null, zoneAreaKm2: r.zone_area_km2 ? parseFloat(r.zone_area_km2 as string) : null, satNm: r.sat_nm as string | null, providerNm: r.provider_nm as string | null, resolution: r.resolution as string | null, purposeDc: r.purpose_dc as string | null, reqstrNm: r.reqstr_nm as string | null, reqDtm: r.req_dtm as string | null, expectedRcvDtm: r.expected_rcv_dtm as string | null, sttsCd: r.stts_cd as string, regDtm: r.reg_dtm as string, }; } export async function listSatRequests(input: ListSatRequestsInput): Promise { const conditions: string[] = ["USE_YN = 'Y'"]; const params: string[] = []; let idx = 1; if (input.status) { conditions.push(`STTS_CD = $${idx++}`); params.push(input.status); } const { rows } = await wingPool.query( `SELECT SAT_REQ_SN, REQ_CD, ACDNT_SN, LON, LAT, ZONE_DC, COORD_DC, ZONE_AREA_KM2, SAT_NM, PROVIDER_NM, RESOLUTION, PURPOSE_DC, REQSTR_NM, REQ_DTM, EXPECTED_RCV_DTM, STTS_CD, REG_DTM FROM SAT_REQUEST WHERE ${conditions.join(' AND ')} ORDER BY REQ_DTM DESC NULLS LAST`, params ); return rows.map((r: Record) => rowToSatRequest(r)); } export async function createSatRequest(input: { reqCd: string; acdntSn?: number; lon?: number; lat?: number; zoneDc?: string; coordDc?: string; zoneAreaKm2?: number; satNm?: string; providerNm?: string; resolution?: string; purposeDc?: string; reqstrNm?: string; reqDtm?: string; expectedRcvDtm?: string; }): Promise<{ satReqSn: number }> { const { rows } = await wingPool.query( `INSERT INTO SAT_REQUEST ( REQ_CD, ACDNT_SN, LON, LAT, GEOM, ZONE_DC, COORD_DC, ZONE_AREA_KM2, SAT_NM, PROVIDER_NM, RESOLUTION, PURPOSE_DC, REQSTR_NM, REQ_DTM, EXPECTED_RCV_DTM ) VALUES ( $1, $2, $3, $4, CASE WHEN $3 IS NOT NULL AND $4 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($3::float, $4::float), 4326) END, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 ) RETURNING SAT_REQ_SN`, [ input.reqCd, input.acdntSn || null, input.lon || null, input.lat || null, input.zoneDc || null, input.coordDc || null, input.zoneAreaKm2 || null, input.satNm || null, input.providerNm || null, input.resolution || null, input.purposeDc || null, input.reqstrNm || null, input.reqDtm || null, input.expectedRcvDtm || null, ] ); return { satReqSn: rows[0].sat_req_sn }; } const VALID_SAT_STATUSES = ['PENDING', 'SHOOTING', 'COMPLETED', 'CANCELLED'] as const; type SatStatus = typeof VALID_SAT_STATUSES[number]; export function isValidSatStatus(value: string): value is SatStatus { return (VALID_SAT_STATUSES as readonly string[]).includes(value); } export async function updateSatRequestStatus(sn: number, sttsCd: string): Promise { await wingPool.query( `UPDATE SAT_REQUEST SET STTS_CD = $1 WHERE SAT_REQ_SN = $2 AND USE_YN = 'Y'`, [sttsCd, sn] ); } // ============================================================ // OIL INFERENCE (GPU 서버 프록시) // ============================================================ const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://211.208.115.83:5001'; const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090'; const INFERENCE_TIMEOUT_MS = 10_000; export interface OilInferenceRegion { classId: number; className: string; pixelCount: number; percentage: number; thicknessMm: number; } export interface OilInferenceResult { mask: string; // base64 uint8 array (values 0-4) width: number; height: number; regions: OilInferenceRegion[]; } /** 여러 이미지를 이미지 분석 서버의 /stitch 엔드포인트로 전송해 합성 JPEG를 반환한다. */ export async function stitchImages( files: Express.Multer.File[], fileId: string ): Promise { const form = new FormData(); form.append('fileId', fileId); for (const f of files) { form.append('files', new Blob([f.buffer], { type: f.mimetype }), f.originalname); } const response = await fetch(`${IMAGE_API_URL}/stitch`, { method: 'POST', body: form, signal: AbortSignal.timeout(300_000), }); if (!response.ok) { const detail = await response.text().catch(() => ''); throw new Error(`stitch server responded ${response.status}: ${detail}`); } return Buffer.from(await response.arrayBuffer()); } /** GPU 추론 서버에 이미지를 전송하고 세그멘테이션 결과를 반환한다. */ export async function requestOilInference(imageBase64: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS); try { const response = await fetch(`${OIL_INFERENCE_URL}/inference`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: imageBase64 }), signal: controller.signal, }); if (!response.ok) { const detail = await response.text().catch(() => ''); throw new Error(`Inference server responded ${response.status}: ${detail}`); } return await response.json() as OilInferenceResult; } finally { clearTimeout(timeout); } } /** GPU 추론 서버 헬스체크 */ export async function checkInferenceHealth(): Promise<{ status: string; device?: string }> { try { const response = await fetch(`${OIL_INFERENCE_URL}/health`, { signal: AbortSignal.timeout(3000), }); if (!response.ok) throw new Error(`status ${response.status}`); return await response.json() as { status: string; device?: string }; } catch { 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; }