package com.hlab.yanalyst.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpStatusCodeException; import org.springframework.web.client.RestTemplate; import java.util.List; import java.util.Map; /** * Gemini(무료 티어)로 유튜브 URL을 직접 보내 화면에 박힌 자막을 추출·한국어 번역해 세그먼트로 받는다. * 음성은 Whisper(정밀 타임스탬프)가 담당하므로 여기선 화면 자막만 다룬다. * 타임스탬프는 Gemini 특성상 ~1초 근사. */ @Slf4j @Service @RequiredArgsConstructor public class GeminiSubtitleService { private static final String PROMPT = "이 영상을 처음부터 끝까지 분석해서, 화면에 표시되는(영상에 박힌) 모든 자막을 빠짐없이 추출하세요. " + "음성/말소리는 무시하고 화면의 글자만 대상으로 합니다. " + "각 자막이 화면에 나타나는 시작 시각과 사라지는 끝 시각을 '영상 시작 기준 초'로 최대한 정확히 적으세요(예: 0, 2.5, 8.1). " + "중요: 모든 항목의 시간을 0으로 두지 마세요. 자막이 바뀌는 순서대로 start 가 증가해야 합니다. " + "각 자막의 화면 원문(그대로)과 한국어 번역을 함께 주세요. " + "JSON 배열로만 출력: [{\"start\": 시작초, \"end\": 끝초, \"original\": \"화면 원문 그대로\", \"text\": \"한국어 번역\"}]. " + "original 은 화면에 보이는 원문 그대로(번역하지 말 것). " + "text 는 그 원문을 반드시 '한국어'로 번역한 결과입니다 — 영어가 아니라 한국어로 쓰세요. " + "화면 자막이 전혀 없으면 빈 배열 []."; private final RestTemplate geminiRestTemplate; private final ObjectMapper objectMapper; @Value("${gemini.api-key:}") private String apiKey; @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 가 설정되지 않았습니다."); } String videoUrl = "https://www.youtube.com/watch?v=" + videoId; String apiUrl = "https://generativelanguage.googleapis.com/v1beta/models/" + model + ":generateContent?key=" + key; log.info("Requesting Gemini screen-subtitles for video {} (model={})", videoId, model); try { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> request = new HttpEntity<>(buildRequest(videoUrl, PROMPT), headers); ResponseEntity response = geminiRestTemplate.postForEntity(apiUrl, request, String.class); if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { throw new RuntimeException("Gemini API 실패: " + response.getStatusCode()); } List segs = extractSegments(objectMapper, response.getBody()); log.info("Gemini screen-subtitles for video {}: {} segments", videoId, segs.size()); return segs; } catch (HttpStatusCodeException e) { // 키는 URL 쿼리에 있으므로 메시지에 apiUrl 을 넣지 않는다(노출 방지). int code = e.getStatusCode().value(); String body = e.getResponseBodyAsString(); String msg; if (code == 403) { msg = "Gemini 접근 거부(403): 이 API 키의 프로젝트가 차단되었습니다. 다른 프로젝트의 새 API 키가 필요합니다."; } else if (code == 429) { msg = "Gemini 무료 한도 초과(429): 잠시 후 다시 시도하세요."; } else if (code == 400 && body.contains("API key not valid")) { msg = "Gemini API 키가 유효하지 않습니다(GEMINI_API_KEY 값을 확인하세요)."; } else { String tail = body.length() > 200 ? body.substring(0, 200) : body; msg = "Gemini 호출 실패(" + code + "): " + tail; } log.error("Gemini 호출 실패({}) for video {}", code, videoId); throw new IllegalArgumentException(msg, e); // GlobalExceptionHandler 가 메시지를 그대로 노출 } catch (IllegalArgumentException e) { throw e; } catch (Exception e) { throw new IllegalArgumentException("Gemini 자막 추출 실패: " + e.getMessage(), e); } } /** API 키 정리(순수): 앞뒤 공백 + 잘못 붙은 선행 '=' 제거. 유효한 키는 이런 문자로 시작하지 않는다. */ static String sanitizeApiKey(String raw) { if (raw == null) return ""; String k = raw.strip(); while (k.startsWith("=")) { k = k.substring(1).strip(); } return k; } /** Gemini generateContent 요청 바디 조립(순수). 유튜브 URL + 프롬프트 + JSON 응답 강제. */ static Map buildRequest(String videoUrl, String prompt) { Map textPart = Map.of("text", prompt); Map filePart = Map.of("file_data", Map.of("file_uri", videoUrl)); Map content = Map.of("parts", List.of(textPart, filePart)); Map itemSchema = Map.of( "type", "OBJECT", "properties", Map.of( "start", Map.of("type", "NUMBER"), "end", Map.of("type", "NUMBER"), "original", Map.of("type", "STRING"), "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); } /** * Gemini 응답에서 세그먼트 배열을 추출(순수). candidates→content→parts→text 안의 JSON 배열을 파싱한다. * 코드펜스나 잡텍스트로 감싸여도 첫 '[' ~ 마지막 ']' 구간을 잘라 파싱한다. 실패/없음이면 빈 리스트. */ static List extractSegments(ObjectMapper objectMapper, String responseJson) { try { JsonNode parts = objectMapper.readTree(responseJson) .path("candidates").path(0).path("content").path("parts"); StringBuilder sb = new StringBuilder(); if (parts.isArray()) { for (JsonNode p : parts) sb.append(p.path("text").asText("")); } String text = sb.toString(); int lb = text.indexOf('['), rb = text.lastIndexOf(']'); if (lb < 0 || rb <= lb) return List.of(); List segs = objectMapper.readValue( text.substring(lb, rb + 1), objectMapper.getTypeFactory().constructCollectionType(List.class, ScriptResponseDto.Segment.class)); return segs == null ? List.of() : segs; } catch (Exception e) { return List.of(); } } }