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:
parent
c44b16a7f4
commit
447645c19a
@ -177,6 +177,15 @@ public class ChannelVideoCurationController {
|
|||||||
.body(resource); // Resource 반환 → Spring 이 Range 요청을 자동으로 206 처리
|
.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")
|
@PostMapping("/{id}/translate")
|
||||||
@Operation(summary = "원본 스크립트 번역 → 재가공 초안",
|
@Operation(summary = "원본 스크립트 번역 → 재가공 초안",
|
||||||
description = "원본 전사 스크립트를 LibreTranslate로 번역해 반환한다(프론트가 '재작성' 칸에 채움). "
|
description = "원본 전사 스크립트를 LibreTranslate로 번역해 반환한다(프론트가 '재작성' 칸에 채움). "
|
||||||
|
|||||||
@ -26,6 +26,7 @@ public class ChannelVideoCurationService {
|
|||||||
private final ChannelService channelService;
|
private final ChannelService channelService;
|
||||||
private final VideoDownloadService videoDownloadService;
|
private final VideoDownloadService videoDownloadService;
|
||||||
private final com.hlab.yanalyst.service.TranslateService translateService;
|
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_STATUS = Set.of("NEW", "REVIEWING", "TARGET", "DONE", "EXCLUDED");
|
||||||
private static final Set<String> ALLOWED_SORT = Set.of("viewsPerHour", "viewsPerSubRatio", "viewCount", "publishedAt", "durationSec");
|
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) {
|
public byte[] renderTrimmedFromCached(Long videoId, double pad, double minGap, double speed) {
|
||||||
ChannelVideo v = find(videoId);
|
ChannelVideo v = find(videoId);
|
||||||
|
|||||||
@ -34,4 +34,15 @@ public class RestTemplateConfig {
|
|||||||
.readTimeout(Duration.ofMinutes(10))
|
.readTimeout(Duration.ofMinutes(10))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemini(유튜브 URL 영상 이해)용 RestTemplate. 영상 분석은 수십 초~수 분 걸릴 수 있어 read timeout 을 길게 둔다.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public RestTemplate geminiRestTemplate(RestTemplateBuilder builder) {
|
||||||
|
return builder
|
||||||
|
.connectTimeout(Duration.ofSeconds(20))
|
||||||
|
.readTimeout(Duration.ofMinutes(5))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 응답에서 세그먼트 배열을 추출(순수). 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -67,6 +67,11 @@ download:
|
|||||||
translate:
|
translate:
|
||||||
base-url: ${TRANSLATE_BASE_URL:https://h-translate.tolag.shop}
|
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:
|
hlab:
|
||||||
# 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집
|
# 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집
|
||||||
scheduler:
|
scheduler:
|
||||||
|
|||||||
@ -224,6 +224,41 @@
|
|||||||
.seg-text { color:var(--text-2); }
|
.seg-text { color:var(--text-2); }
|
||||||
</style>
|
</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="card">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
@ -316,6 +351,8 @@
|
|||||||
let SEGMENTS = []; // 전사 원본 세그먼트(원본 영상 재생에 싱크)
|
let SEGMENTS = []; // 전사 원본 세그먼트(원본 영상 재생에 싱크)
|
||||||
let UPLOADED_FILE = null; // 업로드한 영상 File (렌더 재전송용)
|
let UPLOADED_FILE = null; // 업로드한 영상 File (렌더 재전송용)
|
||||||
let HAS_SERVER_FILE = false; // '원본 다운로드'로 서버에 캐시된 영상 보유 여부(렌더가 업로드 없이 재사용)
|
let HAS_SERVER_FILE = false; // '원본 다운로드'로 서버에 캐시된 영상 보유 여부(렌더가 업로드 없이 재사용)
|
||||||
|
let AI_AUDIO = []; // AI 자막 - 음성(Whisper+한국어) 세그먼트
|
||||||
|
let AI_SCREEN = []; // AI 자막 - 화면(Gemini 한국어) 세그먼트
|
||||||
let trimApplied = false; // 말 없는 구간 제거 미리보기 적용 여부
|
let trimApplied = false; // 말 없는 구간 제거 미리보기 적용 여부
|
||||||
let REMAPPED = []; // 무음 제거 후(배속 전) 세그먼트
|
let REMAPPED = []; // 무음 제거 후(배속 전) 세그먼트
|
||||||
|
|
||||||
@ -432,6 +469,60 @@
|
|||||||
finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); }
|
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(){
|
function onTrimToggle(){
|
||||||
if(document.getElementById('trimOn').checked){ previewTrim(); }
|
if(document.getElementById('trimOn').checked){ previewTrim(); }
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user