초기 커밋: 스테이션(측점) 기반 주행영상 플레이어

- 클라이언트(React/Vite): Video.js 플레이어, 하단 스테이션바, POI/구조물 영상 오버레이, 카메라 파라미터 보정, 미니맵/RoutePanel
- 서버(Express): Range 스트리밍, HLS 변환, 프레임 추출, tus 업로드, 주석 API
- 최근 작업: 스테이션바 종점역 표출/양끝 정렬/미도착(재생불가) 표시, POI 라벨 '구분' 우선·컴팩트 팝업·겹침제외 토글·동일좌표 다중행, 진행방향(상/하) 우선 표출, 커서 배지 크기·픽셀 떨림 개선

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:37:57 +09:00
co-authored by Claude Opus 4.8
commit 78fc090360
214 changed files with 27897 additions and 0 deletions
+103
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;
+69
View File
@@ -0,0 +1,69 @@
import { Router } from 'express';
const router = Router();
/**
* DEM 표고 프록시 — 브라우저 COEP(require-corp)/CSP(connect-src 'self') 때문에
* 클라이언트가 외부 고도 API를 직접 호출하지 못한다. 서버가 중계해 같은 출처로 응답.
*
* GET /api/elevation?lat=36.4,36.5&lon=127.4,127.5 (배치, 콤마 구분)
* 응답: { elevation: number[] } (입력 순서 보존)
*
* 1순위 open-topodata SRTM 30m(절벽/지형 더 정밀), 실패 시 open-meteo 90m 폴백.
*/
router.get('/', async (req, res) => {
const latStr = String(req.query.lat ?? req.query.latitude ?? '');
const lonStr = String(req.query.lon ?? req.query.longitude ?? '');
if (!latStr || !lonStr) {
res.status(400).json({ error: 'lat/lon required' });
return;
}
const lats = latStr.split(',');
const lons = lonStr.split(',');
if (lats.length !== lons.length) {
res.status(400).json({ error: 'lat/lon length mismatch' });
return;
}
// 1) open-topodata SRTM 30m
try {
const locs = lats.map((la, i) => `${la.trim()},${lons[i].trim()}`).join('|');
const r = await fetch(
'https://api.opentopodata.org/v1/srtm30m?locations=' + encodeURIComponent(locs),
);
if (r.ok) {
const j: any = await r.json();
if (Array.isArray(j?.results)) {
const elevation = j.results.map((x: any) =>
typeof x?.elevation === 'number' ? x.elevation : null,
);
if (elevation.some((v: number | null) => v != null)) {
res.json({ elevation, source: 'opentopodata-srtm30m' });
return;
}
}
}
} catch {
/* 폴백으로 진행 */
}
// 2) open-meteo 90m 폴백
try {
const r = await fetch(
'https://api.open-meteo.com/v1/elevation?latitude=' +
encodeURIComponent(latStr) +
'&longitude=' +
encodeURIComponent(lonStr),
);
if (!r.ok) {
res.status(502).json({ error: 'upstream ' + r.status });
return;
}
const j: any = await r.json();
res.json({ elevation: j?.elevation ?? [], source: 'open-meteo-90m' });
} catch (e) {
res.status(502).json({ error: String(e) });
}
});
export default router;
+57
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
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;
+182
View File
@@ -0,0 +1,182 @@
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(/\.[^.]+$/, ''));
// 이미 디스크에 HLS가 생성돼 있으면 재인코딩하지 않고 즉시 완료 처리 (멱등)
// jobs는 메모리라 서버 재시작 후 idle이 되지만, 산출물은 디스크에 남아있음
if (fs.existsSync(path.join(outputDir, 'index.m3u8'))) {
jobs.set(videoId, { status: 'done', percent: 100 });
res.json({ status: 'done', message: 'Already converted (on disk)' });
return;
}
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);
if (job) {
res.json(job);
return;
}
// 메모리에 작업 기록이 없어도 디스크에 산출물이 있으면 done
const m3u8Path = path.join(config.hlsDir, videoId.replace(/\.[^.]+$/, ''), 'index.m3u8');
if (fs.existsSync(m3u8Path)) {
res.json({ status: 'done' as HlsConversionStatus, percent: 100 });
return;
}
res.json({ status: 'idle' as HlsConversionStatus, percent: 0 });
});
// 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
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;
+16
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
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;