좌우 분할: 왼쪽=음성(Whisper 정밀+한국어), 오른쪽=화면(Gemini 한국어). 각각 SRT 다운로드. 음성 한국어는 기존 전사+LibreTranslate 재사용, 화면은 Gemini 신규(transient). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
119 lines
6.7 KiB
Markdown
119 lines
6.7 KiB
Markdown
# AI 자막 2종(음성/화면) → 한국어 SRT 설계
|
|
|
|
- 날짜: 2026-06-25
|
|
- 상태: 설계 확정(구현 대기)
|
|
- 관련: 제거된 화면 자막 OCR의 대체
|
|
|
|
## 1. 목적
|
|
|
|
재가공(`/rework/{id}`) 화면에서 한 영상의 자막을 **두 갈래로** 한국어 SRT까지 만든다:
|
|
|
|
- **왼쪽 = 음성 자막**: Whisper 전사(밀리초 정밀 타임스탬프) → 한국어 번역
|
|
- **오른쪽 = 화면 자막**: Gemini가 유튜브 URL을 보고 화면에 박힌 자막 추출 → 한국어 번역
|
|
|
|
둘을 **좌우 분할 화면으로 동시에 표시**하고, 각각 **SRT로 다운로드**한다.
|
|
|
|
배경: Tesseract OCR은 스타일 자막을 못 읽어 제거됨. 화면 자막은 Gemini가 잘 읽고, 음성은
|
|
이미 있는 Whisper가 정밀한 타임스탬프를 준다 → 각 강점을 살려 2종을 만든다.
|
|
|
|
### 타임스탬프 정밀도(중요)
|
|
- **음성(Whisper)**: 밀리초 정밀 → 거의 완벽.
|
|
- **화면(Gemini)**: 초당 1프레임 샘플링, MM:SS → **~1초 근사**(완벽 불가, 불가피).
|
|
|
|
## 2. 배경 / 현재 구조 (재사용 대상)
|
|
|
|
- `ChannelVideoScript`(videoId당 1건): Whisper 전사 결과(segments_json) 저장. 스크립트 리스트·SRT·번역이 공유.
|
|
- **음성 한국어 = 기존 자산 재사용**:
|
|
- 전사: `ChannelService.transcribeFromCached`(받은 원본 mp4 → Whisper).
|
|
- 번역(세그먼트별): `CurationService.translateScript(id, "ko", "segments")` → 세그먼트와 1:1 정렬된 한국어 텍스트 배열(`texts`). LibreTranslate `translateBatch` 사용.
|
|
- 즉 **왼쪽(음성)** = Whisper 세그먼트(타임코드) + 그 한국어 번역 배열.
|
|
- **화면 한국어 = 신규(Gemini)**: 유튜브 URL → Gemini → 한국어 세그먼트.
|
|
- SRT 생성기: 프론트 `buildSrt(segs, speed)` 재사용(세그먼트 → SRT 문자열).
|
|
- `ChannelVideo`는 `videoId`만 저장 → URL은 `watch?v={videoId}`.
|
|
|
|
## 3. 사용자 흐름
|
|
|
|
```
|
|
[재가공 /rework/{id}] "AI 자막(음성/화면)" 생성 클릭
|
|
├─(왼쪽: 음성) 저장된 Whisper 세그먼트 필요(없으면 '전사' 안내)
|
|
│ → translate(format=segments) 로 한국어 배열 → (세그먼트 타임코드 + 한국어) = 음성 한국어 세그먼트
|
|
│
|
|
└─(오른쪽: 화면) POST /{id}/gemini-subtitles
|
|
→ videoId→URL → Gemini REST(file_data + 프롬프트, responseMimeType=json)
|
|
→ 한국어 화면 세그먼트
|
|
▼
|
|
[화면] 좌우 분할: 왼쪽 음성 / 오른쪽 화면, 각각 [00:00] 한국어 리스트
|
|
▼
|
|
각 패널의 "SRT 다운로드" → 음성 SRT / 화면 SRT (둘 다 한국어)
|
|
```
|
|
|
|
## 4. 컴포넌트
|
|
|
|
### 4.1 `GeminiSubtitleService` (신규, `com.hlab.yanalyst.service`)
|
|
유튜브 URL을 Gemini REST로 보내 **화면 자막 → 한국어 세그먼트**를 받는다.
|
|
|
|
- `List<ScriptResponseDto.Segment> fetchKoreanScreenSubtitles(String videoId)`
|
|
1. URL: `https://www.youtube.com/watch?v={videoId}`.
|
|
2. `buildRequest(videoId, prompt)`: contents.parts=[{text:프롬프트}, {file_data:{file_uri:URL}}],
|
|
generationConfig.responseMimeType=`application/json`,
|
|
responseSchema=array(object{start:number(초), end:number(초), text:string(한국어)}).
|
|
프롬프트: "영상에 **화면으로 표시되는(박힌) 자막만** 추출해 한국어로 번역. 음성은 무시.
|
|
JSON 배열 [{start,end,text}] 로만 출력. start/end는 초(소수 허용), text는 한국어."
|
|
3. `POST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={apiKey}`
|
|
(`geminiRestTemplate`, read timeout 5분).
|
|
4. `candidates[0].content.parts[0].text` → JSON 파싱 → 세그먼트 리스트.
|
|
- 순수 메서드(HTTP 없음, **단위테스트**):
|
|
- `buildRequest(videoId, prompt)` → 요청 Map(parts/URL/responseMimeType 검증)
|
|
- `extractSegments(ObjectMapper, responseJson)` → `List<Segment>`
|
|
(candidates→parts→text의 JSON 배열 파싱, 코드펜스 ```json 방어 제거 후 파싱, 깨지면 빈 리스트)
|
|
- 설정: `@Value gemini.api-key`, `gemini.model`.
|
|
|
|
### 4.2 컨트롤러
|
|
- `POST /{id}/gemini-subtitles` → `curationService.geminiScreenSubtitles(id)`
|
|
→ `{ segments: [한국어 화면 세그먼트] }`. 빈 결과면 안내(영상에 화면 자막 없음/비공개).
|
|
- (음성 한국어는 **기존 `POST /{id}/translate?target=ko&format=segments`** 그대로 사용 — 신규 없음.)
|
|
|
|
### 4.3 `CurationService.geminiScreenSubtitles(Long id)`
|
|
- `find(id)` → `geminiSubtitleService.fetchKoreanScreenSubtitles(videoId)` → `{segments}`.
|
|
- **저장하지 않음(transient)** — 화면 자막은 표시·다운로드용. (음성은 기존 스크립트를 그대로 둠.)
|
|
|
|
### 4.4 `rework.html` — 좌우 분할 패널
|
|
- 새 섹션 "AI 자막 (음성 / 화면)" + 생성 버튼.
|
|
- 생성 시:
|
|
- 왼쪽(음성): 저장된 Whisper 세그먼트 있으면 `translate(format=segments)`로 한국어 배열 받아
|
|
(세그먼트 타임코드 + 한국어) 리스트 렌더. 없으면 "먼저 '전사' 필요" 안내.
|
|
- 오른쪽(화면): `gemini-subtitles` 호출 → 한국어 세그먼트 리스트 렌더.
|
|
- 각 패널 하단 **"SRT 다운로드"**(`buildSrt` 재사용; 파일명 `audio_ko_{id}.srt` / `screen_ko_{id}.srt`).
|
|
- 세그먼트 클릭 시 기존 `seekTo`로 왼쪽 플레이어 이동(선택, 음성 패널에 적용).
|
|
|
|
## 5. 설정 (`application.yml`)
|
|
|
|
```yaml
|
|
gemini:
|
|
api-key: ${GEMINI_API_KEY:} # AI Studio 키(무료 티어)
|
|
model: ${GEMINI_MODEL:gemini-2.5-flash} # 유튜브 URL 지원, 무료 티어
|
|
```
|
|
`RestTemplateConfig`에 `geminiRestTemplate` 빈 추가(read timeout 5분).
|
|
|
|
## 6. 에러 처리
|
|
|
|
- 키 없음/401 → "GEMINI_API_KEY 미설정/무효" 안내.
|
|
- 비공개·화면자막 없음·빈 결과 → 안내 메시지(오른쪽 패널만 영향).
|
|
- 429(무료 한도 초과) → 한도 안내.
|
|
- JSON 파싱 실패 → 코드펜스 제거 후 재시도, 실패 시 원문 일부 담은 예외.
|
|
- 음성 패널: 전사 세그먼트 없으면 "전사 먼저" 안내(번역 호출 안 함).
|
|
- 시크릿(키)은 로그/응답 비노출.
|
|
|
|
## 7. 범위 제외 (YAGNI)
|
|
|
|
- 화면 자막 영구 저장(이번엔 transient: 표시·다운로드만)
|
|
- 원본 언어 SRT(이번엔 둘 다 한국어)
|
|
- 음성 자동 전사 트리거(전사는 기존 버튼으로 선행; 이 기능은 있으면 번역만)
|
|
- 비동기 작업큐, 무료 티어 사용량 모니터링, 타임스탬프 정밀 보정
|
|
|
|
## 8. 테스트 (`src/test`)
|
|
|
|
- `GeminiSubtitleService.buildRequest`: parts에 프롬프트+file_data(youtube URL), responseMimeType=application/json.
|
|
- `extractSegments`: 정상 JSON 배열 파싱 / 코드펜스 감싼 응답 제거 후 파싱 / 깨진·빈 응답 → 빈 리스트.
|
|
- 실제 Gemini·Whisper 호출은 라이브 검증(짧은 공개 쇼츠: 음성/화면 각각 한국어·타임코드 확인).
|