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