h-lab/docs/superpowers/specs/2026-06-12-subtitle-timeline-studio-design.md
hehihoho3@gmail.com fa7cec7f14 docs: add subtitle timeline studio design spec
File-upload + local faster-whisper synced transcription, segment editor,
SRT export, with speed/silence-removal phases. N150 CPU, small int8 model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:34:53 +09:00

8.1 KiB

자막 타임라인 스튜디오 — 설계 (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 유지.
  • ScriptResponseDtoList<Segment> 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일 때):

RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*

모델 캐시 영속화 — python/docker-compose.yml 서비스에 볼륨 추가(재빌드 시 모델 재다운로드 방지):

    environment:
      - HF_HOME=/models
    volumes:
      - ./models:/models

python/app/main.py 에 추가(기존 /transcript는 유지):

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<Segment>, double speed)(순수 함수).
  • 컨트롤러(ChannelVideoCurationController 영역):
    • POST /api/v1/channel-videos/{id}/transcribe (multipart file) → 세그먼트 응답.
    • GET /api/v1/channel-videos/{id}/script.srt?speed=1.0text/plain 다운로드.
    • 기존 /{id}/script 응답에 segments 포함.

5.3 프론트 변경 (rework.html)

  • "원본 스크립트" 카드에:
    • 영상 파일 업로드 입력. 선택 시 URL.createObjectURL로 좌측 <video> 재생(YouTube iframe 대체/병행) + 서버 /transcribe 호출.
    • 응답 세그먼트를 리스트로 렌더: 각 행 [mm:ss] 텍스트. 클릭 시 video.currentTime = start. timeupdate로 현재 세그먼트 하이라이트.
    • SRT 내보내기 버튼(+ 배속 입력, 기본 1.0) → script.srt?speed= 다운로드.
    • 기존 평문 textarea/복사 기능은 폴백으로 유지.
  • 업로드 파일은 Phase 1에서는 클라이언트(object URL) 재생 + 서버 전사용으로만 사용하고 서버 영속화는 하지 않음(Phase 3에서 영속화).

5.4 Phase 1 완료 기준

  • 영상 파일 업로드 → 세그먼트 추출 → [mm:ss] 텍스트 리스트 표시 → 재생 동기 하이라이트 → SRT 다운로드(CapCut import 가능)까지 동작.

6. Phase 2 — 배속

  • SRT 내보내기에 speed 적용: 모든 start/end/ speed. (예: 1.2x → 시간 0.833배)
  • 에디터에 배속 미리보기(선택). 저장 데이터는 원본 타임스탬프 유지, 내보내기 시점에만 변환.

7. Phase 3 — 무음 제거

  • 업로드 영상을 서버에 영속화(또는 재업로드) → Python /silence(ffmpeg silencedetect)로 무음 구간 목록 확보.
  • 무음 구간 제거 후 남는 구간을 이어붙이는 타임라인 재매핑 → 세그먼트 타임스탬프 보정 → SRT 반영.
  • 배속과 조합 가능(무음 제거 후 배속).

8. 범위 밖(YAGNI)

  • 가로 파형 타임라인 + 클립 드래그 같은 본격 CapCut형 UI(무거움). Phase 1은 "재생 동기 세그먼트 리스트"로 시작.
  • CapCut 네이티브 draft(.json) 직접 생성. 표준 SRT import로 충분.
  • 실제 영상 렌더링(배속/컷 적용된 mp4 출력)은 CapCut이 담당.
  • 플랫폼 업로드 API 연동.

9. 열린 항목

  • Python Dockerfile 베이스 이미지 확인(slim이면 ffmpeg apt 설치 필요, full이면 불필요).
  • small 한국어 품질이 부족하면 large-v3-turbo로 상향(속도 trade-off) — 환경변수 WHISPER_MODEL로 교체 가능하게 설계.