초기 커밋: 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:
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;
|
||||
Reference in New Issue
Block a user