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 처리
|
||||
}
|
||||
|
||||
@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로 번역해 반환한다(프론트가 '재작성' 칸에 채움). "
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
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:
|
||||
|
||||
@ -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(); }
|
||||
|
||||
@ -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