fix(gemini): 화면 자막 타임스탬프 0/빈결과 문제 해결

원인: Gemini 2.5 Flash의 thinking(타임스탬프 추론)+JSON 출력이 자막 많은 영상에서
출력 토큰을 초과해 JSON이 잘림 → 0개 또는 타임스탬프 전부 0.

- maxOutputTokens 65536(최대)로 상향 — thinking+JSON 잘림 방지(핵심)
- temperature 0 — 일관성
- 프롬프트: 시작/끝 시각 정확히, 전부 0 금지, 누락 없이 추출하도록 보강
- 빈 결과면 최대 3회 재시도(영상이해 비결정성 보완)

검증: 470·560(자막 많은 영상) 모두 진행형 타임스탬프로 정상 추출.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-25 18:43:47 +09:00
parent 99dbe80fdb
commit b151204267

View File

@ -28,10 +28,12 @@ import java.util.Map;
public class GeminiSubtitleService { public class GeminiSubtitleService {
private static final String PROMPT = private static final String PROMPT =
"유튜브 영상에서 화면에 표시되는(영상에 박힌) 자막만 추출해 한국어로 번역하세요. " "영상을 처음부터 끝까지 분석해서, 화면에 표시되는(영상에 박힌) 모든 자막을 빠짐없이 추출하세요. "
+ "음성/말소리는 무시하고 화면의 글자만 대상으로 합니다. " + "음성/말소리는 무시하고 화면의 글자만 대상으로 합니다. "
+ "각 자막이 화면에 나타나는 시작 시각과 사라지는 끝 시각을 '영상 시작 기준 초'로 최대한 정확히 적으세요(예: 0, 2.5, 8.1). "
+ "중요: 모든 항목의 시간을 0으로 두지 마세요. 자막이 바뀌는 순서대로 start 가 증가해야 합니다. "
+ "JSON 배열로만 출력: [{\"start\": 시작초, \"end\": 끝초, \"text\": \"한국어 번역\"}]. " + "JSON 배열로만 출력: [{\"start\": 시작초, \"end\": 끝초, \"text\": \"한국어 번역\"}]. "
+ "start/end 는 영상 시작 기준 초(소수 가능), text 는 한국어. 화면 자막이 없으면 빈 배열 []."; + "text 는 한국어 번역. 화면 자막이 전혀 없으면 빈 배열 [].";
private final RestTemplate geminiRestTemplate; private final RestTemplate geminiRestTemplate;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@ -42,8 +44,21 @@ public class GeminiSubtitleService {
@Value("${gemini.model:gemini-2.5-flash}") @Value("${gemini.model:gemini-2.5-flash}")
private String model; private String model;
/** 유튜브 영상의 화면 자막을 한국어 세그먼트로 반환. */ /**
* 유튜브 영상의 화면 자막을 한국어 세그먼트로 반환.
* Gemini 영상이해는 비결정적이라 결과가 나올 있어, 0개면 최대 3회 재시도한다(에러는 즉시 전파).
*/
public List<ScriptResponseDto.Segment> fetchKoreanScreenSubtitles(String videoId) { public List<ScriptResponseDto.Segment> fetchKoreanScreenSubtitles(String videoId) {
for (int attempt = 1; attempt <= 3; attempt++) {
List<ScriptResponseDto.Segment> segs = callGeminiOnce(videoId);
if (!segs.isEmpty()) return segs;
log.info("Gemini 화면자막 0개(video {}, {}/3) — 재시도", videoId, attempt);
}
return List.of();
}
/** Gemini 단일 호출(화면 자막 → 한국어 세그먼트). HTTP/키 오류는 예외로 전파, 정상이면 세그먼트(빈 가능). */
private List<ScriptResponseDto.Segment> callGeminiOnce(String videoId) {
String key = sanitizeApiKey(apiKey); String key = sanitizeApiKey(apiKey);
if (key.isEmpty()) { if (key.isEmpty()) {
throw new IllegalArgumentException("GEMINI_API_KEY 가 설정되지 않았습니다."); throw new IllegalArgumentException("GEMINI_API_KEY 가 설정되지 않았습니다.");
@ -110,8 +125,12 @@ public class GeminiSubtitleService {
"start", Map.of("type", "NUMBER"), "start", Map.of("type", "NUMBER"),
"end", Map.of("type", "NUMBER"), "end", Map.of("type", "NUMBER"),
"text", Map.of("type", "STRING"))); "text", Map.of("type", "STRING")));
// Gemini 2.5 Flash 'thinking' 모델: 정확한 타임스탬프는 thinking 으로 추론한다(끄면 시간이 전부 0).
// thinking 토큰이 출력 한도를 먹어 JSON 잘리면 0개가 되므로 maxOutputTokens 크게 준다.
Map<String, Object> genConfig = Map.of( Map<String, Object> genConfig = Map.of(
"responseMimeType", "application/json", "responseMimeType", "application/json",
"temperature", 0,
"maxOutputTokens", 65536,
"responseSchema", Map.of("type", "ARRAY", "items", itemSchema)); "responseSchema", Map.of("type", "ARRAY", "items", itemSchema));
return Map.of("contents", List.of(content), "generationConfig", genConfig); return Map.of("contents", List.of(content), "generationConfig", genConfig);