초기 커밋: DefVideo 소스 등록

abcVideo 플레이어 소스 (client / server / shared / pythonsource / docs / .claude).
.gitignore 적용으로 node_modules·storage·samplevideo·미디어 등 대용량 일괄 제외.
103 files, ~964K.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 03:20:27 +00:00
commit 82662d417d
103 changed files with 17213 additions and 0 deletions

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;