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:
minsung
2026-04-01 15:11:39 +09:00
commit 2aae3d1c0d
89 changed files with 15739 additions and 0 deletions

10
server/.env.example Normal file
View 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
View 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
View 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
View 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),
};

View 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();
}

View 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;

View 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
View 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
View 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
View 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;

View 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
View 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;

View 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;
}
}

View 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();
}

View 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);
}
}

View 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
View 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"]
}