diff --git a/docs/superpowers/specs/2026-06-25-gemini-url-subtitles-design.md b/docs/superpowers/specs/2026-06-25-gemini-url-subtitles-design.md index 15cc614..e7880ba 100644 --- a/docs/superpowers/specs/2026-06-25-gemini-url-subtitles-design.md +++ b/docs/superpowers/specs/2026-06-25-gemini-url-subtitles-design.md @@ -1,127 +1,118 @@ -# Gemini URL 자막 (유튜브 URL → 한국어 자막/SRT) 설계 +# AI 자막 2종(음성/화면) → 한국어 SRT 설계 - 날짜: 2026-06-25 - 상태: 설계 확정(구현 대기) -- 관련: 제거된 화면 자막 OCR(`revert(rework): 화면 자막 OCR 기능 제거`)의 대체 +- 관련: 제거된 화면 자막 OCR의 대체 ## 1. 목적 -재가공(`/rework/{id}`) 화면에서 **"Gemini 자막" 버튼 한 번**으로, 저장된 유튜브 URL을 Gemini에 -넘겨 **화면/음성 자막을 추출 + 한국어로 번역**해 `[00:00] 한국어` 세그먼트로 받아온다. 결과는 기존 -스크립트 자리에 저장되어 **"SRT 내보내기"가 곧 한국어 SRT**가 된다. +재가공(`/rework/{id}`) 화면에서 한 영상의 자막을 **두 갈래로** 한국어 SRT까지 만든다: -배경: Tesseract OCR은 실제 쇼츠의 스타일 자막을 거의 못 읽어 제거됨. Gemini는 화면에 박힌 -스타일 자막과 음성을 모두 읽고 번역까지 한 번에 처리한다(무료 티어로 충분). +- **왼쪽 = 음성 자막**: Whisper 전사(밀리초 정밀 타임스탬프) → 한국어 번역 +- **오른쪽 = 화면 자막**: Gemini가 유튜브 URL을 보고 화면에 박힌 자막 추출 → 한국어 번역 -## 2. 배경 / 현재 구조 +둘을 **좌우 분할 화면으로 동시에 표시**하고, 각각 **SRT로 다운로드**한다. -- 백엔드: Spring Boot 3.4 / Java 21. YouTube Data API·LibreTranslate를 h-lab이 직접 REST 호출하는 패턴. -- 스크립트는 `ChannelVideoScript`(videoId당 1건)에 `segments_json`+`transcript`+`language`로 저장. - 스크립트 리스트·SRT 내보내기·번역(LibreTranslate)이 이 세그먼트를 공유. -- `ChannelVideo`는 `videoId`만 저장 → URL은 `https://www.youtube.com/watch?v={videoId}`. -- 전사 저장 공통 메서드 `ChannelService.persistScript(video, id, dto)` 존재(전사가 사용) → 재사용. +배경: Tesseract OCR은 스타일 자막을 못 읽어 제거됨. 화면 자막은 Gemini가 잘 읽고, 음성은 +이미 있는 Whisper가 정밀한 타임스탬프를 준다 → 각 강점을 살려 2종을 만든다. -### Gemini 무료 티어 확인 (공식) -- 유튜브 URL 직접 입력 지원(preview, 무료). 무료 티어 **하루 8시간 분량**, 공개 영상만. -- Gemini 2.5+ 는 요청당 최대 10개 영상. -- 영상을 **초당 1프레임(1 FPS)** 으로 샘플링, 시각은 **MM:SS** → 타임스탬프는 **~1초 근사**(밀리초·프레임 정밀 X). -- 무료 티어는 **데이터가 제품 개선(학습)에 사용됨**(프라이버시 주의). +### 타임스탬프 정밀도(중요) +- **음성(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}] "Gemini 자막" 클릭 - │ POST /api/v1/channel-videos/{id}/gemini-subtitles +[재가공 /rework/{id}] "AI 자막(음성/화면)" 생성 클릭 + ├─(왼쪽: 음성) 저장된 Whisper 세그먼트 필요(없으면 '전사' 안내) + │ → translate(format=segments) 로 한국어 배열 → (세그먼트 타임코드 + 한국어) = 음성 한국어 세그먼트 + │ + └─(오른쪽: 화면) POST /{id}/gemini-subtitles + → videoId→URL → Gemini REST(file_data + 프롬프트, responseMimeType=json) + → 한국어 화면 세그먼트 ▼ -[Spring] videoId → youtube watch URL - │ Gemini REST 직접 호출(GEMINI_API_KEY) - │ parts: [ {text: 프롬프트}, {file_data:{file_uri: youtubeURL}} ] - │ generationConfig: responseMimeType=application/json (+ responseSchema) - │ 프롬프트: "이 영상의 화면/음성 자막을 모두 추출해 한국어로 번역. - │ JSON 배열 [{start, end, text}] 로만 출력(start/end=초, text=한국어)." +[화면] 좌우 분할: 왼쪽 음성 / 오른쪽 화면, 각각 [00:00] 한국어 리스트 ▼ -[Spring] candidates[0].content.parts[0].text(JSON) 파싱 → List - ▼ -[Spring] persistScript 로 저장(language=ko, transcript=세그먼트 이어붙임, 기존 스크립트 덮어씀) - ▼ -[화면] 세그먼트 리스트에 [00:00] 한국어 표시 - ▼ -"SRT 내보내기" → 한국어 SRT (세그먼트가 이미 한국어) +각 패널의 "SRT 다운로드" → 음성 SRT / 화면 SRT (둘 다 한국어) ``` ## 4. 컴포넌트 ### 4.1 `GeminiSubtitleService` (신규, `com.hlab.yanalyst.service`) -Gemini REST 호출과 응답 파싱 담당(YouTube API를 직접 호출하듯). +유튜브 URL을 Gemini REST로 보내 **화면 자막 → 한국어 세그먼트**를 받는다. -- `List fetchKoreanSubtitles(String videoId)` - 1. URL 구성: `https://www.youtube.com/watch?v={videoId}`. - 2. 요청 바디 조립(`buildRequest`): contents.parts = [프롬프트 text, file_data(file_uri=URL)], +- `List 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 of object{start:number, end:number, text:string}. + 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` 또는 기존 `pythonRestTemplate`처럼 read timeout 길게). - 4. 응답에서 `candidates[0].content.parts[0].text` 추출 → JSON 파싱 → 세그먼트 리스트. -- 순수 메서드(프로세스/HTTP 없음, **단위테스트 대상**): - - `buildRequest(model, videoId, prompt)` → 요청 Map(구조 검증용) - - `extractSegments(ObjectMapper, String responseJson)` → `List` - (candidates→parts→text 안의 JSON 배열 파싱, 코드펜스/잡텍스트 방어적으로 제거 후 파싱) -- 설정 주입: `@Value gemini.api-key`, `gemini.model`. + (`geminiRestTemplate`, read timeout 5분). + 4. `candidates[0].content.parts[0].text` → JSON 파싱 → 세그먼트 리스트. +- 순수 메서드(HTTP 없음, **단위테스트**): + - `buildRequest(videoId, prompt)` → 요청 Map(parts/URL/responseMimeType 검증) + - `extractSegments(ObjectMapper, responseJson)` → `List` + (candidates→parts→text의 JSON 배열 파싱, 코드펜스 ```json 방어 제거 후 파싱, 깨지면 빈 리스트) +- 설정: `@Value gemini.api-key`, `gemini.model`. -### 4.2 `ChannelService` 또는 `CurationService` 연결 -- `CurationService.geminiSubtitles(Long videoId)`: - - `find(videoId)` → `geminiSubtitleService.fetchKoreanSubtitles(videoId)` 로 세그먼트. - - 빈 결과면 `IllegalArgumentException("Gemini가 자막을 찾지 못했습니다(영상에 자막/음성이 없거나 비공개).")`. - - `ScriptResponseDto` 구성(language="ko", segments=결과, transcript=세그먼트 텍스트 이어붙임) - → `ChannelService.persistKoreanScript(...)`(또는 기존 저장 경로 재사용)로 저장. - - 응답 Map: `{hasScript, language:"ko", transcript, segments}`(전사 응답과 동일 형식 → 프론트 재사용). -- 저장은 전사와 동일하게 **기존 스크립트 덮어씀**(videoId당 1건 유지). -- 참고: 세그먼트 텍스트 이어붙이기 헬퍼가 필요하면 작은 순수 메서드로 둔다. +### 4.2 컨트롤러 +- `POST /{id}/gemini-subtitles` → `curationService.geminiScreenSubtitles(id)` + → `{ segments: [한국어 화면 세그먼트] }`. 빈 결과면 안내(영상에 화면 자막 없음/비공개). +- (음성 한국어는 **기존 `POST /{id}/translate?target=ko&format=segments`** 그대로 사용 — 신규 없음.) -### 4.3 컨트롤러 -- `POST /{id}/gemini-subtitles` → `curationService.geminiSubtitles(id)`. - - Swagger: 유튜브 URL을 Gemini로 보내 화면/음성 자막을 한국어로 추출·저장. 응답 {hasScript, language, transcript, segments}. +### 4.3 `CurationService.geminiScreenSubtitles(Long id)` +- `find(id)` → `geminiSubtitleService.fetchKoreanScreenSubtitles(videoId)` → `{segments}`. +- **저장하지 않음(transient)** — 화면 자막은 표시·다운로드용. (음성은 기존 스크립트를 그대로 둠.) -### 4.4 `rework.html` -- 툴바에 **"Gemini 자막"** 버튼(아이콘 `sparkles`/`wand`). -- 클릭 → 상태표시("Gemini 분석 중… 영상 길이에 따라 시간 걸림") → `POST /{id}/gemini-subtitles`. - 성공 시 기존 전사 성공 핸들러처럼 `renderSegments(s.segments)` + transcript 채움. -- 결과 세그먼트가 한국어 → 기존 **SRT 내보내기/세그먼트 클릭 seek/재작성 복사** 그대로 동작. +### 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 API 키(무료 티어) + api-key: ${GEMINI_API_KEY:} # AI Studio 키(무료 티어) model: ${GEMINI_MODEL:gemini-2.5-flash} # 유튜브 URL 지원, 무료 티어 ``` - -`geminiRestTemplate` 빈(긴 read timeout, 예: 5분) 추가 — 또는 기존 `pythonRestTemplate` 재사용. +`RestTemplateConfig`에 `geminiRestTemplate` 빈 추가(read timeout 5분). ## 6. 에러 처리 -- API 키 없음/401 → 명확한 메시지("GEMINI_API_KEY 미설정/유효하지 않음"). -- 비공개·자막없음·빈 결과 → 400 안내 메시지. -- 무료 티어 한도 초과(429) → 메시지에 한도 안내. -- JSON 파싱 실패(모델이 잡텍스트 반환) → 코드펜스 제거 후 재시도, 그래도 실패면 원문 일부를 담은 예외. -- 시크릿(키)은 로그/응답에 노출하지 않음. +- 키 없음/401 → "GEMINI_API_KEY 미설정/무효" 안내. +- 비공개·화면자막 없음·빈 결과 → 안내 메시지(오른쪽 패널만 영향). +- 429(무료 한도 초과) → 한도 안내. +- JSON 파싱 실패 → 코드펜스 제거 후 재시도, 실패 시 원문 일부 담은 예외. +- 음성 패널: 전사 세그먼트 없으면 "전사 먼저" 안내(번역 호출 안 함). +- 시크릿(키)은 로그/응답 비노출. -## 7. 출력/저장 정책 +## 7. 범위 제외 (YAGNI) -- Gemini 결과는 **한국어 세그먼트**로 스크립트에 저장(language=ko) → **"SRT 내보내기" = 한국어 SRT**. -- 기존 음성전사/URL자막/LibreTranslate(번역→재작성·한국어SRT) 경로는 **그대로 공존**(원본 기반 흐름). -- Gemini 경로는 "URL→한국어 자막 직통"의 별도 선택지. 마지막 실행 소스가 스크립트에 반영(덮어씀). +- 화면 자막 영구 저장(이번엔 transient: 표시·다운로드만) +- 원본 언어 SRT(이번엔 둘 다 한국어) +- 음성 자동 전사 트리거(전사는 기존 버튼으로 선행; 이 기능은 있으면 번역만) +- 비동기 작업큐, 무료 티어 사용량 모니터링, 타임스탬프 정밀 보정 ## 8. 테스트 (`src/test`) -순수 로직 위주(HTTP 없음): -- `buildRequest`: parts에 프롬프트+file_data(youtube URL) 포함, responseMimeType=application/json. -- `extractSegments`: 정상 응답(candidates→parts→text의 JSON 배열) 파싱, 코드펜스(```json) 감싼 경우 제거 후 파싱, 빈/깨진 응답 → 빈 리스트 또는 예외. -- 실제 Gemini 호출은 라이브 검증(짧은 공개 쇼츠로 세그먼트/한국어/타임코드 확인). - -## 9. 범위 제외 (YAGNI) - -- 원본+한국어 동시 저장(이번엔 한국어만) -- 비동기 작업큐/진행률 -- 무료 티어 사용량 모니터링·자동 백오프 -- 타임스탬프 정밀 보정(Gemini ~1초 근사 그대로 수용) +- `GeminiSubtitleService.buildRequest`: parts에 프롬프트+file_data(youtube URL), responseMimeType=application/json. +- `extractSegments`: 정상 JSON 배열 파싱 / 코드펜스 감싼 응답 제거 후 파싱 / 깨진·빈 응답 → 빈 리스트. +- 실제 Gemini·Whisper 호출은 라이브 검증(짧은 공개 쇼츠: 음성/화면 각각 한국어·타임코드 확인).