Files
GhiVideo_v4/server/src/routes/annotations.ts
T
b23042andClaude Fable 5 d38b842e8d GhiVideo 소스 복제 — v4 작업 시작 기준
기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제.
(node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:32:51 +09:00

104 lines
3.7 KiB
TypeScript

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;