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

609
CLAUDE.md Normal file
View File

@@ -0,0 +1,609 @@
# CLAUDE.md — abcvideo 프로젝트
이 파일은 Claude Code가 abcvideo 프로젝트에서 작업할 때 참조하는 가이드입니다.
---
## 작업 완료 시 필수 — 히스토리 기록
작업(대화 세션)이 끝날 때 반드시 아래 형식으로 히스토리 파일을 작성한다.
```
파일 경로: docs/history/YYYY-MM-DD_{작업명}.md
```
필수 항목 (누락 시 Stop 훅이 응답을 차단함):
```markdown
**소요 시간**: X분
**Context 사용량**: input Xk / output Xk tokens
```
- 날짜는 오늘 날짜(YYYY-MM-DD), 작업명은 작업 내용을 한글 또는 영문으로 간략히
- 같은 날 여러 작업이면 각각 별도 파일 (예: `2026-04-01_StationOverlay-분석.md`)
- 소요 시간: 대화 시작부터 작업 완료까지의 실제 경과 시간
- Context 사용량: 이 대화의 토큰 사용량 (모를 경우 추정값 기재)
---
## 에이전트 협업 규칙
### 필수 파일
| 파일 | 용도 | 누가 관리 |
|------|------|----------|
| `CLAUDE.md` | 프로젝트 가이드 (아키텍처, 규칙, 기술 스택) | 사용자/아키텍트 |
| `PLAN.md` | 전체 구현 계획 + 각 단계별 세부 태스크 | 계획 수립 시 작성, 에이전트가 필요 시 업데이트 |
| `PROGRESS.md` | 각 단계의 진행 상태 + 완료/미완료/이슈 기록 | 작업하는 에이전트가 실시간 업데이트 |
### 에이전트 작업 시작 프로토콜
모든 에이전트는 작업 시작 시 반드시 아래 순서를 따른다:
```
1. CLAUDE.md 읽기 → 프로젝트 규칙/아키텍처 파악
2. PLAN.md 읽기 → 전체 계획에서 자신이 담당할 단계 확인
3. PROGRESS.md 읽기 → 이전 단계 완료 여부, 현재 상태, 알려진 이슈 확인
4. 작업 시작 전 PROGRESS.md에 자신의 단계를 "🔄 진행 중"으로 업데이트
5. 작업 완료 후 PROGRESS.md에 결과 기록 (완료 항목, 남은 이슈, 다음 단계 참고사항)
```
### PLAN.md 구조
```markdown
# PLAN.md
## 단계 N: 단계 이름
### 목표
### 세부 태스크
- [ ] 태스크 1
- [ ] 태스크 2
### 산출물 (완료 기준)
### 다음 단계 의존성
```
- 각 단계는 독립적으로 실행 가능한 단위로 분할
- 태스크는 체크박스(`- [ ]` / `- [x]`)로 관리
- 완료 기준(산출물)이 명확해야 다음 에이전트가 판단 가능
### PROGRESS.md 구조
```markdown
# PROGRESS.md
## 현재 상태 요약
- 마지막 완료 단계: N
- 현재 진행 단계: N+1
- 블로커: (있으면 기록)
## 단계별 진행 기록
### 단계 N: 단계 이름
- 상태: ✅ 완료 | 🔄 진행 중 | ⏳ 대기 | ❌ 실패
- 완료일: YYYY-MM-DD
- 완료 항목:
- 항목 1
- 항목 2
- 미완료/이슈:
- 이슈 설명
- 다음 단계 참고:
- 후속 에이전트가 알아야 할 사항
```
- 에이전트는 작업 중 **의미 있는 마일스톤마다** PROGRESS.md를 업데이트
- 실패 시 원인과 시도한 방법을 기록하여 다음 에이전트가 같은 실수를 반복하지 않도록 함
- 이전 에이전트가 남긴 "다음 단계 참고"를 반드시 확인 후 작업 시작
### 에이전트 간 인계 규칙
1. **이전 단계 산출물 검증**: 코드가 빌드/실행 가능한 상태인지 확인 후 작업 시작
2. **기존 코드 존중**: 이전 에이전트가 작성한 코드를 이유 없이 대폭 변경하지 않음
3. **충돌 방지**: 같은 파일을 동시에 수정하는 병렬 에이전트 실행 금지
4. **이슈 에스컬레이션**: 블로커 발견 시 PROGRESS.md에 기록하고 사용자에게 보고
---
## 프로젝트 개요
웹 브라우저(PC)에서 **2GB 이상의 대용량 동영상**을 안정적으로 재생하는 특화 기능 탑재 플레이어.
단순 재생을 넘어 **프레임 추출, 단위 이동, 텍스트 오버레이** 등 영상 분석/편집 보조 도구로 기능.
### 핵심 요구사항
1. 2GB 이상 동영상 안정 재생 (서버 파일 + 로컬 파일 모두)
2. 프레임 단위 추출 (정확도 우선)
3. 입력 단위 기준 앞뒤 이동 (프레임 / 초 / 장면)
4. 영상 위 텍스트 입력 (자막형 + 메모형 병행 지원)
5. 확장 가능한 플러그인 아키텍처
### 설계 결정 (2026-03-24 확정)
| 항목 | 결정 | 비고 |
|------|------|------|
| 사용자 모델 | **단일 사용자 도구** | 인증/세션/큐 불필요, 구현 단순화 |
| 로컬 파일 | **업로드 없이 직접 재생** | File API → `URL.createObjectURL()` |
| 프레임 정확도 | **표준 수준** | CFR: `1/fps` 산술, VFR: best-effort |
| HLS 변환 전 재생 | **원본 즉시 재생** | Range Request로 바로 재생, HLS는 백그라운드 변환 |
| 브라우저 지원 | **PC 웹 우선** | Chrome/Firefox/Edge, iOS Safari 무시 |
---
## 아키텍처 결정 (ADR)
### 재생 이중 경로
```
서버 파일: GET /api/stream/:videoId (Range Request) → 즉시 재생
POST /api/hls/:videoId/convert → 백그라운드 HLS 변환
HLS 준비 완료 시 → hls.js로 전환 (seek 안정성 향상)
로컬 파일: File API → URL.createObjectURL() → <video src> 직접 재생
프레임 추출 필요 시 → 서버 업로드 또는 Canvas 폴백
```
- 로컬 파일은 HLS 변환 없이 브라우저가 직접 재생
- 서버 파일은 Range Request로 즉시 재생 후, HLS 변환 완료 시 전환
- 2GB 이상 로컬 파일은 `URL.createObjectURL()`이 메모리 효율적 (Blob URL은 참조만 유지)
### 스트리밍 방식: HLS
- Progressive Download는 2GB+ seek 불안정으로 제외
- HLS 세그먼트 분할로 파일 크기 제한 완전 우회
- **세그먼트 크기: 6초** (분석 도구 특성상 seek 반응성 우선)
- **키프레임 간격: 2초** (`-force_key_frames "expr:gte(t,n_forced*2)"`)
- hls.js v1.6.15로 Chrome/Firefox/Edge 지원 (iOS Safari 네이티브는 비대상)
### hls.js 필수 설정
```javascript
const hls = new Hls({
maxBufferLength: 30, // 전방 버퍼: 30초
maxMaxBufferLength: 600, // 최대 10분
maxBufferSize: 60 * 1024 * 1024, // 60MB
backBufferLength: 30, // *** 필수: 재생 완료 버퍼 30초 후 해제 ***
// (기본값 Infinity → 장시간 재생 시 메모리 누수)
enableWorker: true, // Web Worker 트랜스먹싱
});
```
> `backBufferLength: 30` 미설정 시 2GB 영상 30분 재생 후 브라우저 크래시 위험.
### 플레이어: Video.js 8.23.x
- 플러그인 200개+ 생태계 → 확장성 우선
- `@videojs/http-streaming` 내장으로 HLS 기본 지원
- 전체화면 시 오버레이 유지를 위해 **컨테이너 전체를 fullscreen**으로 처리
- Video.js v10 (2026 중반 GA 예정)으로 향후 마이그레이션 가능하도록 플레이어 로직을 훅/서비스 레이어로 분리
### 프레임 추출: 서버 FFmpeg (child_process.spawn)
- Canvas API는 키프레임 의존으로 정확도 불충분
- ffmpeg.wasm은 ~30MB 로딩 + iOS 미지원 + VFR 불안정
- 서버 FFmpeg + `-accurate_seek` 플래그로 정확한 프레임 추출
- **fluent-ffmpeg 아카이브됨 (2025-05)** → `child_process.spawn()` + 얇은 TypeScript 래퍼 사용
- 로컬 파일 프레임 추출: Canvas API 즉시 미리보기 + 정확 추출 시 서버 업로드
### 백엔드: Node.js + Express
- 서버 영상 스트리밍 (Range Request)
- HLS 변환 API (FFmpeg child_process.spawn)
- 프레임 추출 API
- 대용량 업로드 (**@tus/server v2.3.0** — 자동 재개/재시도)
- 주석/메모 저장 (**better-sqlite3 v12.8.0**)
- FFmpeg 변환 진행률: **SSE (Server-Sent Events)**
---
## 기술 스택
### 프론트엔드
```
React 18 + TypeScript
Video.js 8.23.x + @videojs/http-streaming (HLS 내장)
hls.js 1.6.x (Video.js VHS 보완/대체 가능)
Tailwind CSS
Vite
Zustand (상태 관리)
interact.js (메모형 오버레이 드래그/리사이즈)
subsrt (자막 포맷 변환: SRT, VTT, ASS 등)
```
### 백엔드
```
Node.js 20+ LTS + Express
child_process.spawn (FFmpeg 래퍼 — fluent-ffmpeg 대체)
@tus/server 2.3.x + @tus/file-store (대용량 업로드)
better-sqlite3 12.8.x (주석 저장)
check-disk-space 3.4.x (디스크 모니터링)
```
### 인프라/개발 환경
```
FFmpeg 6.x+ (시스템 설치 필요 — PATH 등록)
Node.js 20+ LTS
```
---
## 프로젝트 구조
```
abcvideo/
├── client/ # React 프론트엔드
│ ├── src/
│ │ ├── components/
│ │ │ ├── player/ # Video.js 플레이어 컴포넌트
│ │ │ ├── overlay/ # 텍스트 오버레이 (자막형/메모형)
│ │ │ ├── controls/ # 커스텀 컨트롤 (단위 이동, 속도 등)
│ │ │ └── toolbar/ # 프레임 추출, 주석 관리 툴바
│ │ ├── hooks/
│ │ │ ├── useVideoPlayer.ts # Video.js 초기화/제어
│ │ │ ├── useFrameStep.ts # 프레임 단위 이동
│ │ │ ├── useAnnotations.ts # 주석 CRUD
│ │ │ └── useHls.ts # HLS 로드/에러 처리
│ │ ├── store/ # Zustand 스토어
│ │ ├── types/ # 공유 타입 정의
│ │ ├── utils/
│ │ │ ├── timecode.ts # 타임코드 변환 유틸
│ │ │ ├── vtt.ts # WebVTT 생성/파싱
│ │ │ └── frameCapture.ts # Canvas 프레임 캡처 (로컬 파일용)
│ │ └── App.tsx
│ ├── index.html
│ ├── vite.config.ts
│ └── tsconfig.json
├── server/ # Node.js 백엔드
│ ├── src/
│ │ ├── routes/
│ │ │ ├── stream.ts # Range Request 스트리밍
│ │ │ ├── hls.ts # HLS 변환/서빙
│ │ │ ├── frame.ts # 프레임 추출 API
│ │ │ ├── upload.ts # tus 업로드 연동
│ │ │ ├── local.ts # 로컬 파일 프록시 (선택)
│ │ │ └── annotations.ts # 주석 CRUD API
│ │ ├── services/
│ │ │ ├── ffmpeg.ts # FFmpeg spawn 래퍼 (HLS 변환, 프레임 추출)
│ │ │ ├── streaming.ts # Range Request 로직
│ │ │ └── storage.ts # 파일/DB 접근
│ │ ├── middleware/
│ │ │ ├── security.ts # Path traversal 방어, CORS
│ │ │ └── upload.ts # tus 서버 설정
│ │ ├── db/
│ │ │ └── schema.sql # SQLite 스키마
│ │ └── app.ts
│ └── tsconfig.json
├── shared/ # 클라이언트/서버 공유 타입
│ └── types.ts
├── storage/ # 런타임 생성 (gitignore)
│ ├── videos/ # 업로드된 원본 영상
│ ├── hls/ # HLS 세그먼트 (.m3u8, .ts)
│ ├── frames/ # 추출된 프레임 이미지
│ └── thumbnails/ # 탐색바 썸네일 스프라이트
├── CLAUDE.md # 프로젝트 가이드 (아키텍처, 규칙)
├── PLAN.md # 전체 구현 계획 + 단계별 세부 태스크
├── PROGRESS.md # 진행 상태 추적 (에이전트 실시간 업데이트)
├── README.md
└── package.json # 루트 (워크스페이스)
```
---
## API 설계
### 스트리밍
```
GET /api/stream/:videoId # Range Request 스트리밍 (원본 즉시 재생)
GET /api/hls/:videoId/index.m3u8 # HLS 플레이리스트
GET /api/hls/:videoId/:segment # HLS 세그먼트 (.ts)
POST /api/hls/:videoId/convert # HLS 변환 시작 (비동기)
GET /api/hls/:videoId/progress # 변환 진행 상태 (SSE)
```
### 프레임 추출
```
GET /api/frame/:videoId?time=00:01:30.000 # 특정 시간 프레임
GET /api/frame/:videoId?frame=1234 # 프레임 번호로 추출
GET /api/thumbnails/:videoId/sprite.jpg # 탐색바 썸네일 스프라이트
GET /api/thumbnails/:videoId/sprite.vtt # 썸네일 위치 VTT
```
### 업로드 (tus 프로토콜)
```
# tus 엔드포인트 — @tus/server가 자동 처리
POST /api/upload # 업로드 생성 (tus Creation)
PATCH /api/upload/:uploadId # 청크 전송 (tus 자동 재개)
HEAD /api/upload/:uploadId # 업로드 상태 조회
DELETE /api/upload/:uploadId # 업로드 취소
```
### 주석/메모
```
GET /api/annotations/:videoId # 영상별 주석 목록
POST /api/annotations/:videoId # 주석 추가
PUT /api/annotations/:videoId/:id # 주석 수정
DELETE /api/annotations/:videoId/:id # 주석 삭제
GET /api/annotations/:videoId/export?format=vtt|srt|json|csv # 내보내기
```
### 메타데이터
```
GET /api/meta/:videoId # 영상 메타데이터 (fps, duration, resolution 등)
GET /api/videos # 업로드된 영상 목록
DELETE /api/videos/:videoId # 영상 삭제 (원본 + HLS + 프레임 + 썸네일 일괄)
```
---
## 핵심 구현 규칙
### 대용량 파일 처리
- Range Request: `fs.createReadStream({ start, end, highWaterMark: 1024 * 1024 })` 사용
- 응답 시 반드시 `Accept-Ranges: bytes` 헤더 포함 (Safari seek 호환)
- `stream/promises``pipeline()` 사용 (에러 핸들링 + 백프레셔 자동 처리)
- 4GB 이상 파일: JavaScript `Number``2^53-1`까지 안전 → BigInt 불필요
- HLS 변환은 원본 파일을 직접 수정하지 않고 `storage/hls/{videoId}/`에 출력
- 소스가 이미 H.264인 경우 `-c copy`로 재인코딩 없이 세그먼트 분할만 수행 (수 초 내 완료)
### Range Request 구현 패턴
```typescript
// 핵심: 한 응답에 전체 파일을 보내지 않고 청크 크기 제한
const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB
const start = requestedStart;
const end = Math.min(requestedEnd || (start + CHUNK_SIZE - 1), fileSize - 1);
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': 'video/mp4',
});
```
### HLS 변환 FFmpeg 명령
```bash
# H.264 소스 → 재인코딩 없이 세그먼트 분할 (빠름)
ffmpeg -i input.mp4 -c copy \
-f hls -hls_time 6 -hls_list_size 0 \
-hls_segment_filename 'output/segment%03d.ts' \
-hls_playlist_type vod \
output/index.m3u8
# 비-H.264 소스 → 트랜스코딩 (느림)
ffmpeg -i input.mkv \
-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 6 -hls_list_size 0 \
-hls_segment_filename 'output/segment%03d.ts' \
-hls_playlist_type vod \
-force_key_frames "expr:gte(t,n_forced*2)" \
output/index.m3u8
```
### FFmpeg spawn 래퍼 패턴
```typescript
import { spawn } from 'child_process';
function runFFmpeg(args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
proc.stderr.on('data', (chunk) => {
stderr += chunk.toString();
// 진행률 파싱: "time=00:01:30.00" 패턴
});
proc.on('close', (code) =>
code === 0 ? resolve() : reject(new Error(`FFmpeg exit ${code}: ${stderr.slice(-500)}`))
);
});
}
```
### 프레임 정확도
- `requestVideoFrameCallback` API로 실제 FPS 동적 감지 (fallback: 30fps)
- 프레임 번호 → 시간 변환: `frameNumber / fps` (currentTime 누적 연산 금지 — 부동소수점 드리프트)
- 서버 FFmpeg 프레임 추출: `-accurate_seek -ss {time} -i {file} -frames:v 1` 순서 준수
- VFR 영상은 best-effort (FFprobe 메타데이터 기반 평균 FPS 사용)
### 타임코드 표현
- 내부 저장: **초 단위 float** (예: `90.033`)
- 표시: `HH:MM:SS.mmm` 형식
- VTT 내보내기: `HH:MM:SS.mmm --> HH:MM:SS.mmm` (점 사용)
- SRT 내보내기: `HH:MM:SS,mmm --> HH:MM:SS,mmm` (콤마 사용)
### 보안
- 서버 파일 경로: `path.resolve()` 정규화 후 허용 디렉토리(`storage/videos/`) 내부 검증 (Path Traversal 방어)
- 업로드 파일 검증: MIME 타입 체크 + **FFprobe로 실제 코덱/컨테이너 확인**
- 허용 MIME: `video/mp4`, `video/quicktime`, `video/x-matroska`, `video/webm`
- 업로드 크기 상한: 환경변수 `MAX_UPLOAD_SIZE` (기본 20GB)
- CORS 설정: 개발 시 `localhost:5173``localhost:3001` 허용
- `Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy` 헤더 (SharedArrayBuffer 대비)
### 전체화면
- `document.fullscreenElement``<video>`가 아닌 **플레이어 컨테이너 div**여야 오버레이 유지
- Video.js `fluid: true` 모드로 컨테이너 기준 비율 유지
- Safari webkit 접두사: `webkitRequestFullscreen` 분기 불필요 (PC 웹 대상)
### 오버레이 성능
- `timeupdate` 이벤트에서 직접 DOM 조작 금지 (~250ms 간격, 부정확)
- **`requestVideoFrameCallback`** 또는 `requestAnimationFrame` 루프로 현재 시간 읽고 표시할 주석 필터링
- 주석 배열은 `time` 기준 정렬 유지 → **이진 탐색 O(log n)** 조회
- 오버레이 요소에 `will-change: transform` 적용 (GPU 가속)
- 동시 표시 오버레이 50개 미만 유지 (DOM 레이아웃 스래싱 방지)
### 메모리 관리
- `URL.createObjectURL()` 사용 후 반드시 `URL.revokeObjectURL()` 호출
- Canvas 프레임 캡처 시 단일 Canvas 재사용 (매번 새로 생성 금지)
- hls.js `backBufferLength: 30` 필수 설정
- QuotaExceededError 발생 시: `abort()``remove(0, currentTime - 30)` → 재시도
---
## 자막형 vs 메모형 동작 정의
### 자막형 (Subtitle Mode)
- WebVTT `<track kind="subtitles">` 요소 사용
- 시작 시간 ~ 종료 시간 범위 표시
- 위치: 하단 고정 (WebVTT `position`, `line` 속성으로 조정 가능)
- 내보내기: `.vtt`, `.srt`
### 메모형 (Memo Mode)
- 커스텀 DOM 오버레이 레이어 + **interact.js** 드래그/리사이즈
- 특정 시점(포인트) + 표시 지속 시간 (기본 3초)
- 위치: 드래그로 자유 배치 (**x%, y% 정규화 좌표** — 해상도 독립적)
- 크기: width%, height% 정규화 (리사이즈 가능)
- 내보내기: `.json`, `.csv`
### 주석 데이터 모델 (SQLite)
```json
{
"id": "uuid",
"videoId": "string",
"type": "subtitle | memo",
"timeStart": 90.033,
"timeEnd": 95.500,
"position": { "x": 50.0, "y": 75.0 },
"size": { "width": 30.0, "height": 10.0 },
"text": "주석 내용",
"style": {
"fontSize": 16,
"color": "#ffffff",
"backgroundColor": "rgba(0,0,0,0.7)"
},
"createdAt": "ISO-8601",
"updatedAt": "ISO-8601"
}
```
---
## 키보드 단축키
| 키 | 동작 | 비고 |
|----|------|------|
| `Space` | 재생 / 일시정지 | 플레이어 포커스 시에만 |
| `←` / `→` | 5초 뒤로 / 앞으로 | |
| `J` / `L` | 10초 뒤로 / 앞으로 | |
| `,` / `.` | 이전 프레임 / 다음 프레임 | 일시정지 상태에서만 |
| `[` / `]` | 이전 장면 / 다음 장면 | |
| `F` | 전체화면 토글 | |
| `M` | 음소거 토글 | |
| `0`~`9` | 10% 단위 탐색 | |
| `Shift + S` | 현재 프레임 캡처 | |
| `Shift + M` | 현재 시간에 메모 추가 | |
| `+` / `-` | 재생 속도 증가 / 감소 | |
> 텍스트 입력 모드(메모 작성 중)에서는 모든 플레이어 단축키 비활성화.
---
## Video.js React 통합 패턴
```tsx
// ref + useEffect 패턴 (Video.js 공식 권장)
const videoRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<Player | null>(null);
useEffect(() => {
if (!playerRef.current) {
const videoElement = document.createElement('video-js');
videoElement.classList.add('vjs-big-play-centered');
videoRef.current!.appendChild(videoElement);
playerRef.current = videojs(videoElement, options, onReady);
}
}, [options]);
useEffect(() => {
return () => {
if (playerRef.current && !playerRef.current.isDisposed()) {
playerRef.current.dispose();
playerRef.current = null;
}
};
}, []);
return <div data-vjs-player ref={videoRef} />;
```
> `data-vjs-player` 래퍼로 Video.js 추가 DOM 래핑 방지.
> React 18 Strict Mode에서 `playerRef.current` 가드 필수 (이중 초기화 방지).
---
## 환경변수 (server/.env)
```env
PORT=3001
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 # 20GB (bytes)
FFMPEG_PATH=ffmpeg # 시스템 PATH에 등록된 경우 기본값
HLS_SEGMENT_TIME=6 # HLS 세그먼트 길이 (초)
THUMBNAIL_INTERVAL=10 # 썸네일 추출 간격 (초)
```
---
## 개발 단계 (에이전트 계획)
> 세부 태스크와 완료 기준은 `PLAN.md` 참조. 진행 상태는 `PROGRESS.md` 참조.
| 단계 | 내용 | 의존성 |
|------|------|--------|
| 1. 프로젝트 구조 | 모노레포 세팅, TypeScript 설정, 폴더 골격, 패키지 설치 | — |
| 2. 백엔드 서버 | Express + Range Request 스트리밍 + FFmpeg spawn 래퍼 + tus 업로드 + HLS 변환 + SSE 진행률 | FFmpeg 설치 |
| 3. 기본 플레이어 | Video.js + 이중 경로 재생 (로컬 File API + 서버 HLS) + 기본 UI | 단계 2 |
| 4. 프레임 추출 | Canvas 즉시 미리보기 + 서버 FFmpeg API (정확 추출) | 단계 2, 3 |
| 5. 단위 이동 | 프레임/초/장면 seek + requestVideoFrameCallback FPS 감지 + 키보드 단축키 | 단계 3, 4 |
| 6. 텍스트 오버레이 | 자막형(WebVTT track) + 메모형(interact.js DOM 오버레이) + 내보내기(subsrt) | 단계 3 |
| 7. UI/UX 통합 | 전체 통합, 반응형 레이아웃, 썸네일 미리보기, 디스크 정리 | 단계 2~6 |
```
단계 1 → 단계 2 → 단계 4 ──┐
└──→ 단계 3 → 단계 5 ──┤→ 단계 7
→ 단계 6 ──┘
※ 단계 5와 6은 병렬 진행 가능
```
---
## 파일 정리 정책
- 업로드 실패/중단 임시 파일: **24시간 후 자동 삭제** (서버 시작 시 + 주기적 스캔)
- 영상 삭제 시: 원본 + HLS 세그먼트 + 프레임 + 썸네일 + 주석 DB 레코드 일괄 삭제
- 디스크 잔여 공간 **10GB 미만 시 경고** (check-disk-space)
- 영상 1개당 예상 디스크 사용량: 원본 + HLS ≈ 원본 x 2 + 프레임/썸네일 ~50MB
---
## 알려진 제약 / 주의사항
- **VFR 영상**: 가변 프레임율 영상은 프레임 번호 계산 부정확 → FFprobe 평균 FPS 사용 (best-effort)
- **CORS + Canvas**: 크로스 오리진 영상에서 `toDataURL()` 보안 오류 → `crossorigin="anonymous"` 필수
- **HLS 변환 시간**: 2GB 영상 트랜스코딩 시 수 분~십수 분 소요 → SSE 진행률로 클라이언트 통보
- **H.264 소스 감지**: FFprobe로 코덱 확인 후 `-c copy` (재인코딩 불필요) vs 트랜스코딩 분기
- **WebVTT 리전**: Chrome/Edge에서 미지원 → 리전 기능 사용 금지, 기본 positioning만 사용
- **Video.js v10**: 2026 중반 GA 예정, 플러그인 호환 불확실 → 플레이어 로직을 훅으로 분리하여 마이그레이션 비용 최소화