feat: StationOverlay 렌더링 최적화 및 스무딩 적용 close #1
- 텍스트(측점/POI) 전 프레임 사전 계산 Map (requestIdleCallback 백그라운드) - 드론 데이터 이동 평균 스무딩 (smoothFrame ±N프레임) - 30fps→60fps 프레임 간 선형 보간 (performance.now() 기반) - EMA(지수이동평균) 표시 위치 스무딩 (α=0.01 기본값) - 글씨 2배 크기, bold, strokeText 테두리, 배경 박스 제거 - 카메라 파라미터 패널에 smooth/EMA α 슬라이더 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
10
server/.env.example
Normal file
10
server/.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
PORT=3030
|
||||
VIDEOS_DIR=../storage/videos
|
||||
HLS_DIR=../storage/hls
|
||||
FRAMES_DIR=../storage/frames
|
||||
THUMBNAILS_DIR=../storage/thumbnails
|
||||
DB_PATH=../storage/annotations.db
|
||||
MAX_UPLOAD_SIZE=21474836480
|
||||
FFMPEG_PATH=ffmpeg
|
||||
HLS_SEGMENT_TIME=6
|
||||
THUMBNAIL_INTERVAL=10
|
||||
31
server/package.json
Normal file
31
server/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@abcvideo/server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/app.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/app.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@abcvideo/shared": "*",
|
||||
"@tus/file-store": "^1.4.0",
|
||||
"@tus/server": "^2.3.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"check-disk-space": "^3.4.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"iconv-lite": "^0.4.24",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.30",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
109
server/src/app.ts
Normal file
109
server/src/app.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import checkDiskSpace from 'check-disk-space';
|
||||
import { config } from './config';
|
||||
import { initDatabase, ensureStorageDirs, cleanupOldTempFiles } from './services/storage';
|
||||
import { checkFFmpegInstalled } from './services/ffmpeg';
|
||||
import streamRouter from './routes/stream';
|
||||
import hlsRouter from './routes/hls';
|
||||
import frameRouter from './routes/frame';
|
||||
import uploadRouter from './routes/upload';
|
||||
import metaRouter from './routes/meta';
|
||||
import annotationsRouter from './routes/annotations';
|
||||
import geoRouter from './routes/geo';
|
||||
|
||||
const app = express();
|
||||
|
||||
// 내부 네트워크 접근 허용: 모든 origin 허용 (사내 단일 사용자 도구)
|
||||
app.use(cors({ origin: true }));
|
||||
|
||||
// Raw body parser for chunk uploads (must come before express.json)
|
||||
app.use('/api/upload/chunk', express.raw({ type: 'application/octet-stream', limit: '110mb' }));
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Routes
|
||||
app.use('/api/stream', streamRouter);
|
||||
app.use('/api/hls', hlsRouter);
|
||||
app.use('/api/frame', frameRouter);
|
||||
app.use('/api/upload', uploadRouter);
|
||||
app.use('/api/videos', metaRouter);
|
||||
app.use('/api/meta', metaRouter);
|
||||
app.use('/api/annotations/:videoId', annotationsRouter);
|
||||
app.use('/api/geo', geoRouter);
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// 프로덕션 빌드 정적 파일 서빙 (client/dist)
|
||||
const clientDistPath = path.resolve(__dirname, '../../client/dist');
|
||||
// CSP 헤더: 브라우저 확장 프로그램의 스크립트 주입 차단
|
||||
const cspHeader =
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"font-src 'self' data:; " +
|
||||
"img-src 'self' blob: data:; " +
|
||||
"media-src 'self' blob:; " +
|
||||
"connect-src 'self'; " +
|
||||
"worker-src 'self' blob:;";
|
||||
app.use((req, res, next) => {
|
||||
if (!req.path.startsWith('/api')) {
|
||||
res.setHeader('Content-Security-Policy', cspHeader);
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.use(express.static(clientDistPath));
|
||||
// SPA fallback: API가 아닌 모든 요청은 index.html로
|
||||
app.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/api')) return next();
|
||||
res.sendFile(path.join(clientDistPath, 'index.html'));
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error('[error]', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
});
|
||||
|
||||
async function start(): Promise<void> {
|
||||
// Check FFmpeg
|
||||
const ffmpegOk = await checkFFmpegInstalled();
|
||||
if (!ffmpegOk) {
|
||||
console.warn('[warn] FFmpeg not found in PATH. Frame extraction and HLS conversion will fail.');
|
||||
} else {
|
||||
console.log('[ffmpeg] detected OK');
|
||||
}
|
||||
|
||||
// Init storage dirs + DB
|
||||
await ensureStorageDirs();
|
||||
initDatabase();
|
||||
|
||||
// Startup cleanup
|
||||
await cleanupOldTempFiles();
|
||||
|
||||
// Disk space check every hour
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const space = await checkDiskSpace(path.resolve(config.videosDir));
|
||||
const freeGB = space.free / (1024 ** 3);
|
||||
if (freeGB < 10) {
|
||||
console.warn(`[disk] WARNING: only ${freeGB.toFixed(1)}GB free`);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 60 * 60 * 1000);
|
||||
|
||||
// Periodic cleanup every hour
|
||||
setInterval(() => cleanupOldTempFiles(), 60 * 60 * 1000);
|
||||
|
||||
app.listen(config.port, '0.0.0.0', () => {
|
||||
console.log(`[server] running on http://0.0.0.0:${config.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch(console.error);
|
||||
|
||||
export default app;
|
||||
15
server/src/config.ts
Normal file
15
server/src/config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import path from 'path';
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3030', 10),
|
||||
videosDir: path.resolve(process.env.VIDEOS_DIR || '../storage/videos'),
|
||||
hlsDir: path.resolve(process.env.HLS_DIR || '../storage/hls'),
|
||||
framesDir: path.resolve(process.env.FRAMES_DIR || '../storage/frames'),
|
||||
thumbnailsDir: path.resolve(process.env.THUMBNAILS_DIR || '../storage/thumbnails'),
|
||||
dbPath: path.resolve(process.env.DB_PATH || '../storage/annotations.db'),
|
||||
maxUploadSize: parseInt(process.env.MAX_UPLOAD_SIZE || String(20 * 1024 * 1024 * 1024), 10),
|
||||
ffmpegPath: process.env.FFMPEG_PATH || 'ffmpeg',
|
||||
ffprobePath: process.env.FFPROBE_PATH || 'ffprobe',
|
||||
hlsSegmentTime: parseInt(process.env.HLS_SEGMENT_TIME || '6', 10),
|
||||
thumbnailInterval: parseInt(process.env.THUMBNAIL_INTERVAL || '10', 10),
|
||||
};
|
||||
33
server/src/middleware/security.ts
Normal file
33
server/src/middleware/security.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import path from 'path';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
'video/mp4',
|
||||
'video/quicktime',
|
||||
'video/x-matroska',
|
||||
'video/webm',
|
||||
'video/avi',
|
||||
]);
|
||||
|
||||
export function pathTraversalGuard(allowedBase: string) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const id = req.params.videoId || req.params.id || '';
|
||||
if (!id) { next(); return; }
|
||||
const resolved = path.resolve(allowedBase, id);
|
||||
if (!resolved.startsWith(path.resolve(allowedBase))) {
|
||||
res.status(400).json({ error: 'Invalid path' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function validateMimeType(mimeType: string): boolean {
|
||||
return ALLOWED_MIME_TYPES.has(mimeType);
|
||||
}
|
||||
|
||||
export function securityHeaders(_req: Request, res: Response, next: NextFunction): void {
|
||||
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
||||
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
|
||||
next();
|
||||
}
|
||||
103
server/src/routes/annotations.ts
Normal file
103
server/src/routes/annotations.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import {
|
||||
getAnnotations,
|
||||
createAnnotation,
|
||||
getAnnotation,
|
||||
updateAnnotation,
|
||||
deleteAnnotation,
|
||||
} from '../services/storage';
|
||||
import type { CreateAnnotationInput, UpdateAnnotationInput } from '@abcvideo/shared';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
|
||||
// GET /api/annotations/:videoId
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
const annotations = getAnnotations(req.params.videoId);
|
||||
res.json(annotations);
|
||||
});
|
||||
|
||||
// POST /api/annotations/:videoId
|
||||
router.post('/', (req: Request, res: Response) => {
|
||||
const input: CreateAnnotationInput = { ...req.body, videoId: req.params.videoId };
|
||||
if (!input.type || input.timeStart === undefined || input.timeEnd === undefined) {
|
||||
res.status(400).json({ error: 'Missing required fields: type, timeStart, timeEnd' });
|
||||
return;
|
||||
}
|
||||
const annotation = createAnnotation(input);
|
||||
res.status(201).json(annotation);
|
||||
});
|
||||
|
||||
// GET /api/annotations/:videoId/export?format=vtt|srt|json|csv
|
||||
// NOTE: this must be registered before /:id to avoid "export" matching as an id
|
||||
router.get('/export', (req: Request, res: Response) => {
|
||||
const { format = 'json' } = req.query as { format?: string };
|
||||
const annotations = getAnnotations(req.params.videoId);
|
||||
|
||||
const toTimecode = (s: number, sep = '.') => {
|
||||
const h = Math.floor(s / 3600).toString().padStart(2, '0');
|
||||
const m = Math.floor((s % 3600) / 60).toString().padStart(2, '0');
|
||||
const sec = Math.floor(s % 60).toString().padStart(2, '0');
|
||||
const ms = Math.round((s % 1) * 1000).toString().padStart(3, '0');
|
||||
return `${h}:${m}:${sec}${sep}${ms}`;
|
||||
};
|
||||
|
||||
if (format === 'vtt') {
|
||||
const lines = ['WEBVTT', ''];
|
||||
annotations
|
||||
.filter(a => a.type === 'subtitle')
|
||||
.forEach((a, i) => {
|
||||
lines.push(`${i + 1}`);
|
||||
lines.push(`${toTimecode(a.timeStart)} --> ${toTimecode(a.timeEnd)}`);
|
||||
lines.push(a.text, '');
|
||||
});
|
||||
res.setHeader('Content-Type', 'text/vtt');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="annotations.vtt"`);
|
||||
res.send(lines.join('\n'));
|
||||
} else if (format === 'srt') {
|
||||
const lines: string[] = [];
|
||||
annotations
|
||||
.filter(a => a.type === 'subtitle')
|
||||
.forEach((a, i) => {
|
||||
lines.push(`${i + 1}`);
|
||||
lines.push(`${toTimecode(a.timeStart, ',')} --> ${toTimecode(a.timeEnd, ',')}`);
|
||||
lines.push(a.text, '');
|
||||
});
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="annotations.srt"`);
|
||||
res.send(lines.join('\n'));
|
||||
} else if (format === 'csv') {
|
||||
const header = 'id,type,timeStart,timeEnd,text,posX,posY\n';
|
||||
const rows = annotations.map(a =>
|
||||
`${a.id},${a.type},${a.timeStart},${a.timeEnd},"${a.text.replace(/"/g, '""')}",${a.position.x},${a.position.y}`
|
||||
).join('\n');
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="annotations.csv"`);
|
||||
res.send(header + rows);
|
||||
} else {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="annotations.json"`);
|
||||
res.json(annotations);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/annotations/:videoId/:id
|
||||
router.put('/:id', (req: Request, res: Response) => {
|
||||
const input: UpdateAnnotationInput = req.body;
|
||||
const updated = updateAnnotation(req.params.id, input);
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: 'Annotation not found' });
|
||||
return;
|
||||
}
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
// DELETE /api/annotations/:videoId/:id
|
||||
router.delete('/:id', (req: Request, res: Response) => {
|
||||
const success = deleteAnnotation(req.params.id);
|
||||
if (!success) {
|
||||
res.status(404).json({ error: 'Annotation not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
57
server/src/routes/frame.ts
Normal file
57
server/src/routes/frame.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import fs from 'fs';
|
||||
import { promises as fsp } from 'fs';
|
||||
import path from 'path';
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { config } from '../config';
|
||||
import { runFFmpeg } from '../services/ffmpeg';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/frame/:videoId?time=00:01:30.000
|
||||
router.get('/:videoId', async (req: Request, res: Response) => {
|
||||
const { videoId } = req.params;
|
||||
const { time, frame } = req.query as { time?: string; frame?: string };
|
||||
|
||||
const inputPath = path.resolve(config.videosDir, videoId);
|
||||
if (!inputPath.startsWith(path.resolve(config.videosDir))) {
|
||||
res.status(400).json({ error: 'Invalid video ID' });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
res.status(404).json({ error: 'Video not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
let seekTime = '0';
|
||||
if (time) {
|
||||
seekTime = time;
|
||||
} else if (frame) {
|
||||
// frame number to time requires fps — use 30fps default
|
||||
seekTime = String(parseInt(frame, 10) / 30);
|
||||
}
|
||||
|
||||
const outputFile = path.join(config.framesDir, `${uuidv4()}.jpg`);
|
||||
|
||||
try {
|
||||
await runFFmpeg([
|
||||
'-accurate_seek',
|
||||
'-ss', seekTime,
|
||||
'-i', inputPath,
|
||||
'-frames:v', '1',
|
||||
'-q:v', '2',
|
||||
outputFile,
|
||||
]);
|
||||
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
const stream = fs.createReadStream(outputFile);
|
||||
stream.pipe(res);
|
||||
stream.on('end', () => {
|
||||
fsp.unlink(outputFile).catch(() => {});
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Frame extraction failed', detail: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
94
server/src/routes/geo.ts
Normal file
94
server/src/routes/geo.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Router } from 'express';
|
||||
import path from 'path';
|
||||
import { setGeoDataDir, findFramesForPoi, findPoisForFrame, getAllPois, getDroneFrames, getCenterlinePoints } from '../services/geoMatch';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 드론 CSV + building 폴더 위치 설정
|
||||
const GEO_DATA_DIR = process.env.GEO_DATA_DIR ||
|
||||
path.resolve(__dirname, '../../../samplevideo');
|
||||
|
||||
setGeoDataDir(GEO_DATA_DIR);
|
||||
|
||||
/** POI/측점 전체 목록 (자동완성용) */
|
||||
router.get('/pois', (_req, res) => {
|
||||
try {
|
||||
const pois = getAllPois();
|
||||
res.json(pois);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 건물/측점명 검색 → 해당 POI가 카메라에 보이는 프레임 목록
|
||||
* GET /api/geo/search?q=회덕역&margin=1.0
|
||||
*/
|
||||
router.get('/search', (req, res) => {
|
||||
const q = String(req.query.q || '').trim();
|
||||
const margin = parseFloat(String(req.query.margin || '1.0'));
|
||||
const yawOffset = parseFloat(String(req.query.yawOffset || '0'));
|
||||
if (!q) return res.status(400).json({ error: 'q 파라미터 필요' });
|
||||
|
||||
try {
|
||||
const result = findFramesForPoi(
|
||||
q,
|
||||
isNaN(margin) ? 1.0 : margin,
|
||||
parseFloat(String(req.query.maxDist || '2000')),
|
||||
isNaN(yawOffset) ? 0 : yawOffset,
|
||||
);
|
||||
if (!result.poi) return res.status(404).json({ error: `"${q}" POI를 찾을 수 없습니다` });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 특정 프레임에서 보이는 POI/측점 목록
|
||||
* GET /api/geo/frame/1234?margin=1.0
|
||||
*/
|
||||
router.get('/frame/:frameNum', (req, res) => {
|
||||
const frameNum = parseInt(req.params.frameNum, 10);
|
||||
const margin = parseFloat(String(req.query.margin || '1.0'));
|
||||
const yawOffset = parseFloat(String(req.query.yawOffset || '0'));
|
||||
if (isNaN(frameNum)) return res.status(400).json({ error: '유효한 프레임 번호 필요' });
|
||||
|
||||
try {
|
||||
const result = findPoisForFrame(frameNum, isNaN(margin) ? 1.0 : margin, isNaN(yawOffset) ? 0 : yawOffset);
|
||||
if (!result.droneFrame) return res.status(404).json({ error: `프레임 ${frameNum}을 찾을 수 없습니다` });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 드론 비행경로 전체 데이터 (경로 시각화용)
|
||||
* GET /api/geo/frames?step=30 step: 샘플링 간격 (기본 30 = 1초마다)
|
||||
*/
|
||||
router.get('/frames', (req, res) => {
|
||||
const step = Math.max(1, parseInt(String(req.query.step || '30'), 10));
|
||||
try {
|
||||
const all = getDroneFrames();
|
||||
const sampled = all.filter((_, i) => i % step === 0);
|
||||
res.json(sampled);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 선로 중심선 전체 좌표 (center.csv, 224점)
|
||||
* GET /api/geo/centerline
|
||||
*/
|
||||
router.get('/centerline', (_req, res) => {
|
||||
try {
|
||||
const pts = getCenterlinePoints();
|
||||
res.json(pts);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
163
server/src/routes/hls.ts
Normal file
163
server/src/routes/hls.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import fs from 'fs';
|
||||
import { promises as fsp } from 'fs';
|
||||
import path from 'path';
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { config } from '../config';
|
||||
import { probeVideo, getVideoCodec, getVideoDuration, runFFmpeg } from '../services/ffmpeg';
|
||||
import type { HlsConversionStatus } from '@abcvideo/shared';
|
||||
|
||||
const router = Router();
|
||||
|
||||
interface ConversionJob {
|
||||
status: HlsConversionStatus;
|
||||
percent: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const jobs = new Map<string, ConversionJob>();
|
||||
const sseClients = new Map<string, Set<Response>>();
|
||||
|
||||
function emitProgress(videoId: string, job: ConversionJob): void {
|
||||
const clients = sseClients.get(videoId);
|
||||
if (!clients) return;
|
||||
const data = JSON.stringify({ videoId, percent: job.percent, status: job.status });
|
||||
for (const res of clients) {
|
||||
res.write(`data: ${data}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/hls/:videoId/progress (SSE)
|
||||
router.get('/:videoId/progress', (req: Request, res: Response) => {
|
||||
const { videoId } = req.params;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
res.write('\n');
|
||||
|
||||
if (!sseClients.has(videoId)) sseClients.set(videoId, new Set());
|
||||
sseClients.get(videoId)!.add(res);
|
||||
|
||||
const job = jobs.get(videoId);
|
||||
if (job) {
|
||||
res.write(`data: ${JSON.stringify({ videoId, percent: job.percent, status: job.status })}\n\n`);
|
||||
}
|
||||
|
||||
req.on('close', () => {
|
||||
sseClients.get(videoId)?.delete(res);
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/hls/:videoId/convert
|
||||
router.post('/:videoId/convert', async (req: Request, res: Response) => {
|
||||
const { videoId } = req.params;
|
||||
const inputPath = path.resolve(config.videosDir, videoId);
|
||||
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
res.status(404).json({ error: 'Video not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = jobs.get(videoId);
|
||||
if (existing?.status === 'converting') {
|
||||
res.json({ status: 'converting', message: 'Already converting' });
|
||||
return;
|
||||
}
|
||||
if (existing?.status === 'done') {
|
||||
res.json({ status: 'done', message: 'Already converted' });
|
||||
return;
|
||||
}
|
||||
|
||||
const outputDir = path.join(config.hlsDir, videoId.replace(/\.[^.]+$/, ''));
|
||||
await fsp.mkdir(outputDir, { recursive: true });
|
||||
|
||||
const job: ConversionJob = { status: 'converting', percent: 0 };
|
||||
jobs.set(videoId, job);
|
||||
|
||||
res.json({ status: 'converting', message: 'Conversion started' });
|
||||
|
||||
// Run conversion asynchronously
|
||||
(async () => {
|
||||
try {
|
||||
const probe = await probeVideo(inputPath);
|
||||
const duration = getVideoDuration(probe);
|
||||
const codec = getVideoCodec(probe);
|
||||
const isCopyable = codec === 'h264';
|
||||
|
||||
const ffmpegArgs = isCopyable
|
||||
? [
|
||||
'-i', inputPath,
|
||||
'-c', 'copy',
|
||||
'-f', 'hls',
|
||||
'-hls_time', String(config.hlsSegmentTime),
|
||||
'-hls_list_size', '0',
|
||||
'-hls_playlist_type', 'vod',
|
||||
'-hls_segment_filename', path.join(outputDir, 'segment%03d.ts'),
|
||||
path.join(outputDir, 'index.m3u8'),
|
||||
]
|
||||
: [
|
||||
'-i', inputPath,
|
||||
'-c:v', 'libx264', '-preset', 'medium', '-crf', '23',
|
||||
'-profile:v', 'high', '-level', '4.1',
|
||||
'-c:a', 'aac', '-b:a', '128k', '-ar', '48000',
|
||||
'-f', 'hls',
|
||||
'-hls_time', String(config.hlsSegmentTime),
|
||||
'-hls_list_size', '0',
|
||||
'-hls_playlist_type', 'vod',
|
||||
'-force_key_frames', `expr:gte(t,n_forced*2)`,
|
||||
'-hls_segment_filename', path.join(outputDir, 'segment%03d.ts'),
|
||||
path.join(outputDir, 'index.m3u8'),
|
||||
];
|
||||
|
||||
await runFFmpeg(ffmpegArgs, duration, (progress) => {
|
||||
job.percent = progress.percent;
|
||||
emitProgress(videoId, job);
|
||||
});
|
||||
|
||||
job.status = 'done';
|
||||
job.percent = 100;
|
||||
emitProgress(videoId, job);
|
||||
console.log(`[hls] conversion complete: ${videoId}`);
|
||||
} catch (err) {
|
||||
job.status = 'error';
|
||||
job.error = String(err);
|
||||
emitProgress(videoId, job);
|
||||
console.error(`[hls] conversion error for ${videoId}:`, err);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// GET /api/hls/:videoId/status
|
||||
router.get('/:videoId/status', (req: Request, res: Response) => {
|
||||
const { videoId } = req.params;
|
||||
const job = jobs.get(videoId) || { status: 'idle' as HlsConversionStatus, percent: 0 };
|
||||
res.json(job);
|
||||
});
|
||||
|
||||
// GET /api/hls/:videoId/index.m3u8
|
||||
router.get('/:videoId/index.m3u8', async (req: Request, res: Response) => {
|
||||
const hlsId = req.params.videoId.replace(/\.[^.]+$/, '');
|
||||
const filePath = path.join(config.hlsDir, hlsId, 'index.m3u8');
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404).json({ error: 'HLS not ready' });
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
// GET /api/hls/:videoId/:segment (.ts segments)
|
||||
router.get('/:videoId/:segment', async (req: Request, res: Response) => {
|
||||
const hlsId = req.params.videoId.replace(/\.[^.]+$/, '');
|
||||
const filePath = path.join(config.hlsDir, hlsId, req.params.segment);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404).json({ error: 'Segment not found' });
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'video/mp2t');
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
export default router;
|
||||
77
server/src/routes/meta.ts
Normal file
77
server/src/routes/meta.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import fs from 'fs';
|
||||
import { promises as fsp } from 'fs';
|
||||
import path from 'path';
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { config } from '../config';
|
||||
import { probeVideo, getVideoCodec, getVideoDuration, getVideoFps } from '../services/ffmpeg';
|
||||
import type { VideoMeta } from '@abcvideo/shared';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/videos
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const files = await fsp.readdir(config.videosDir, { withFileTypes: true });
|
||||
const videos = files
|
||||
.filter(f => f.isFile() && /\.(mp4|mkv|webm|mov|avi)$/i.test(f.name))
|
||||
.map(f => ({ videoId: f.name, filename: f.name }));
|
||||
res.json(videos);
|
||||
} catch {
|
||||
res.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/meta/:videoId
|
||||
router.get('/:videoId', async (req: Request, res: Response) => {
|
||||
const { videoId } = req.params;
|
||||
const filePath = path.resolve(config.videosDir, videoId);
|
||||
if (!filePath.startsWith(path.resolve(config.videosDir))) {
|
||||
res.status(400).json({ error: 'Invalid video ID' });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404).json({ error: 'Video not found' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const probe = await probeVideo(filePath);
|
||||
const videoStream = probe.streams.find(s => s.codec_type === 'video');
|
||||
const stat = await fsp.stat(filePath);
|
||||
const meta: VideoMeta = {
|
||||
videoId,
|
||||
filename: videoId,
|
||||
size: stat.size,
|
||||
duration: getVideoDuration(probe),
|
||||
fps: getVideoFps(probe),
|
||||
width: videoStream?.width || 0,
|
||||
height: videoStream?.height || 0,
|
||||
codec: getVideoCodec(probe),
|
||||
hlsStatus: 'idle',
|
||||
createdAt: stat.birthtime.toISOString(),
|
||||
};
|
||||
res.json(meta);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Metadata extraction failed', detail: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/videos/:videoId
|
||||
router.delete('/:videoId', async (req: Request, res: Response) => {
|
||||
const { videoId } = req.params;
|
||||
const filePath = path.resolve(config.videosDir, videoId);
|
||||
if (!filePath.startsWith(path.resolve(config.videosDir))) {
|
||||
res.status(400).json({ error: 'Invalid video ID' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
// Remove HLS dir
|
||||
const hlsId = videoId.replace(/\.[^.]+$/, '');
|
||||
await fsp.rm(path.join(config.hlsDir, hlsId), { recursive: true, force: true });
|
||||
res.json({ success: true });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Delete failed' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
16
server/src/routes/stream.ts
Normal file
16
server/src/routes/stream.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import { streamVideo } from '../services/streaming';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/:videoId', async (req, res) => {
|
||||
try {
|
||||
await streamVideo(req, res);
|
||||
} catch (err) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Streaming error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
201
server/src/routes/upload.ts
Normal file
201
server/src/routes/upload.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Chunked upload route
|
||||
*
|
||||
* POST /api/upload/init — start upload session, returns uploadId
|
||||
* POST /api/upload/chunk — send a chunk (multipart/form-data or raw body)
|
||||
* POST /api/upload/complete — assemble chunks into final file
|
||||
* GET /api/upload/:uploadId/status — check progress
|
||||
*
|
||||
* Each chunk is sent as raw binary body with headers:
|
||||
* X-Upload-Id: <uploadId>
|
||||
* X-Chunk-Index: <0-based index>
|
||||
* X-Total-Chunks: <total>
|
||||
* Content-Type: application/octet-stream
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import { promises as fsp } from 'fs';
|
||||
import path from 'path';
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { config } from '../config';
|
||||
import { probeVideo } from '../services/ffmpeg';
|
||||
import { validateMimeType } from '../middleware/security';
|
||||
|
||||
const router = Router();
|
||||
|
||||
interface UploadSession {
|
||||
uploadId: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
totalChunks: number;
|
||||
receivedChunks: Set<number>;
|
||||
tmpDir: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, UploadSession>();
|
||||
|
||||
// POST /api/upload/init
|
||||
router.post('/init', (req: Request, res: Response) => {
|
||||
const { filename, mimeType, totalChunks } = req.body as {
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
totalChunks?: number;
|
||||
};
|
||||
|
||||
if (!filename || !mimeType || !totalChunks) {
|
||||
res.status(400).json({ error: 'Missing: filename, mimeType, totalChunks' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateMimeType(mimeType)) {
|
||||
res.status(400).json({ error: `Unsupported MIME type: ${mimeType}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadId = uuidv4();
|
||||
const tmpDir = path.join(config.videosDir, 'tmp', uploadId);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
sessions.set(uploadId, {
|
||||
uploadId,
|
||||
filename,
|
||||
mimeType,
|
||||
totalChunks: Number(totalChunks),
|
||||
receivedChunks: new Set(),
|
||||
tmpDir,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
res.status(201).json({ uploadId });
|
||||
});
|
||||
|
||||
// POST /api/upload/chunk (raw binary body)
|
||||
router.post('/chunk', (req: Request, res: Response) => {
|
||||
const uploadId = req.headers['x-upload-id'] as string;
|
||||
const chunkIndex = parseInt(req.headers['x-chunk-index'] as string, 10);
|
||||
|
||||
if (!uploadId || isNaN(chunkIndex)) {
|
||||
res.status(400).json({ error: 'Missing X-Upload-Id or X-Chunk-Index header' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessions.get(uploadId);
|
||||
if (!session) {
|
||||
res.status(404).json({ error: 'Upload session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (chunkIndex < 0 || chunkIndex >= session.totalChunks) {
|
||||
res.status(400).json({ error: 'Invalid chunk index' });
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkPath = path.join(session.tmpDir, `chunk_${chunkIndex.toString().padStart(6, '0')}`);
|
||||
const writeStream = fs.createWriteStream(chunkPath);
|
||||
|
||||
req.pipe(writeStream);
|
||||
|
||||
writeStream.on('finish', () => {
|
||||
session.receivedChunks.add(chunkIndex);
|
||||
res.json({
|
||||
chunkIndex,
|
||||
received: session.receivedChunks.size,
|
||||
total: session.totalChunks,
|
||||
});
|
||||
});
|
||||
|
||||
writeStream.on('error', (err) => {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Chunk write failed', detail: String(err) });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/upload/complete
|
||||
router.post('/complete', async (req: Request, res: Response) => {
|
||||
const { uploadId } = req.body as { uploadId?: string };
|
||||
|
||||
if (!uploadId) {
|
||||
res.status(400).json({ error: 'Missing uploadId' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessions.get(uploadId);
|
||||
if (!session) {
|
||||
res.status(404).json({ error: 'Upload session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.receivedChunks.size !== session.totalChunks) {
|
||||
res.status(400).json({
|
||||
error: 'Not all chunks received',
|
||||
received: session.receivedChunks.size,
|
||||
total: session.totalChunks,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitize filename: keep extension, replace unsafe chars
|
||||
const ext = path.extname(session.filename).toLowerCase() || '.mp4';
|
||||
const baseName = path.basename(session.filename, ext).replace(/[^a-zA-Z0-9_\-]/g, '_');
|
||||
const finalName = `${baseName}_${uploadId.slice(0, 8)}${ext}`;
|
||||
const finalPath = path.join(config.videosDir, finalName);
|
||||
|
||||
try {
|
||||
const writeStream = fs.createWriteStream(finalPath);
|
||||
|
||||
for (let i = 0; i < session.totalChunks; i++) {
|
||||
const chunkPath = path.join(session.tmpDir, `chunk_${i.toString().padStart(6, '0')}`);
|
||||
const data = await fsp.readFile(chunkPath);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writeStream.write(data, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writeStream.end((err?: Error | null) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup tmp dir
|
||||
await fsp.rm(session.tmpDir, { recursive: true, force: true });
|
||||
sessions.delete(uploadId);
|
||||
|
||||
// Probe final file
|
||||
try {
|
||||
const probe = await probeVideo(finalPath);
|
||||
console.log(`[upload] complete: ${finalName}, duration: ${probe.format.duration}s`);
|
||||
} catch (err) {
|
||||
console.error('[upload] probe failed:', err);
|
||||
}
|
||||
|
||||
res.json({ videoId: finalName, filename: finalName });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Assembly failed', detail: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/upload/:uploadId/status
|
||||
router.get('/:uploadId/status', (req: Request, res: Response) => {
|
||||
const { uploadId } = req.params;
|
||||
const session = sessions.get(uploadId);
|
||||
if (!session) {
|
||||
res.status(404).json({ error: 'Upload session not found' });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
uploadId,
|
||||
filename: session.filename,
|
||||
received: session.receivedChunks.size,
|
||||
total: session.totalChunks,
|
||||
status: session.receivedChunks.size === session.totalChunks ? 'complete' : 'uploading',
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
119
server/src/services/ffmpeg.ts
Normal file
119
server/src/services/ffmpeg.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { config } from '../config';
|
||||
|
||||
export interface FFprobeStream {
|
||||
codec_name: string;
|
||||
codec_type: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
r_frame_rate?: string;
|
||||
avg_frame_rate?: string;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
export interface FFprobeFormat {
|
||||
duration: string;
|
||||
size: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface FFprobeResult {
|
||||
streams: FFprobeStream[];
|
||||
format: FFprobeFormat;
|
||||
}
|
||||
|
||||
export interface FFmpegProgress {
|
||||
percent: number;
|
||||
currentTime: number;
|
||||
}
|
||||
|
||||
type ProgressCallback = (progress: FFmpegProgress) => void;
|
||||
|
||||
export function runFFprobe(args: string[]): Promise<FFprobeResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(config.ffprobePath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
|
||||
proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
||||
proc.on('close', (code) => {
|
||||
if (code !== 0) return reject(new Error(`FFprobe exit ${code}: ${stderr.slice(-300)}`));
|
||||
try {
|
||||
resolve(JSON.parse(stdout) as FFprobeResult);
|
||||
} catch {
|
||||
reject(new Error(`FFprobe JSON parse error: ${stdout.slice(-200)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function runFFmpeg(args: string[], totalDuration?: number, onProgress?: ProgressCallback): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(config.ffmpegPath, ['-y', ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stderr = '';
|
||||
|
||||
proc.stderr.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
stderr += text;
|
||||
|
||||
if (onProgress && totalDuration) {
|
||||
const match = text.match(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
|
||||
if (match) {
|
||||
const currentTime =
|
||||
parseInt(match[1]) * 3600 +
|
||||
parseInt(match[2]) * 60 +
|
||||
parseInt(match[3]) +
|
||||
parseInt(match[4]) / 100;
|
||||
onProgress({
|
||||
percent: Math.min(99, (currentTime / totalDuration) * 100),
|
||||
currentTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
onProgress?.({ percent: 100, currentTime: totalDuration || 0 });
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`FFmpeg exit ${code}: ${stderr.slice(-500)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function probeVideo(filePath: string): Promise<FFprobeResult> {
|
||||
return runFFprobe([
|
||||
'-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_format',
|
||||
'-show_streams',
|
||||
filePath,
|
||||
]);
|
||||
}
|
||||
|
||||
export function getVideoCodec(probe: FFprobeResult): string {
|
||||
const videoStream = probe.streams.find(s => s.codec_type === 'video');
|
||||
return videoStream?.codec_name || 'unknown';
|
||||
}
|
||||
|
||||
export function getVideoDuration(probe: FFprobeResult): number {
|
||||
return parseFloat(probe.format.duration || '0');
|
||||
}
|
||||
|
||||
export function getVideoFps(probe: FFprobeResult): number {
|
||||
const videoStream = probe.streams.find(s => s.codec_type === 'video');
|
||||
if (!videoStream?.r_frame_rate) return 30;
|
||||
const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
|
||||
return den ? num / den : 30;
|
||||
}
|
||||
|
||||
export async function checkFFmpegInstalled(): Promise<boolean> {
|
||||
try {
|
||||
await runFFprobe(['-version']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
522
server/src/services/geoMatch.ts
Normal file
522
server/src/services/geoMatch.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* 드론 영상 ↔ 지리정보 매핑 서비스
|
||||
*
|
||||
* 드론 CSV(frame별 위경도/자세) + POI/측점 CSV를 조합하여:
|
||||
* - 건물/측점명 → 해당 프레임 탐색
|
||||
* - 프레임 번호 → 카메라 시야 내 POI/측점 목록
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import iconv from 'iconv-lite';
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 타입 정의
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
export interface DroneFrame {
|
||||
frame: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
altitude: number;
|
||||
yaw: number; // 기수방향 (North=0, 시계방향, degrees)
|
||||
pitch: number; // 카메라 틸트 (음수=아래, degrees)
|
||||
roll: number;
|
||||
focalLen: number; // 35mm 환산 초점거리 (mm)
|
||||
}
|
||||
|
||||
export interface GeoPoint {
|
||||
title: string;
|
||||
category: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number; // 표고 (m)
|
||||
type: 'poi' | 'station';
|
||||
}
|
||||
|
||||
export interface FrameMatch {
|
||||
frame: number;
|
||||
time: number; // 초 단위 (frame / fps)
|
||||
bearingDiff: number; // 수평 각도차 (degrees, |값|이 작을수록 중심에 가까움)
|
||||
elevationDiff: number; // 수직 각도차 (degrees)
|
||||
distance: number; // 수평 거리 (m)
|
||||
pixelX: number; // 이미지 내 추정 픽셀 X (0~1 정규화)
|
||||
pixelY: number; // 이미지 내 추정 픽셀 Y (0~1 정규화)
|
||||
}
|
||||
|
||||
export interface PoiInFrame {
|
||||
poi: GeoPoint;
|
||||
bearingDiff: number;
|
||||
elevationDiff: number;
|
||||
distance: number;
|
||||
pixelX: number;
|
||||
pixelY: number;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 카메라 파라미터 (35mm 환산, Full Frame 36×24mm 기준)
|
||||
// ──────────────────────────────────────────
|
||||
const SENSOR_W_MM = 36;
|
||||
const SENSOR_H_MM = 24;
|
||||
const R_EARTH = 6371000;
|
||||
|
||||
function toRad(deg: number) { return deg * Math.PI / 180; }
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 블렌더 방식 월드 ENU 좌표계
|
||||
//
|
||||
// 블렌더에서 하는 것과 동일:
|
||||
// 1. 기준점(origin) = 드론 첫 프레임 위치
|
||||
// 2. 모든 측점 / 드론경로를 이 기준점 기준 ENU(m)로 단 한 번 변환
|
||||
// 3. 카메라(=드론) 위치도 같은 월드 좌표
|
||||
// 4. 측점 벡터 = 측점_월드 − 카메라_월드 → 카메라 회전 적용 → 투영
|
||||
//
|
||||
// cos(lat) 보정을 기준점의 위도로 고정 → 전 프레임 일관성 보장
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
/** 위경도+표고 → 월드 ENU (m). refLat/refLon/refAlt = 원점 */
|
||||
function geoToEnu(
|
||||
lat: number, lon: number, alt: number,
|
||||
refLat: number, refLon: number, refAlt: number,
|
||||
): [number, number, number] {
|
||||
const cosRef = Math.cos(toRad(refLat));
|
||||
const e = toRad(lon - refLon) * cosRef * R_EARTH; // East (m)
|
||||
const n = toRad(lat - refLat) * R_EARTH; // North (m)
|
||||
const u = alt - refAlt; // Up (m)
|
||||
return [e, n, u];
|
||||
}
|
||||
|
||||
/** 월드 ENU 벡터 + 카메라 자세 → 정규화 픽셀 (0~1) */
|
||||
function projectEnu(
|
||||
relEnu: [number, number, number], // 측점_월드 − 카메라_월드 (ENU m)
|
||||
yawDeg: number, // 카메라 yaw (North=0, CW+, degrees)
|
||||
pitchDeg: number, // 카메라 pitch (음수=아래, degrees)
|
||||
focalMm: number, // 35mm 환산 초점거리
|
||||
yawOffset = 0,
|
||||
): { px: number; py: number; cx: number; cy: number; cz: number } | null {
|
||||
const yaw = toRad(yawDeg + yawOffset);
|
||||
const pitch = toRad(pitchDeg);
|
||||
const cosY = Math.cos(yaw), sinY = Math.sin(yaw);
|
||||
const cosP = Math.cos(pitch), sinP = Math.sin(pitch);
|
||||
|
||||
// 카메라 축 벡터 (ENU 기준)
|
||||
const fwd: readonly [number, number, number] = [sinY * cosP, cosY * cosP, sinP];
|
||||
const right: readonly [number, number, number] = [cosY, -sinY, 0];
|
||||
const up: readonly [number, number, number] = [
|
||||
right[1] * fwd[2] - right[2] * fwd[1],
|
||||
right[2] * fwd[0] - right[0] * fwd[2],
|
||||
right[0] * fwd[1] - right[1] * fwd[0],
|
||||
];
|
||||
|
||||
const dot = (a: readonly [number, number, number], b: readonly [number, number, number]) =>
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
|
||||
const cx = dot(relEnu, right); // 카메라 좌우 (+= 오른쪽)
|
||||
const cy = dot(relEnu, up); // 카메라 상하 (+= 위)
|
||||
const cz = dot(relEnu, fwd); // 카메라 깊이 (+= 앞)
|
||||
|
||||
if (cz <= 0) return null; // 카메라 뒤 → 불가
|
||||
|
||||
const f = focalMm || 24;
|
||||
const px = 0.5 + (cx / cz) * (f / SENSOR_W_MM);
|
||||
const py = 0.5 - (cy / cz) * (f / SENSOR_H_MM);
|
||||
return { px, py, cx, cy, cz };
|
||||
}
|
||||
|
||||
/** 편의 래퍼: DroneFrame + POI + 공통 기준점 → 픽셀 */
|
||||
function project3D(
|
||||
drone: DroneFrame,
|
||||
poi: { lat: number; lon: number; z: number },
|
||||
yawOffset = 0,
|
||||
origin?: { lat: number; lon: number; alt: number },
|
||||
): { px: number; py: number; dist: number; h: number; v: number; inFov: boolean } | null {
|
||||
// 기준점: 지정하지 않으면 현재 드론 위치 사용 (기존 동작 유지)
|
||||
const ref = origin ?? { lat: drone.lat, lon: drone.lon, alt: drone.altitude };
|
||||
|
||||
const stEnu = geoToEnu(poi.lat, poi.lon, poi.z, ref.lat, ref.lon, ref.alt);
|
||||
const drEnu = geoToEnu(drone.lat, drone.lon, drone.altitude, ref.lat, ref.lon, ref.alt);
|
||||
const relEnu: [number, number, number] = [
|
||||
stEnu[0] - drEnu[0],
|
||||
stEnu[1] - drEnu[1],
|
||||
stEnu[2] - drEnu[2],
|
||||
];
|
||||
const dist = Math.sqrt(relEnu[0] ** 2 + relEnu[1] ** 2); // 수평 거리
|
||||
|
||||
const res = projectEnu(relEnu, drone.yaw, drone.pitch, drone.focalLen || 24, yawOffset);
|
||||
if (!res) return null;
|
||||
|
||||
const h = Math.atan2(res.cx, res.cz) * (180 / Math.PI);
|
||||
const v = Math.atan2(res.cy, res.cz) * (180 / Math.PI);
|
||||
const inFov = res.px >= 0 && res.px <= 1 && res.py >= 0 && res.py <= 1;
|
||||
return {
|
||||
px: Math.max(0, Math.min(1, res.px)),
|
||||
py: Math.max(0, Math.min(1, res.py)),
|
||||
dist, h, v, inFov,
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// CSV 파싱
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
function parseCsvLine(line: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (const ch of line) {
|
||||
if (ch === '"') { inQuotes = !inQuotes; }
|
||||
else if (ch === ',' && !inQuotes) { result.push(current.trim()); current = ''; }
|
||||
else { current += ch; }
|
||||
}
|
||||
result.push(current.trim());
|
||||
return result;
|
||||
}
|
||||
|
||||
function readCsvUtf8(filePath: string): string[][] {
|
||||
const raw = fs.readFileSync(filePath);
|
||||
// UTF-8 BOM 체크
|
||||
const hasBom = raw[0] === 0xEF && raw[1] === 0xBB && raw[2] === 0xBF;
|
||||
let text: string;
|
||||
if (hasBom) {
|
||||
text = raw.slice(3).toString('utf-8');
|
||||
} else {
|
||||
try {
|
||||
text = iconv.decode(raw, 'euc-kr');
|
||||
if (!/[가-힣]/.test(text)) text = raw.toString('utf-8');
|
||||
} catch {
|
||||
text = raw.toString('utf-8');
|
||||
}
|
||||
}
|
||||
// BOM 문자 제거 및 파싱
|
||||
return text.replace(/^\uFEFF/, '').split(/\r?\n/).filter(Boolean).map(parseCsvLine);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 데이터 로더 (싱글턴 캐시)
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
let _frames: DroneFrame[] | null = null;
|
||||
let _pois: GeoPoint[] | null = null;
|
||||
let _dataDir: string | null = null;
|
||||
let _terrainOffset: number | null = null; // abs_alt - rel_alt (지형 해발고도 m)
|
||||
|
||||
/**
|
||||
* SRT 파일에서 첫 프레임의 rel_alt(AGL)와 abs_alt(AMSL)를 읽어
|
||||
* terrain offset = abs_alt - rel_alt 를 반환.
|
||||
* 이 값을 드론 altitude(abs_alt)에서 빼면 AGL 고도가 됨.
|
||||
*/
|
||||
function loadTerrainOffset(dataDir: string): number {
|
||||
const srtFiles = fs.readdirSync(dataDir).filter(f => f.endsWith('.srt'));
|
||||
if (!srtFiles.length) return 0;
|
||||
const srtPath = path.join(dataDir, srtFiles[0]);
|
||||
const raw = fs.readFileSync(srtPath);
|
||||
const text = iconv.decode(raw, 'utf-8').slice(0, 2000); // 첫 부분만 읽기
|
||||
const relMatch = text.match(/rel_alt:\s*([\d.]+)/);
|
||||
const absMatch = text.match(/abs_alt:\s*([\d.]+)/);
|
||||
if (relMatch && absMatch) {
|
||||
const offset = parseFloat(absMatch[1]) - parseFloat(relMatch[1]);
|
||||
console.log(`[geo] terrain offset: ${offset.toFixed(2)}m (abs=${absMatch[1]}, rel=${relMatch[1]})`);
|
||||
return offset;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 선로 중심선 (center.csv, 224점)
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
export interface CenterlinePoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number; // 타원체고(h) — GPS 표고 (m), SRT abs_alt와 동일 기준
|
||||
}
|
||||
|
||||
let _centerline: CenterlinePoint[] | null = null;
|
||||
|
||||
function loadCenterline(): CenterlinePoint[] {
|
||||
if (_centerline) return _centerline;
|
||||
|
||||
const csvPath = process.env.CENTER_CSV_PATH ??
|
||||
path.resolve(__dirname, '../../../pythonsource/input/center.csv');
|
||||
|
||||
if (!fs.existsSync(csvPath)) {
|
||||
console.warn(`[geo] center.csv not found: ${csvPath}`);
|
||||
_centerline = [];
|
||||
return _centerline;
|
||||
}
|
||||
|
||||
const rows = readCsvUtf8(csvPath);
|
||||
if (rows.length < 2) { _centerline = []; return _centerline; }
|
||||
|
||||
// 컬럼 순서 (EUC-KR 헤더 파싱 우회, 인덱스 직접 사용):
|
||||
// 0=id, 1=lat, 2=lon, 3=표고(H), 4=지오이드높이(N), 5=타원체고(h), ..., 9=x(5186), 10=y(5186)
|
||||
_centerline = rows.slice(1).map(r => ({
|
||||
lat: parseFloat(r[1]),
|
||||
lon: parseFloat(r[2]),
|
||||
z: parseFloat(r[5]), // 타원체고(h)
|
||||
})).filter(p => !isNaN(p.lat) && !isNaN(p.lon) && !isNaN(p.z));
|
||||
|
||||
console.log(`[geo] Centerline: ${_centerline.length} points from ${path.basename(csvPath)}`);
|
||||
return _centerline;
|
||||
}
|
||||
|
||||
export function getCenterlinePoints(): CenterlinePoint[] {
|
||||
return loadCenterline();
|
||||
}
|
||||
|
||||
export function setGeoDataDir(dir: string) {
|
||||
_dataDir = dir;
|
||||
_frames = null;
|
||||
_pois = null;
|
||||
_terrainOffset = null;
|
||||
_centerline = null;
|
||||
}
|
||||
|
||||
function loadFrames(): DroneFrame[] {
|
||||
if (_frames) return _frames;
|
||||
if (!_dataDir) return [];
|
||||
|
||||
const csvPath = fs.readdirSync(_dataDir)
|
||||
.find(f => f.endsWith('.csv') && !fs.statSync(path.join(_dataDir!, f)).isDirectory()
|
||||
&& !path.join(_dataDir!, f).includes('building'));
|
||||
|
||||
// 드론 CSV는 data dir 바로 아래 (building 폴더 제외)
|
||||
const files = fs.readdirSync(_dataDir).filter(f =>
|
||||
f.endsWith('.csv') && f.includes('회덕') && !f.includes('POI') && !f.includes('측점')
|
||||
);
|
||||
if (!files.length) return [];
|
||||
|
||||
const rows = readCsvUtf8(path.join(_dataDir, files[0]));
|
||||
const header = rows[0].map(h => h.trim().replace(/^\uFEFF/, ''));
|
||||
const fi = (name: string) => header.indexOf(name);
|
||||
|
||||
_frames = rows.slice(1).map(r => ({
|
||||
frame: parseInt(r[fi('frame_cnt')], 10),
|
||||
lat: parseFloat(r[fi('latitude')]),
|
||||
lon: parseFloat(r[fi('longitude')]),
|
||||
altitude: parseFloat(r[fi('altitude')]),
|
||||
yaw: parseFloat(r[fi('yaw')]),
|
||||
pitch: parseFloat(r[fi('pitch')]),
|
||||
roll: parseFloat(r[fi('roll')]),
|
||||
focalLen: parseFloat(r[fi('focal_len')]),
|
||||
})).filter(f => !isNaN(f.lat));
|
||||
|
||||
return _frames;
|
||||
}
|
||||
|
||||
function loadPois(): GeoPoint[] {
|
||||
if (_pois) return _pois;
|
||||
if (!_dataDir) return [];
|
||||
|
||||
const buildingDir = path.join(_dataDir, 'building');
|
||||
if (!fs.existsSync(buildingDir)) return [];
|
||||
|
||||
const result: GeoPoint[] = [];
|
||||
|
||||
// POI 위경도 파일 (_타원체고 버전 우선)
|
||||
const allPoiFiles = fs.readdirSync(buildingDir).filter(f => f.includes('POI') && f.includes('위경도'));
|
||||
const poiFiles = allPoiFiles.filter(f => f.includes('타원체고')).length > 0
|
||||
? allPoiFiles.filter(f => f.includes('타원체고'))
|
||||
: allPoiFiles.filter(f => !f.includes('타원체고'));
|
||||
for (const f of poiFiles) {
|
||||
const rows = readCsvUtf8(path.join(buildingDir, f));
|
||||
const header = rows[0].map((h: string) => h.trim().replace(/^\uFEFF/, ''));
|
||||
const fi = (name: string) => header.indexOf(name);
|
||||
for (const r of rows.slice(1)) {
|
||||
const lat = parseFloat(r[fi('lat')]);
|
||||
const lon = parseFloat(r[fi('lon')]);
|
||||
if (isNaN(lat) || isNaN(lon)) continue;
|
||||
result.push({
|
||||
title: r[fi('title')] || '',
|
||||
category: r[fi('category_clean')] || '건물',
|
||||
lat, lon,
|
||||
z: parseFloat(r[fi('z')]) || 0,
|
||||
type: 'poi',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 측점 위경도 파일
|
||||
const stationFiles = fs.readdirSync(buildingDir).filter(f => f.includes('측점') && f.includes('위경도'));
|
||||
for (const f of stationFiles) {
|
||||
const rows = readCsvUtf8(path.join(buildingDir, f));
|
||||
const header = rows[0].map((h: string) => h.trim().replace(/^\uFEFF/, ''));
|
||||
const fi = (name: string) => header.indexOf(name);
|
||||
for (const r of rows.slice(1)) {
|
||||
const lat = parseFloat(r[fi('lat')]);
|
||||
const lon = parseFloat(r[fi('lon')]);
|
||||
if (isNaN(lat) || isNaN(lon)) continue;
|
||||
result.push({
|
||||
title: r[fi('title')] || '',
|
||||
category: '측점',
|
||||
lat, lon,
|
||||
z: parseFloat(r[fi('z')]) || 0,
|
||||
type: 'station',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_pois = result;
|
||||
return _pois;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 월드 좌표 원점: 첫 번째 측점
|
||||
//
|
||||
// 드론 frame[0]을 원점으로 쓰면 드론 GPS 오차가 그대로 반영됨.
|
||||
// 대신 첫 번째 측점(측량 기준점)을 ENU 원점으로 사용하면
|
||||
// 드론 카메라가 측점 좌표계에 맞춰 정렬됨.
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
function stationOrder(title: string): number {
|
||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
||||
if (!m) return 0;
|
||||
return parseInt(m[1]) * 1000 + parseInt(m[2]);
|
||||
}
|
||||
|
||||
/** 첫 번째 측점 위치를 ENU 원점으로 반환 */
|
||||
function getWorldOrigin(frames: DroneFrame[], pois: GeoPoint[]): { lat: number; lon: number; alt: number } {
|
||||
const stations = pois.filter(p => p.type === 'station').sort((a, b) => stationOrder(a.title) - stationOrder(b.title));
|
||||
if (stations.length) {
|
||||
return { lat: stations[0].lat, lon: stations[0].lon, alt: stations[0].z };
|
||||
}
|
||||
// 측점 없으면 frame[0] fallback
|
||||
return frames[0]
|
||||
? { lat: frames[0].lat, lon: frames[0].lon, alt: frames[0].altitude }
|
||||
: { lat: 0, lon: 0, alt: 0 };
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 핵심 API
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
const DEFAULT_FPS = 30;
|
||||
|
||||
/**
|
||||
* 건물/측점명 검색 → 매칭된 POI + 카메라 시야에 들어오는 프레임 목록
|
||||
* marginFactor: FOV 대비 허용 배수 (1.0 = 정확한 FOV 안, 1.5 = 50% 여유)
|
||||
*/
|
||||
export function findFramesForPoi(query: string, marginFactor = 1.0, maxDist = 2000, yawOffset = 0): {
|
||||
poi: GeoPoint | null;
|
||||
frames: FrameMatch[];
|
||||
} {
|
||||
const frames = loadFrames();
|
||||
const pois = loadPois();
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const poi = pois.find(p => p.title.toLowerCase().includes(q));
|
||||
if (!poi) return { poi: null, frames: [] };
|
||||
|
||||
const matches: FrameMatch[] = [];
|
||||
|
||||
// 첫 번째 측점을 ENU 원점으로 사용 (드론 카메라를 측점 좌표계에 정렬)
|
||||
const origin = getWorldOrigin(frames, pois);
|
||||
|
||||
for (const f of frames) {
|
||||
const res = project3D(f, poi, yawOffset, origin);
|
||||
if (!res) continue;
|
||||
if (res.dist > maxDist) continue;
|
||||
|
||||
// marginFactor: FOV 바깥까지 허용하는 배율
|
||||
const halfW = 0.5 * marginFactor;
|
||||
const halfH = 0.5 * marginFactor;
|
||||
const rawPx = 0.5 + (res.px - 0.5); // 이미 0~1
|
||||
const rawPy = 0.5 + (res.py - 0.5);
|
||||
if (Math.abs(rawPx - 0.5) > halfW || Math.abs(rawPy - 0.5) > halfH) continue;
|
||||
|
||||
matches.push({
|
||||
frame: f.frame,
|
||||
time: f.frame / DEFAULT_FPS,
|
||||
bearingDiff: res.h,
|
||||
elevationDiff: res.v,
|
||||
distance: res.dist,
|
||||
pixelX: res.px,
|
||||
pixelY: res.py,
|
||||
});
|
||||
}
|
||||
|
||||
// 연속 프레임 그룹화: 끊김 없이 이어지는 구간에서 가장 중심에 가까운 프레임 1개만 선택
|
||||
matches.sort((a, b) => a.frame - b.frame);
|
||||
|
||||
const GAP = 30; // 30프레임 이상 끊기면 새 구간
|
||||
const groups: FrameMatch[][] = [];
|
||||
let group: FrameMatch[] = [];
|
||||
|
||||
for (const m of matches) {
|
||||
if (group.length === 0 || m.frame - group[group.length - 1].frame <= GAP) {
|
||||
group.push(m);
|
||||
} else {
|
||||
groups.push(group);
|
||||
group = [m];
|
||||
}
|
||||
}
|
||||
if (group.length > 0) groups.push(group);
|
||||
|
||||
// 각 구간에서 가장 중심에 가까운 프레임 선택
|
||||
const best = groups.map(g => {
|
||||
// groupStart/groupEnd는 시간순 정렬 상태에서 먼저 기록
|
||||
const groupStart = g[0].frame;
|
||||
const groupEnd = g[g.length - 1].frame;
|
||||
g.sort((a, b) => (a.bearingDiff ** 2 + a.elevationDiff ** 2) - (b.bearingDiff ** 2 + b.elevationDiff ** 2));
|
||||
return { ...g[0], groupSize: g.length, groupStart, groupEnd };
|
||||
});
|
||||
|
||||
// 거리 가까운 순 정렬
|
||||
best.sort((a, b) => (a.bearingDiff ** 2 + a.elevationDiff ** 2) - (b.bearingDiff ** 2 + b.elevationDiff ** 2));
|
||||
|
||||
return { poi, frames: best as any };
|
||||
}
|
||||
|
||||
/**
|
||||
* 프레임 번호 → 해당 프레임에서 카메라 시야에 들어오는 POI/측점 목록
|
||||
*/
|
||||
export function findPoisForFrame(frameNum: number, marginFactor = 1.0, yawOffset = 0): {
|
||||
droneFrame: DroneFrame | null;
|
||||
pois: PoiInFrame[];
|
||||
} {
|
||||
const frames = loadFrames();
|
||||
const pois = loadPois();
|
||||
|
||||
// 정확히 일치하는 프레임 우선, 없으면 가장 가까운 frame_cnt로 fallback
|
||||
const drone = frames.find(f => f.frame === frameNum) ?? (() => {
|
||||
let best = frames[0];
|
||||
let bestD = Math.abs((best?.frame ?? 0) - frameNum);
|
||||
for (const f of frames) {
|
||||
const d = Math.abs(f.frame - frameNum);
|
||||
if (d < bestD) { bestD = d; best = f; }
|
||||
if (d === 0) break;
|
||||
}
|
||||
return best;
|
||||
})();
|
||||
if (!drone) return { droneFrame: null, pois: [] };
|
||||
|
||||
const result: PoiInFrame[] = [];
|
||||
|
||||
// 첫 번째 측점을 ENU 원점으로 사용
|
||||
const origin = getWorldOrigin(frames, pois);
|
||||
|
||||
for (const poi of pois) {
|
||||
const res = project3D(drone, poi, yawOffset, origin);
|
||||
if (!res) continue;
|
||||
|
||||
// marginFactor 1.0 = 정확한 FOV, 1.3 = 30% 여유
|
||||
const halfW = 0.5 * marginFactor;
|
||||
const halfH = 0.5 * marginFactor;
|
||||
if (Math.abs(res.px - 0.5) > halfW || Math.abs(res.py - 0.5) > halfH) continue;
|
||||
|
||||
result.push({ poi, bearingDiff: res.h, elevationDiff: res.v, distance: res.dist, pixelX: res.px, pixelY: res.py });
|
||||
}
|
||||
|
||||
result.sort((a, b) => a.distance - b.distance);
|
||||
return { droneFrame: drone, pois: result };
|
||||
}
|
||||
|
||||
/** POI 전체 목록 (검색/자동완성용) */
|
||||
export function getAllPois(): GeoPoint[] {
|
||||
return loadPois();
|
||||
}
|
||||
|
||||
/** 드론 비행경로 데이터 (경로 시각화용) */
|
||||
export function getDroneFrames(): DroneFrame[] {
|
||||
return loadFrames();
|
||||
}
|
||||
149
server/src/services/storage.ts
Normal file
149
server/src/services/storage.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import fs from 'fs';
|
||||
import { promises as fsp } from 'fs';
|
||||
import path from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { config } from '../config';
|
||||
import type { Annotation, CreateAnnotationInput, UpdateAnnotationInput } from '@abcvideo/shared';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
let db: Database.Database;
|
||||
|
||||
export function initDatabase(): void {
|
||||
db = new Database(config.dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
video_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('subtitle','memo')),
|
||||
time_start REAL NOT NULL,
|
||||
time_end REAL NOT NULL,
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
pos_x REAL NOT NULL DEFAULT 50,
|
||||
pos_y REAL NOT NULL DEFAULT 75,
|
||||
size_width REAL NOT NULL DEFAULT 30,
|
||||
size_height REAL NOT NULL DEFAULT 10,
|
||||
style_font_size INTEGER DEFAULT 16,
|
||||
style_color TEXT DEFAULT '#ffffff',
|
||||
style_bg_color TEXT DEFAULT 'rgba(0,0,0,0.7)',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_annotations_video ON annotations(video_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_annotations_time ON annotations(video_id, time_start);
|
||||
`);
|
||||
console.log('[db] SQLite initialized');
|
||||
}
|
||||
|
||||
export async function ensureStorageDirs(): Promise<void> {
|
||||
const dirs = [config.videosDir, config.hlsDir, config.framesDir, config.thumbnailsDir];
|
||||
for (const dir of dirs) {
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function rowToAnnotation(row: Record<string, unknown>): Annotation {
|
||||
return {
|
||||
id: row.id as string,
|
||||
videoId: row.video_id as string,
|
||||
type: row.type as 'subtitle' | 'memo',
|
||||
timeStart: row.time_start as number,
|
||||
timeEnd: row.time_end as number,
|
||||
text: row.text as string,
|
||||
position: { x: row.pos_x as number, y: row.pos_y as number },
|
||||
size: { width: row.size_width as number, height: row.size_height as number },
|
||||
style: {
|
||||
fontSize: row.style_font_size as number,
|
||||
color: row.style_color as string,
|
||||
backgroundColor: row.style_bg_color as string,
|
||||
},
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAnnotations(videoId: string): Annotation[] {
|
||||
const rows = db
|
||||
.prepare('SELECT * FROM annotations WHERE video_id = ? ORDER BY time_start')
|
||||
.all(videoId) as Record<string, unknown>[];
|
||||
return rows.map(rowToAnnotation);
|
||||
}
|
||||
|
||||
export function createAnnotation(input: CreateAnnotationInput): Annotation {
|
||||
const now = new Date().toISOString();
|
||||
const id = uuidv4();
|
||||
db.prepare(`
|
||||
INSERT INTO annotations
|
||||
(id, video_id, type, time_start, time_end, text, pos_x, pos_y,
|
||||
size_width, size_height, style_font_size, style_color, style_bg_color,
|
||||
created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
id, input.videoId, input.type,
|
||||
input.timeStart, input.timeEnd, input.text,
|
||||
input.position.x, input.position.y,
|
||||
input.size.width, input.size.height,
|
||||
input.style?.fontSize ?? 16,
|
||||
input.style?.color ?? '#ffffff',
|
||||
input.style?.backgroundColor ?? 'rgba(0,0,0,0.7)',
|
||||
now, now
|
||||
);
|
||||
return getAnnotation(id)!;
|
||||
}
|
||||
|
||||
export function getAnnotation(id: string): Annotation | null {
|
||||
const row = db.prepare('SELECT * FROM annotations WHERE id = ?').get(id) as Record<string, unknown> | undefined;
|
||||
return row ? rowToAnnotation(row) : null;
|
||||
}
|
||||
|
||||
export function updateAnnotation(id: string, input: UpdateAnnotationInput): Annotation | null {
|
||||
const existing = getAnnotation(id);
|
||||
if (!existing) return null;
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(`
|
||||
UPDATE annotations SET
|
||||
type = ?, time_start = ?, time_end = ?, text = ?,
|
||||
pos_x = ?, pos_y = ?, size_width = ?, size_height = ?,
|
||||
style_font_size = ?, style_color = ?, style_bg_color = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
input.type ?? existing.type,
|
||||
input.timeStart ?? existing.timeStart,
|
||||
input.timeEnd ?? existing.timeEnd,
|
||||
input.text ?? existing.text,
|
||||
input.position?.x ?? existing.position.x,
|
||||
input.position?.y ?? existing.position.y,
|
||||
input.size?.width ?? existing.size.width,
|
||||
input.size?.height ?? existing.size.height,
|
||||
input.style?.fontSize ?? existing.style.fontSize,
|
||||
input.style?.color ?? existing.style.color,
|
||||
input.style?.backgroundColor ?? existing.style.backgroundColor,
|
||||
now, id
|
||||
);
|
||||
return getAnnotation(id);
|
||||
}
|
||||
|
||||
export function deleteAnnotation(id: string): boolean {
|
||||
const result = db.prepare('DELETE FROM annotations WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export async function cleanupOldTempFiles(): Promise<void> {
|
||||
const tmpDir = path.join(config.videosDir, 'tmp');
|
||||
if (!fs.existsSync(tmpDir)) return;
|
||||
const maxAge = 24 * 60 * 60 * 1000;
|
||||
try {
|
||||
const entries = await fsp.readdir(tmpDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const filePath = path.join(tmpDir, entry.name);
|
||||
const stat = await fsp.stat(filePath);
|
||||
if (Date.now() - stat.mtimeMs > maxAge) {
|
||||
await fsp.rm(filePath, { recursive: true, force: true });
|
||||
console.log(`[cleanup] removed old temp: ${entry.name}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[cleanup] error:', err);
|
||||
}
|
||||
}
|
||||
78
server/src/services/streaming.ts
Normal file
78
server/src/services/streaming.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import fs from 'fs';
|
||||
import { promises as fsp } from 'fs';
|
||||
import path from 'path';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { Request, Response } from 'express';
|
||||
import { config } from '../config';
|
||||
|
||||
const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export function resolveVideoPath(videoId: string): string {
|
||||
const filePath = path.resolve(config.videosDir, videoId);
|
||||
// Path traversal guard
|
||||
if (!filePath.startsWith(path.resolve(config.videosDir))) {
|
||||
throw new Error('Invalid video path');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export async function streamVideo(req: Request, res: Response): Promise<void> {
|
||||
const { videoId } = req.params;
|
||||
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = resolveVideoPath(videoId);
|
||||
} catch {
|
||||
res.status(400).json({ error: 'Invalid video ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = await fsp.stat(filePath);
|
||||
} catch {
|
||||
res.status(404).json({ error: 'Video not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const fileSize = stat.size;
|
||||
const rangeHeader = req.headers.range;
|
||||
|
||||
if (!rangeHeader) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'video/mp4',
|
||||
'Content-Length': fileSize,
|
||||
'Accept-Ranges': 'bytes',
|
||||
});
|
||||
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
||||
await pipeline(stream, res);
|
||||
return;
|
||||
}
|
||||
|
||||
const [startStr, endStr] = rangeHeader.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(startStr, 10);
|
||||
const end = Math.min(
|
||||
endStr ? parseInt(endStr, 10) : start + CHUNK_SIZE - 1,
|
||||
fileSize - 1
|
||||
);
|
||||
|
||||
if (start > end || start >= fileSize) {
|
||||
res.writeHead(416, { 'Content-Range': `bytes */${fileSize}` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': end - start + 1,
|
||||
'Content-Type': 'video/mp4',
|
||||
});
|
||||
|
||||
const stream = fs.createReadStream(filePath, {
|
||||
start,
|
||||
end,
|
||||
highWaterMark: 1024 * 1024,
|
||||
});
|
||||
await pipeline(stream, res);
|
||||
}
|
||||
18
server/tsconfig.json
Normal file
18
server/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./dist",
|
||||
"rootDirs": ["./src", "../shared/src"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"@abcvideo/shared": ["../shared/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user