From 447645c19ae571214cd3439cf243df7bf0470dde Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Thu, 25 Jun 2026 17:04:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(rework):=20AI=20=EC=9E=90=EB=A7=89=202?= =?UTF-8?q?=EC=A2=85(=EC=9D=8C=EC=84=B1=20Whisper=20/=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20Gemini)=E2=86=92=ED=95=9C=EA=B5=AD=EC=96=B4,=20=EC=A2=8C?= =?UTF-8?q?=EC=9A=B0=20=EB=B6=84=ED=95=A0=20+=20SRT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재가공 화면에 '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) --- .../ChannelVideoCurationController.java | 9 ++ .../channel/ChannelVideoCurationService.java | 14 ++ .../global/config/RestTemplateConfig.java | 11 ++ .../service/GeminiSubtitleService.java | 132 ++++++++++++++++++ src/main/resources/application.yml | 5 + src/main/resources/templates/rework.html | 91 ++++++++++++ .../service/GeminiSubtitleServiceTest.java | 68 +++++++++ 7 files changed, 330 insertions(+) create mode 100644 src/main/java/com/hlab/yanalyst/service/GeminiSubtitleService.java create mode 100644 src/test/java/com/hlab/yanalyst/service/GeminiSubtitleServiceTest.java diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java index bd9aa0b..f447742 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java @@ -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> geminiSubtitles(@PathVariable Long id) { + return ApiResponse.ok(curationService.geminiScreenSubtitles(id)); + } + @PostMapping("/{id}/translate") @Operation(summary = "원본 스크립트 번역 → 재가공 초안", description = "원본 전사 스크립트를 LibreTranslate로 번역해 반환한다(프론트가 '재작성' 칸에 채움). " diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java index de04776..4c0f660 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java @@ -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 ALLOWED_STATUS = Set.of("NEW", "REVIEWING", "TARGET", "DONE", "EXCLUDED"); private static final Set ALLOWED_SORT = Set.of("viewsPerHour", "viewsPerSubRatio", "viewCount", "publishedAt", "durationSec"); @@ -183,6 +184,19 @@ public class ChannelVideoCurationService { "받은 원본이 없습니다. 먼저 '원본 다운로드'를 실행하세요.")); } + /** + * 유튜브 URL을 Gemini에 보내 화면 자막을 한국어 세그먼트로 추출한다(저장 안 함 — 표시·다운로드용). + * 음성 자막은 기존 전사+번역(왼쪽)이 담당하므로 여기선 화면(오른쪽)만. + */ + public Map geminiScreenSubtitles(Long videoId) { + ChannelVideo v = find(videoId); + List segments = + geminiSubtitleService.fetchKoreanScreenSubtitles(v.getVideoId()); + Map result = new LinkedHashMap<>(); + result.put("segments", segments); + return result; + } + /** 다운로드 캐시 파일로 말 없는 구간 제거(+배속) 렌더. 캐시 없으면 안내 예외. */ public byte[] renderTrimmedFromCached(Long videoId, double pad, double minGap, double speed) { ChannelVideo v = find(videoId); diff --git a/src/main/java/com/hlab/yanalyst/global/config/RestTemplateConfig.java b/src/main/java/com/hlab/yanalyst/global/config/RestTemplateConfig.java index 5e238ca..a713f2c 100644 --- a/src/main/java/com/hlab/yanalyst/global/config/RestTemplateConfig.java +++ b/src/main/java/com/hlab/yanalyst/global/config/RestTemplateConfig.java @@ -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(); + } } diff --git a/src/main/java/com/hlab/yanalyst/service/GeminiSubtitleService.java b/src/main/java/com/hlab/yanalyst/service/GeminiSubtitleService.java new file mode 100644 index 0000000..b14dfcb --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/service/GeminiSubtitleService.java @@ -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을 직접 보내 화면에 박힌 자막을 추출·한국어 번역해 세그먼트로 받는다. + * 음성은 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 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> 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 을 넣지 않는다(노출 방지). + 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 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"), + "text", Map.of("type", "STRING"))); + Map genConfig = Map.of( + "responseMimeType", "application/json", + "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(); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c7a0ae4..2344df6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: diff --git a/src/main/resources/templates/rework.html b/src/main/resources/templates/rework.html index ef72105..442a4a1 100644 --- a/src/main/resources/templates/rework.html +++ b/src/main/resources/templates/rework.html @@ -224,6 +224,41 @@ .seg-text { color:var(--text-2); } + +
+
+

🤖 AI 자막 (음성 / 화면)

+ +
+ +
+
+
+
🎙 음성 Whisper·정밀
+ +
+
‘생성’을 누르세요
+
+
+
+
🖼 화면 Gemini·~1초
+ +
+
‘생성’을 누르세요
+
+
+ +
+
@@ -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 = '
' + esc(error) + '
'; return; } + if(!segs || !segs.length){ box.innerHTML = '
자막 없음
'; return; } + box.innerHTML = segs.map(sg => + '
' + + '' + mmss(sg.start) + '' + + '' + esc(sg.text) + '
').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(); } diff --git a/src/test/java/com/hlab/yanalyst/service/GeminiSubtitleServiceTest.java b/src/test/java/com/hlab/yanalyst/service/GeminiSubtitleServiceTest.java new file mode 100644 index 0000000..4593bce --- /dev/null +++ b/src/test/java/com/hlab/yanalyst/service/GeminiSubtitleServiceTest.java @@ -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 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 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 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(); + } +}