화면(Gemini)·음성 패널에서 원문과 한국어 번역을 함께 보여줘 비교 가능하게 함. - ScriptResponseDto.Segment: original 필드 추가(NON_NULL) - Gemini 프롬프트/스키마: original(화면 원문) + text(한국어 번역) 둘 다 요청, text 는 반드시 한국어(영어 방지) 명시 - rework.html: 각 줄에 한국어(위)+원문(아래 회색) 표시, 음성 패널은 Whisper 원문 사용 검증: 470 화면자막 → 원문 일본어 + 한국어 번역 + 진행형 타임스탬프 정상. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
168 lines
8.9 KiB
Java
168 lines
8.9 KiB
Java
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을 직접 보내 <b>화면에 박힌 자막</b>을 추출·한국어 번역해 세그먼트로 받는다.
|
|
* 음성은 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<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);
|
|
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<Map<String, Object>> request = new HttpEntity<>(buildRequest(videoUrl, PROMPT), headers);
|
|
|
|
ResponseEntity<String> response = geminiRestTemplate.postForEntity(apiUrl, request, String.class);
|
|
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
|
throw new RuntimeException("Gemini API 실패: " + response.getStatusCode());
|
|
}
|
|
List<ScriptResponseDto.Segment> 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<String, Object> buildRequest(String videoUrl, String prompt) {
|
|
Map<String, Object> textPart = Map.of("text", prompt);
|
|
Map<String, Object> filePart = Map.of("file_data", Map.of("file_uri", videoUrl));
|
|
Map<String, Object> content = Map.of("parts", List.of(textPart, filePart));
|
|
|
|
Map<String, Object> 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<String, Object> 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<ScriptResponseDto.Segment> 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<ScriptResponseDto.Segment> 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();
|
|
}
|
|
}
|
|
}
|