# 자막 타임라인 스튜디오 — 설계 (Subtitle Timeline Studio) 작성일: 2026-06-12 대상: 재가공 에디터(`/rework/{id}`)의 스크립트 추출 정교화 ## 1. 목표 (사용자 의도) 재가공 에디터에서 영상의 텍스트를 **타임스탬프 동기**로 추출하고, CapCut으로 가져가 편집할 수 있게 한다. 구체적으로: 1. 영상에 있는 텍스트를 (후처리·수정 없이) **그대로**, 영상과 **싱크가 맞게** 추출 2. CapCut 화면처럼 자막을 보여주기 3. 파일로 내보내(SRT) CapCut에 import해서 수정 가능 4. 전체 배속(예: 1.2x) 적용 시 자막 타임스탬프도 같이 배속 보정 5. 무음 구간 제거 시 자막 타임스탬프도 같이 보정 ## 2. 핵심 제약과 결정 - **mp4 파일 자체에는 텍스트가 없다** → 텍스트 확보는 (a) YouTube 자막 읽기(URL 필요, 자막 있을 때만) 또는 (b) 음성인식(Whisper) 중 하나. 사용자는 **파일 업로드 기반**을 원함 → **(b) Whisper** 채택. - **비용 0 요구** → API가 아닌 **로컬 `faster-whisper`** 사용. - **서버 사양**: Intel N150(4코어), RAM 15GB, GPU 없음, Docker 구동. Java(`springboot/`)와 Python(`python/`)이 **동일 우분투 호스트**에 공존. - **타겟이 Shorts(≤60초)** → N150 CPU + `small`(int8) 모델로 1개당 약 20~60초 전사. 인터랙티브하게 수용 가능. - 무음 제거(Phase 3)는 실제 오디오가 필요 → 업로드한 파일을 서버에서 ffmpeg로 처리. ## 3. 역할 분담 연산은 우분투(Python)에 집중, Java는 오케스트레이션·저장·내보내기. - **h-python (`python/app/main.py`, Docker)**: 무거운 작업. - 신설 `POST /transcribe` — 업로드된 영상 파일 → `faster-whisper` → 세그먼트 반환. - 기존 `POST /transcript`(URL 자막, 평문) 은 그대로 유지. - (Phase 3) `POST /silence` — ffmpeg `silencedetect`로 무음 구간 반환. - **Java (이 저장소, Spring Boot)**: 에디터 UI + 오케스트레이션. - 파일 업로드 수신 → Python `/transcribe` 호출 → 세그먼트를 DB 저장. - SRT 생성·다운로드(순수 계산, 배속 파라미터 반영). - **프론트(`rework.html`)**: 업로드 영상 로컬 재생 + 세그먼트 리스트(재생 동기) + SRT 내보내기. ## 4. 데이터 모델 세그먼트 단위 = `{start: 초, end: 초, text: 문자열}`. - `ChannelVideoScript`에 컬럼 추가: - `segments_json TEXT` — 세그먼트 배열 JSON 직렬화. (ddl-auto:update가 자동 생성) - 기존 `transcript`(평문, 세그먼트 텍스트를 공백 조인) 유지 → 기존 평문 UI 폴백. - 기존 `language` 유지. - `ScriptResponseDto`에 `List segments` 추가 (`Segment{double start; double end; String text;}`). ## 5. Phase 1 — 동기 추출 + 세그먼트 에디터 + SRT 내보내기 (코어) ### 5.1 h-python 변경 (사용자가 적용) `python/app/requirements.txt` 에 추가: ``` faster-whisper ``` `python/Dockerfile` — 오디오 디코드/Phase 3 대비 ffmpeg 설치(베이스가 slim일 때): ```dockerfile RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* ``` 모델 캐시 영속화 — `python/docker-compose.yml` 서비스에 볼륨 추가(재빌드 시 모델 재다운로드 방지): ```yaml environment: - HF_HOME=/models volumes: - ./models:/models ``` `python/app/main.py` 에 추가(기존 `/transcript`는 유지): ```python import os, tempfile from fastapi import UploadFile, File, Form from faster_whisper import WhisperModel # 모델 1회 로드(지연 싱글턴). N150 CPU → small/int8. _model = None def get_model(): global _model if _model is None: _model = WhisperModel( os.getenv("WHISPER_MODEL", "small"), device="cpu", compute_type="int8", ) return _model @app.post("/transcribe") async def transcribe(file: UploadFile = File(...), language: str | None = Form(None)): # 업로드 파일을 임시 저장 suffix = os.path.splitext(file.filename or "")[1] or ".mp4" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: tmp.write(await file.read()) path = tmp.name try: segments, info = get_model().transcribe( path, language=language, # None=자동 감지 vad_filter=True, # 무음 기반 구간화로 타임스탬프 품질↑ beam_size=5, ) seg_list, texts = [], [] for s in segments: # 제너레이터 → 순회 시 실제 연산 seg_list.append({ "start": round(s.start, 3), "end": round(s.end, 3), "text": s.text.strip(), }) texts.append(s.text.strip()) return { "language": info.language, "duration": round(info.duration, 3), "segments": seg_list, "transcript": " ".join(texts), } finally: os.remove(path) ``` > 동시 요청은 N150에서 CPU 경합이 크므로 1인 사용 전제(직렬 처리). 필요 시 향후 락/큐 추가. ### 5.2 Java 변경 (이 저장소) - `ScriptResponseDto`: `segments` 필드 추가 + `Segment` 중첩 DTO. - `ChannelVideoScript`: `segmentsJson` 컬럼 추가(+getter/setter). - `ChannelService`: - 신설 `transcribeFromFile(Long channelVideoId, MultipartFile file)` → Python `/transcribe` 멀티파트 호출 → 세그먼트 JSON 저장 + `transcript` 평문 저장 + `hasScript=true`. 재추출 시 기존 1건 유지(현행 정책 동일). - SRT 생성 유틸 `buildSrt(List, double speed)`(순수 함수). - 컨트롤러(`ChannelVideoCurationController` 영역): - `POST /api/v1/channel-videos/{id}/transcribe` (multipart `file`) → 세그먼트 응답. - `GET /api/v1/channel-videos/{id}/script.srt?speed=1.0` → `text/plain` 다운로드. - 기존 `/{id}/script` 응답에 `segments` 포함. ### 5.3 프론트 변경 (`rework.html`) - "원본 스크립트" 카드에: - **영상 파일 업로드** 입력. 선택 시 `URL.createObjectURL`로 좌측 `