feat(rework): AI 자막 2종(음성 Whisper / 화면 Gemini)→한국어, 좌우 분할 + SRT

재가공 화면에 'AI 자막(음성/화면)' 카드 추가. '생성' 한 번에:
- 왼쪽(음성): 저장된 Whisper 세그먼트(정밀 타임스탬프) → LibreTranslate 한국어
- 오른쪽(화면): 유튜브 URL → Gemini가 화면 박힌 자막 추출+한국어 번역
각 패널을 [00:00] 한국어 리스트로 표시, 각각 SRT 다운로드(audio_ko/screen_ko).

- GeminiSubtitleService 신규: youtube URL을 generativelanguage API에 전송,
  responseMimeType=json 구조화 응답 파싱. buildRequest/extractSegments/sanitizeApiKey 순수+단위테스트
- sanitizeApiKey: 잘못 붙은 선행 '='·공백 정리(env var 오타 방어)
- geminiRestTemplate 빈(5분), POST /{id}/gemini-subtitles, CurationService.geminiScreenSubtitles
- 설정 gemini.api-key/model(gemini-2.5-flash), rework.html 분할 카드+JS
- 음성 한국어는 기존 translate(format=segments) 재사용

검증: 470 화면자막 → 한국어 27세그먼트 30초 추출(스타일 일본어 자막도 정확).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-25 17:04:24 +09:00
parent c44b16a7f4
commit 447645c19a
7 changed files with 330 additions and 0 deletions

View File

@ -177,6 +177,15 @@ public class ChannelVideoCurationController {
.body(resource); // Resource 반환 Spring Range 요청을 자동으로 206 처리
}
@PostMapping("/{id}/gemini-subtitles")
@Operation(summary = "화면 자막(Gemini, 한국어)",
description = "저장된 유튜브 URL을 Gemini에 보내 화면에 박힌 자막을 추출·한국어 번역해 세그먼트로 반환한다(저장 안 함). "
+ "음성 자막은 전사+번역(왼쪽)이 담당. 응답: {segments:[{start,end,text(한국어)}]}. "
+ "타임스탬프는 Gemini 특성상 ~1초 근사.")
public ApiResponse<Map<String, Object>> geminiSubtitles(@PathVariable Long id) {
return ApiResponse.ok(curationService.geminiScreenSubtitles(id));
}
@PostMapping("/{id}/translate")
@Operation(summary = "원본 스크립트 번역 → 재가공 초안",
description = "원본 전사 스크립트를 LibreTranslate로 번역해 반환한다(프론트가 '재작성' 칸에 채움). "

View File

@ -26,6 +26,7 @@ public class ChannelVideoCurationService {
private final ChannelService channelService;
private final VideoDownloadService videoDownloadService;
private final com.hlab.yanalyst.service.TranslateService translateService;
private final com.hlab.yanalyst.service.GeminiSubtitleService geminiSubtitleService;
private static final Set<String> ALLOWED_STATUS = Set.of("NEW", "REVIEWING", "TARGET", "DONE", "EXCLUDED");
private static final Set<String> ALLOWED_SORT = Set.of("viewsPerHour", "viewsPerSubRatio", "viewCount", "publishedAt", "durationSec");
@ -183,6 +184,19 @@ public class ChannelVideoCurationService {
"받은 원본이 없습니다. 먼저 '원본 다운로드'를 실행하세요."));
}
/**
* 유튜브 URL을 Gemini에 보내 <b>화면 자막</b> 한국어 세그먼트로 추출한다(저장 표시·다운로드용).
* 음성 자막은 기존 전사+번역(왼쪽) 담당하므로 여기선 화면(오른쪽).
*/
public Map<String, Object> geminiScreenSubtitles(Long videoId) {
ChannelVideo v = find(videoId);
List<ScriptResponseDto.Segment> segments =
geminiSubtitleService.fetchKoreanScreenSubtitles(v.getVideoId());
Map<String, Object> result = new LinkedHashMap<>();
result.put("segments", segments);
return result;
}
/** 다운로드 캐시 파일로 말 없는 구간 제거(+배속) 렌더. 캐시 없으면 안내 예외. */
public byte[] renderTrimmedFromCached(Long videoId, double pad, double minGap, double speed) {
ChannelVideo v = find(videoId);

View File

@ -34,4 +34,15 @@ public class RestTemplateConfig {
.readTimeout(Duration.ofMinutes(10))
.build();
}
/**
* Gemini(유튜브 URL 영상 이해) RestTemplate. 영상 분석은 수십 ~ 걸릴 있어 read timeout 길게 둔다.
*/
@Bean
public RestTemplate geminiRestTemplate(RestTemplateBuilder builder) {
return builder
.connectTimeout(Duration.ofSeconds(20))
.readTimeout(Duration.ofMinutes(5))
.build();
}
}

View File

@ -0,0 +1,132 @@
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 =
"이 유튜브 영상에서 화면에 표시되는(영상에 박힌) 자막만 추출해 한국어로 번역하세요. "
+ "음성/말소리는 무시하고 화면의 글자만 대상으로 합니다. "
+ "JSON 배열로만 출력: [{\"start\": 시작초, \"end\": 끝초, \"text\": \"한국어 번역\"}]. "
+ "start/end 는 영상 시작 기준 초(소수 가능), 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;
/** 유튜브 영상의 화면 자막을 한국어 세그먼트로 반환. */
public List<ScriptResponseDto.Segment> fetchKoreanScreenSubtitles(String videoId) {
String key = sanitizeApiKey(apiKey);
if (key.isEmpty()) {
throw new IllegalStateException("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 넣지 않는다(노출 방지).
String body = e.getResponseBodyAsString();
String tail = body.length() > 300 ? body.substring(0, 300) : body;
throw new RuntimeException("Gemini 호출 실패(" + e.getStatusCode() + "): " + tail, e);
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("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"),
"text", Map.of("type", "STRING")));
Map<String, Object> genConfig = Map.of(
"responseMimeType", "application/json",
"responseSchema", Map.of("type", "ARRAY", "items", itemSchema));
return Map.of("contents", List.of(content), "generationConfig", genConfig);
}
/**
* Gemini 응답에서 세그먼트 배열을 추출(순수). candidatescontentpartstext 안의 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();
}
}
}

View File

@ -67,6 +67,11 @@ download:
translate:
base-url: ${TRANSLATE_BASE_URL:https://h-translate.tolag.shop}
# Gemini(무료 티어): 유튜브 URL → 화면 자막 추출 + 한국어 번역. AI Studio API 키 사용.
gemini:
api-key: ${GEMINI_API_KEY:}
model: ${GEMINI_MODEL:gemini-2.5-flash} # 유튜브 URL 영상 이해 지원
hlab:
# 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집
scheduler:

View File

@ -224,6 +224,41 @@
.seg-text { color:var(--text-2); }
</style>
<!-- AI 자막 (음성 Whisper / 화면 Gemini) -->
<div class="card">
<div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:8px;">
<h3 class="text-lg font-bold">🤖 AI 자막 (음성 / 화면)</h3>
<button class="btn btn-primary px-3 py-2 flex items-center gap-1" id="aiGenBtn" onclick="generateAiSubtitles()" title="왼쪽=음성(Whisper 정밀+한국어), 오른쪽=화면(Gemini 한국어). 음성은 먼저 '전사' 필요">
<i data-lucide="sparkles" style="width:15px;"></i> 생성
</button>
</div>
<div id="aiStatus" class="text-sm mb-2" style="display:none;"></div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div>
<div class="flex items-center justify-between mb-1" style="gap:6px;">
<div class="font-bold text-sm">🎙 음성 <span class="text-xs text-muted">Whisper·정밀</span></div>
<button class="btn btn-secondary" style="padding:4px 9px; font-size:0.8rem;" onclick="downloadAiSrt('audio')"><i data-lucide="file-down" style="width:13px;"></i> SRT</button>
</div>
<div id="aiAudioList" class="ai-list"><div class="ai-empty">‘생성’을 누르세요</div></div>
</div>
<div>
<div class="flex items-center justify-between mb-1" style="gap:6px;">
<div class="font-bold text-sm">🖼 화면 <span class="text-xs text-muted">Gemini·~1초</span></div>
<button class="btn btn-secondary" style="padding:4px 9px; font-size:0.8rem;" onclick="downloadAiSrt('screen')"><i data-lucide="file-down" style="width:13px;"></i> SRT</button>
</div>
<div id="aiScreenList" class="ai-list"><div class="ai-empty">‘생성’을 누르세요</div></div>
</div>
</div>
<style>
.ai-list { max-height:300px; overflow:auto; border:1px solid var(--glass-border);
border-radius:var(--radius-md); background:var(--surface-2); }
.ai-row { display:flex; gap:8px; padding:6px 10px; cursor:pointer; border-bottom:1px solid var(--surface);
font-size:0.85rem; line-height:1.5; }
.ai-row:hover { background:var(--surface); }
.ai-empty { padding:14px 10px; color:var(--text-2); font-size:0.85rem; text-align:center; }
</style>
</div>
<!-- 재작성 에디터 -->
<div class="card">
<div class="flex items-center justify-between mb-3">
@ -316,6 +351,8 @@
let SEGMENTS = []; // 전사 원본 세그먼트(원본 영상 재생에 싱크)
let UPLOADED_FILE = null; // 업로드한 영상 File (렌더 재전송용)
let HAS_SERVER_FILE = false; // '원본 다운로드'로 서버에 캐시된 영상 보유 여부(렌더가 업로드 없이 재사용)
let AI_AUDIO = []; // AI 자막 - 음성(Whisper+한국어) 세그먼트
let AI_SCREEN = []; // AI 자막 - 화면(Gemini 한국어) 세그먼트
let trimApplied = false; // 말 없는 구간 제거 미리보기 적용 여부
let REMAPPED = []; // 무음 제거 후(배속 전) 세그먼트
@ -432,6 +469,60 @@
finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); }
}
// ===== AI 자막 (음성 Whisper / 화면 Gemini) =====
function renderAiPanel(elId, segs, error){
const box = document.getElementById(elId);
if(error){ box.innerHTML = '<div class="ai-empty" style="color:#f87171;">' + esc(error) + '</div>'; return; }
if(!segs || !segs.length){ box.innerHTML = '<div class="ai-empty">자막 없음</div>'; return; }
box.innerHTML = segs.map(sg =>
'<div class="ai-row" data-start="' + sg.start + '" onclick="seekTo(' + sg.start + ')">'
+ '<span class="seg-time">' + mmss(sg.start) + '</span>'
+ '<span class="seg-text">' + esc(sg.text) + '</span></div>').join('');
}
// 음성(Whisper 정밀+한국어) + 화면(Gemini 한국어)을 동시에 생성해 좌우 패널에 표시.
async function generateAiSubtitles(){
const btn = document.getElementById('aiGenBtn');
const status = document.getElementById('aiStatus');
const orig = btn.innerHTML; btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '생성 중…';
status.style.display = 'block'; status.style.color = '#facc15';
status.textContent = 'AI 자막 생성 중… (음성 번역 + Gemini 화면분석, 영상 길이에 따라 시간 걸림)';
// 왼쪽: 저장된 Whisper 세그먼트 → 한국어 번역(LibreTranslate)
const audioP = (async () => {
if(!SEGMENTS || !SEGMENTS.length) return { error: '음성 자막: 먼저 ‘전사’를 실행하세요' };
const r = await api(API + '/' + VIDEO_ID + '/translate?target=ko&format=segments', { method:'POST' });
const ko = (r && r.texts) || [];
return { segments: SEGMENTS.map((sg, i) => ({ start: sg.start, end: sg.end, text: (ko[i] != null ? ko[i] : (sg.text || '')) })) };
})().catch(e => ({ error: '음성 번역 실패: ' + e.message }));
// 오른쪽: Gemini 화면 자막(한국어)
const screenP = api(API + '/' + VIDEO_ID + '/gemini-subtitles', { method:'POST' })
.then(r => ({ segments: (r && r.segments) || [] }))
.catch(e => ({ error: 'Gemini 실패: ' + e.message }));
const [audio, screen] = await Promise.all([audioP, screenP]);
AI_AUDIO = audio.segments || [];
AI_SCREEN = screen.segments || [];
renderAiPanel('aiAudioList', AI_AUDIO, audio.error);
renderAiPanel('aiScreenList', AI_SCREEN, screen.error);
status.style.color = '#4ade80';
status.textContent = '완료 · 음성 ' + AI_AUDIO.length + '개 · 화면 ' + AI_SCREEN.length + '개'
+ (audio.error ? ' · ⚠️음성:' + audio.error : '') + (screen.error ? ' · ⚠️화면:' + screen.error : '');
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig; if(window.lucide) lucide.createIcons();
}
// 음성/화면 패널의 한국어 세그먼트를 SRT로 다운로드.
function downloadAiSrt(which){
const segs = which === 'audio' ? AI_AUDIO : AI_SCREEN;
if(!segs || !segs.length){ alert('먼저 ‘생성’을 눌러 ' + (which==='audio'?'음성':'화면') + ' 자막을 만드세요.'); return; }
const blob = new Blob([buildSrt(segs, 1.0)], {type:'application/x-subrip'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = (which==='audio'?'audio_ko_':'screen_ko_') + VIDEO_ID + '.srt';
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href);
}
// ===== 말 없는 구간 제거 =====
function onTrimToggle(){
if(document.getElementById('trimOn').checked){ previewTrim(); }

View File

@ -0,0 +1,68 @@
package com.hlab.yanalyst.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/** Gemini 요청 조립 / 응답 파싱의 순수 로직 테스트(HTTP 없음). */
class GeminiSubtitleServiceTest {
private final ObjectMapper om = new ObjectMapper();
@Test
void buildRequest_youtubeURL와_프롬프트와_json응답형식을_담는다() throws Exception {
Map<String, Object> req = GeminiSubtitleService.buildRequest(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ", "화면 자막 한국어로");
String json = om.writeValueAsString(req);
assertThat(json)
.contains("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
.contains("file_uri")
.contains("화면 자막 한국어로")
.contains("application/json"); // responseMimeType
}
@Test
void extractSegments_정상응답의_JSON배열을_세그먼트로() {
String resp = "{\"candidates\":[{\"content\":{\"parts\":[{\"text\":"
+ "\"[{\\\"start\\\":0.0,\\\"end\\\":3.0,\\\"text\\\":\\\"안녕하세요\\\"},"
+ "{\\\"start\\\":3.0,\\\"end\\\":5.5,\\\"text\\\":\\\"반갑습니다\\\"}]\"}]}}]}";
List<ScriptResponseDto.Segment> segs = GeminiSubtitleService.extractSegments(om, resp);
assertThat(segs).hasSize(2);
assertThat(segs.get(0).getStart()).isEqualTo(0.0);
assertThat(segs.get(0).getEnd()).isEqualTo(3.0);
assertThat(segs.get(0).getText()).isEqualTo("안녕하세요");
assertThat(segs.get(1).getText()).isEqualTo("반갑습니다");
}
@Test
void extractSegments_코드펜스로_감싼_응답도_파싱() {
String resp = "{\"candidates\":[{\"content\":{\"parts\":[{\"text\":"
+ "\"```json\\n[{\\\"start\\\":1,\\\"end\\\":2,\\\"text\\\":\\\"테스트\\\"}]\\n```\"}]}}]}";
List<ScriptResponseDto.Segment> segs = GeminiSubtitleService.extractSegments(om, resp);
assertThat(segs).hasSize(1);
assertThat(segs.get(0).getText()).isEqualTo("테스트");
}
@Test
void sanitizeApiKey_앞의_등호와_공백을_정리() {
assertThat(GeminiSubtitleService.sanitizeApiKey(" =AIzaKEY ")).isEqualTo("AIzaKEY");
assertThat(GeminiSubtitleService.sanitizeApiKey("==AIzaKEY")).isEqualTo("AIzaKEY");
assertThat(GeminiSubtitleService.sanitizeApiKey("AIzaKEY")).isEqualTo("AIzaKEY");
assertThat(GeminiSubtitleService.sanitizeApiKey(null)).isEqualTo("");
}
@Test
void extractSegments_깨진응답이면_빈리스트() {
assertThat(GeminiSubtitleService.extractSegments(om, "{}")).isEmpty();
assertThat(GeminiSubtitleService.extractSegments(om,
"{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"이건 JSON 아님\"}]}}]}")).isEmpty();
}
}