feat: 숏폼 자막 Whisper 폴백 추가
This commit is contained in:
parent
f284a5b1e5
commit
9a3047142d
@ -96,8 +96,12 @@ public class ShortformJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void markSubtitleCollecting() {
|
public void markSubtitleCollecting() {
|
||||||
|
markSubtitleCollecting(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markSubtitleCollecting(String message) {
|
||||||
subtitleStatus = SubtitleStatus.COLLECTING;
|
subtitleStatus = SubtitleStatus.COLLECTING;
|
||||||
subtitleError = null;
|
subtitleError = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void storeSubtitle(String vtt) {
|
public void storeSubtitle(String vtt) {
|
||||||
|
|||||||
@ -33,6 +33,7 @@ public class ShortformService {
|
|||||||
job.changeCategory(category);
|
job.changeCategory(category);
|
||||||
if (job.subtitleStatusOrDefault() != SubtitleStatus.READY
|
if (job.subtitleStatusOrDefault() != SubtitleStatus.READY
|
||||||
&& job.subtitleStatusOrDefault() != SubtitleStatus.COLLECTING) {
|
&& job.subtitleStatusOrDefault() != SubtitleStatus.COLLECTING) {
|
||||||
|
job.markSubtitleCollecting("자막 수집 대기 중");
|
||||||
scheduleSubtitleAfterCommit(job.getId());
|
scheduleSubtitleAfterCommit(job.getId());
|
||||||
}
|
}
|
||||||
return JobDetail.from(job);
|
return JobDetail.from(job);
|
||||||
@ -48,9 +49,11 @@ public class ShortformService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public void retrySubtitle(Long jobId) {
|
public void retrySubtitle(Long jobId) {
|
||||||
ShortformJob job = jobRepository.findById(jobId)
|
ShortformJob job = jobRepository.findById(jobId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
||||||
|
job.markSubtitleCollecting("자막 재수집 대기 중");
|
||||||
scheduleSubtitleAfterCommit(jobId);
|
scheduleSubtitleAfterCommit(jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ public class ShortformSubtitleService {
|
|||||||
private static final int MAX_COLLECTION_ATTEMPTS = 2;
|
private static final int MAX_COLLECTION_ATTEMPTS = 2;
|
||||||
|
|
||||||
private final ShortformJobRepository jobRepository;
|
private final ShortformJobRepository jobRepository;
|
||||||
|
private final ShortformWhisperService whisperService;
|
||||||
|
|
||||||
@Value("${ytdlp.bin:yt-dlp}")
|
@Value("${ytdlp.bin:yt-dlp}")
|
||||||
private String ytdlpBin;
|
private String ytdlpBin;
|
||||||
@ -44,11 +45,11 @@ public class ShortformSubtitleService {
|
|||||||
if (job == null || job.subtitleStatusOrDefault() == SubtitleStatus.READY) {
|
if (job == null || job.subtitleStatusOrDefault() == SubtitleStatus.READY) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job.markSubtitleCollecting();
|
job.markSubtitleCollecting("YouTube 자막 확인 중");
|
||||||
jobRepository.save(job);
|
jobRepository.save(job);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String vtt = collectWithRetry(job.getVideoId());
|
String vtt = collectOrTranscribe(jobId, job.getVideoId());
|
||||||
ShortformJob current = jobRepository.findById(jobId).orElse(null);
|
ShortformJob current = jobRepository.findById(jobId).orElse(null);
|
||||||
if (current == null) return;
|
if (current == null) return;
|
||||||
if (vtt == null) current.markSubtitleUnavailable("한국어 공식/자동 자막이 없습니다");
|
if (vtt == null) current.markSubtitleUnavailable("한국어 공식/자동 자막이 없습니다");
|
||||||
@ -63,6 +64,24 @@ public class ShortformSubtitleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String collectOrTranscribe(Long jobId, String videoId) throws IOException, InterruptedException {
|
||||||
|
try {
|
||||||
|
String vtt = collectWithRetry(videoId);
|
||||||
|
if (vtt != null) return vtt;
|
||||||
|
} catch (IOException | RuntimeException e) {
|
||||||
|
log.warn("YouTube VTT 수집 실패, Whisper 폴백 실행: videoId={}", videoId, e);
|
||||||
|
}
|
||||||
|
updateProgress(jobId, "YouTube 자막 없음 · Whisper 자막 생성 중");
|
||||||
|
return whisperService.transcribe(videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateProgress(Long jobId, String message) {
|
||||||
|
jobRepository.findById(jobId).ifPresent(current -> {
|
||||||
|
current.markSubtitleCollecting(message);
|
||||||
|
jobRepository.save(current);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
String collectWithRetry(String videoId) throws IOException, InterruptedException {
|
String collectWithRetry(String videoId) throws IOException, InterruptedException {
|
||||||
Exception lastFailure = null;
|
Exception lastFailure = null;
|
||||||
for (int attempt = 1; attempt <= MAX_COLLECTION_ATTEMPTS; attempt++) {
|
for (int attempt = 1; attempt <= MAX_COLLECTION_ATTEMPTS; attempt++) {
|
||||||
|
|||||||
@ -0,0 +1,187 @@
|
|||||||
|
package com.hlab.yanalyst.domain.shortform;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
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.util.LinkedMultiValueMap;
|
||||||
|
import org.springframework.util.MultiValueMap;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/** YouTube 자막이 없는 숏폼 영상을 Whisper로 전사해 VTT를 생성한다. */
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class ShortformWhisperService {
|
||||||
|
|
||||||
|
private static final Pattern VIDEO_ID = Pattern.compile("^[A-Za-z0-9_-]{11}$");
|
||||||
|
|
||||||
|
@Qualifier("pythonRestTemplate")
|
||||||
|
private final RestTemplate pythonRestTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public ShortformWhisperService(@Qualifier("pythonRestTemplate") RestTemplate pythonRestTemplate,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.pythonRestTemplate = pythonRestTemplate;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Value("${python.base-url:http://h-python.tolag.shop}")
|
||||||
|
private String pythonBaseUrl;
|
||||||
|
|
||||||
|
@Value("${ytdlp.bin:yt-dlp}")
|
||||||
|
private String ytdlpBin;
|
||||||
|
|
||||||
|
@Value("${ytdlp.timeout-seconds:600}")
|
||||||
|
private long timeoutSeconds;
|
||||||
|
|
||||||
|
@Value("${download.dir:downloads}")
|
||||||
|
private String downloadDir;
|
||||||
|
|
||||||
|
public String transcribe(String videoId) throws IOException, InterruptedException {
|
||||||
|
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
|
||||||
|
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Path dir = Path.of(downloadDir, "subtitles", videoId, "whisper");
|
||||||
|
Files.createDirectories(dir);
|
||||||
|
try {
|
||||||
|
Path audio = downloadAudio(videoId, dir);
|
||||||
|
ScriptResponseDto response = requestTranscription(audio);
|
||||||
|
return toVtt(response == null ? null : response.getSegments());
|
||||||
|
} finally {
|
||||||
|
deleteFiles(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path downloadAudio(String videoId, Path dir) throws IOException, InterruptedException {
|
||||||
|
List<String> command = buildDownloadCommand(ytdlpBin, videoId, dir.resolve("source.%(ext)s"));
|
||||||
|
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||||
|
StringBuilder output = new StringBuilder();
|
||||||
|
Thread drainer = new Thread(() -> drain(process, output), "yt-dlp-audio-" + videoId);
|
||||||
|
drainer.setDaemon(true);
|
||||||
|
drainer.start();
|
||||||
|
|
||||||
|
boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
||||||
|
if (!finished) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
throw new RuntimeException("yt-dlp 오디오 다운로드 타임아웃(" + timeoutSeconds + "s)");
|
||||||
|
}
|
||||||
|
drainer.join(2000);
|
||||||
|
if (process.exitValue() != 0) {
|
||||||
|
String logText = output.toString();
|
||||||
|
throw new RuntimeException("yt-dlp 오디오 다운로드 실패: "
|
||||||
|
+ logText.substring(Math.max(0, logText.length() - 500)).strip());
|
||||||
|
}
|
||||||
|
|
||||||
|
try (var files = Files.list(dir)) {
|
||||||
|
return files.filter(Files::isRegularFile)
|
||||||
|
.max(Comparator.comparingLong(ShortformWhisperService::fileSize))
|
||||||
|
.orElseThrow(() -> new RuntimeException("다운로드된 오디오 파일이 없습니다"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ScriptResponseDto requestTranscription(Path audio) throws IOException {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||||
|
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||||
|
body.add("file", new FileSystemResource(audio));
|
||||||
|
|
||||||
|
ResponseEntity<String> response = pythonRestTemplate.postForEntity(
|
||||||
|
pythonBaseUrl + "/transcribe", new HttpEntity<>(body, headers), String.class);
|
||||||
|
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
||||||
|
throw new RuntimeException("Whisper API 실패: " + response.getStatusCode());
|
||||||
|
}
|
||||||
|
return objectMapper.readValue(response.getBody(), ScriptResponseDto.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> buildDownloadCommand(String binary, String videoId, Path output) {
|
||||||
|
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
|
||||||
|
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
|
||||||
|
}
|
||||||
|
List<String> command = new ArrayList<>();
|
||||||
|
command.add(binary);
|
||||||
|
command.add("--no-playlist");
|
||||||
|
command.add("--force-overwrites");
|
||||||
|
command.add("-f");
|
||||||
|
command.add("bestaudio/best");
|
||||||
|
command.add("-o");
|
||||||
|
command.add(output.toString());
|
||||||
|
command.add("https://www.youtube.com/watch?v=" + videoId);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String toVtt(List<ScriptResponseDto.Segment> segments) {
|
||||||
|
if (segments == null || segments.isEmpty()) return null;
|
||||||
|
StringBuilder vtt = new StringBuilder("WEBVTT\n\n");
|
||||||
|
int cue = 1;
|
||||||
|
for (ScriptResponseDto.Segment segment : segments) {
|
||||||
|
if (segment == null || segment.getText() == null || segment.getText().isBlank()
|
||||||
|
|| segment.getEnd() <= segment.getStart()) continue;
|
||||||
|
vtt.append(cue++).append('\n')
|
||||||
|
.append(formatTimestamp(segment.getStart())).append(" --> ")
|
||||||
|
.append(formatTimestamp(segment.getEnd())).append('\n')
|
||||||
|
.append(segment.getText().strip()).append("\n\n");
|
||||||
|
}
|
||||||
|
return cue == 1 ? null : vtt.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatTimestamp(double seconds) {
|
||||||
|
long millis = Math.max(0, Math.round(seconds * 1000));
|
||||||
|
long hours = millis / 3_600_000;
|
||||||
|
long minutes = (millis % 3_600_000) / 60_000;
|
||||||
|
long secs = (millis % 60_000) / 1000;
|
||||||
|
long ms = millis % 1000;
|
||||||
|
return "%02d:%02d:%02d.%03d".formatted(hours, minutes, secs, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long fileSize(Path path) {
|
||||||
|
try {
|
||||||
|
return Files.size(path);
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void deleteFiles(Path dir) {
|
||||||
|
try (var files = Files.list(dir)) {
|
||||||
|
files.filter(Files::isRegularFile).forEach(path -> {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(path);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Whisper 임시 파일 삭제 실패: {}", path, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Whisper 임시 폴더 정리 실패: {}", dir, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void drain(Process process, StringBuilder target) {
|
||||||
|
try (BufferedReader reader = new BufferedReader(
|
||||||
|
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) target.append(line).append('\n');
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// 프로세스 종료 중 스트림이 닫힐 수 있다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -77,6 +77,7 @@
|
|||||||
<script>
|
<script>
|
||||||
let currentCategory = new URLSearchParams(location.search).get('category') === 'POLITICS'
|
let currentCategory = new URLSearchParams(location.search).get('category') === 'POLITICS'
|
||||||
? 'POLITICS' : 'ENTERTAINMENT';
|
? 'POLITICS' : 'ENTERTAINMENT';
|
||||||
|
let subtitlePollTimer = null;
|
||||||
|
|
||||||
async function api(path, opts) {
|
async function api(path, opts) {
|
||||||
const res = await fetch(path, Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts));
|
const res = await fetch(path, Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts));
|
||||||
@ -231,7 +232,7 @@
|
|||||||
<div class="t">${esc(j.title || j.videoId)}</div>
|
<div class="t">${esc(j.title || j.videoId)}</div>
|
||||||
<div class="u">${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개</div>
|
<div class="u">${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개</div>
|
||||||
<div class="sf-subtitle ${j.subtitleStatus}"
|
<div class="sf-subtitle ${j.subtitleStatus}"
|
||||||
title="${esc(j.subtitleError || '')}">자막 ${subtitleLabel(j.subtitleStatus)}</div>
|
title="${esc(j.subtitleError || '')}">자막 ${subtitleLabel(j)}</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="sf-category ${j.category}">${j.category === 'POLITICS' ? '정치' : '예능'}</span>
|
<span class="sf-category ${j.category}">${j.category === 'POLITICS' ? '정치' : '예능'}</span>
|
||||||
<span class="sf-badge ${j.status}">${j.status}</span>
|
<span class="sf-badge ${j.status}">${j.status}</span>
|
||||||
@ -248,11 +249,16 @@
|
|||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
});
|
});
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
|
clearTimeout(subtitlePollTimer);
|
||||||
|
if (jobs.some(j => j.subtitleStatus === 'COLLECTING')) {
|
||||||
|
subtitlePollTimer = setTimeout(loadJobs, 2000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function subtitleLabel(status) {
|
function subtitleLabel(job) {
|
||||||
|
if (job.subtitleStatus === 'COLLECTING' && job.subtitleError) return job.subtitleError;
|
||||||
return ({NOT_REQUESTED:'대기', COLLECTING:'수집 중…', READY:'준비 완료',
|
return ({NOT_REQUESTED:'대기', COLLECTING:'수집 중…', READY:'준비 완료',
|
||||||
UNAVAILABLE:'없음', FAILED:'실패'})[status] || '대기';
|
UNAVAILABLE:'없음', FAILED:'실패'})[job.subtitleStatus] || '대기';
|
||||||
}
|
}
|
||||||
|
|
||||||
switchCategory(currentCategory);
|
switchCategory(currentCategory);
|
||||||
|
|||||||
@ -36,13 +36,26 @@ class ShortformSubtitleServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void retriesOnceWhenFirstCollectionHasNoSubtitle() throws Exception {
|
void retriesOnceWhenFirstCollectionHasNoSubtitle() throws Exception {
|
||||||
ShortformSubtitleService service = spy(new ShortformSubtitleService(mock(ShortformJobRepository.class)));
|
ShortformSubtitleService service = spy(new ShortformSubtitleService(
|
||||||
|
mock(ShortformJobRepository.class), mock(ShortformWhisperService.class)));
|
||||||
doReturn(null, "WEBVTT\n").when(service).collect("gDfGqRDubCg");
|
doReturn(null, "WEBVTT\n").when(service).collect("gDfGqRDubCg");
|
||||||
|
|
||||||
assertThat(service.collectWithRetry("gDfGqRDubCg")).isEqualTo("WEBVTT\n");
|
assertThat(service.collectWithRetry("gDfGqRDubCg")).isEqualTo("WEBVTT\n");
|
||||||
verify(service, times(2)).collect("gDfGqRDubCg");
|
verify(service, times(2)).collect("gDfGqRDubCg");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fallsBackToWhisperWhenYoutubeHasNoSubtitle() throws Exception {
|
||||||
|
ShortformJobRepository repository = mock(ShortformJobRepository.class);
|
||||||
|
ShortformWhisperService whisperService = mock(ShortformWhisperService.class);
|
||||||
|
ShortformSubtitleService service = spy(new ShortformSubtitleService(repository, whisperService));
|
||||||
|
doReturn(null).when(service).collectWithRetry("gDfGqRDubCg");
|
||||||
|
when(whisperService.transcribe("gDfGqRDubCg")).thenReturn("WEBVTT\n");
|
||||||
|
|
||||||
|
assertThat(service.collectOrTranscribe(1L, "gDfGqRDubCg")).isEqualTo("WEBVTT\n");
|
||||||
|
verify(whisperService).transcribe("gDfGqRDubCg");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void collectsSubtitleForEntertainmentJob() throws Exception {
|
void collectsSubtitleForEntertainmentJob() throws Exception {
|
||||||
ShortformJobRepository repository = mock(ShortformJobRepository.class);
|
ShortformJobRepository repository = mock(ShortformJobRepository.class);
|
||||||
@ -51,8 +64,9 @@ class ShortformSubtitleServiceTest {
|
|||||||
"gDfGqRDubCg",
|
"gDfGqRDubCg",
|
||||||
ShortformCategory.ENTERTAINMENT);
|
ShortformCategory.ENTERTAINMENT);
|
||||||
when(repository.findById(1L)).thenReturn(Optional.of(job));
|
when(repository.findById(1L)).thenReturn(Optional.of(job));
|
||||||
ShortformSubtitleService service = spy(new ShortformSubtitleService(repository));
|
ShortformSubtitleService service = spy(new ShortformSubtitleService(
|
||||||
doReturn("WEBVTT\n").when(service).collectWithRetry("gDfGqRDubCg");
|
repository, mock(ShortformWhisperService.class)));
|
||||||
|
doReturn("WEBVTT\n").when(service).collectOrTranscribe(1L, "gDfGqRDubCg");
|
||||||
|
|
||||||
service.collectAsync(1L);
|
service.collectAsync(1L);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
package com.hlab.yanalyst.domain.shortform;
|
||||||
|
|
||||||
|
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class ShortformWhisperServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildsAudioOnlyDownloadCommand() {
|
||||||
|
List<String> command = ShortformWhisperService.buildDownloadCommand(
|
||||||
|
"yt-dlp", "gDfGqRDubCg", Path.of("downloads/source.%(ext)s"));
|
||||||
|
|
||||||
|
assertThat(command).contains("--no-playlist", "--force-overwrites", "-f", "bestaudio/best");
|
||||||
|
assertThat(command).endsWith("https://www.youtube.com/watch?v=gDfGqRDubCg");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidVideoId() {
|
||||||
|
assertThatThrownBy(() -> ShortformWhisperService.buildDownloadCommand(
|
||||||
|
"yt-dlp", "bad;id", Path.of("out")))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertsWhisperSegmentsToVtt() {
|
||||||
|
ScriptResponseDto.Segment first = segment(1.234, 4.567, "첫 번째 자막");
|
||||||
|
ScriptResponseDto.Segment second = segment(65.0, 67.25, "두 번째 자막");
|
||||||
|
|
||||||
|
assertThat(ShortformWhisperService.toVtt(List.of(first, second)))
|
||||||
|
.isEqualTo("WEBVTT\n\n1\n00:00:01.234 --> 00:00:04.567\n첫 번째 자막\n\n"
|
||||||
|
+ "2\n00:01:05.000 --> 00:01:07.250\n두 번째 자막\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScriptResponseDto.Segment segment(double start, double end, String text) {
|
||||||
|
ScriptResponseDto.Segment segment = new ScriptResponseDto.Segment();
|
||||||
|
segment.setStart(start);
|
||||||
|
segment.setEnd(end);
|
||||||
|
segment.setText(text);
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user