feat(rework): yt-dlp 원본 다운로드 → 전사·렌더 파이프라인 자동연결
재가공 화면 '원본 다운로드' 버튼으로 저장된 videoId를 yt-dlp(로컬 ProcessBuilder)로
받아 서버에 캐시하고, 그 파일을 기존 Python /transcribe·/render 에 그대로 투입한다.
수동 다운로드+업로드 단계를 전사·렌더 양쪽에서 제거.
- VideoDownloadService 신규: 인자 리스트 ProcessBuilder(셸 미사용), videoId 검증, 캐시 조회
- ChannelService: 전사·렌더를 Resource 기반 공통 메서드로 추출(업로드/캐시 공유)
- 컨트롤러: POST /{id}/download(다운로드+전사), /render 의 file 을 선택값으로(없으면 캐시)
- rework.html: '원본 다운로드' 버튼 + downloadOriginal(), 렌더가 서버 캐시 재사용
- 설정 ytdlp.*/download.dir(기본값 有), downloads/ gitignore
- buildCommand/videoId 검증 단위테스트(src/test 신규)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a177b0c987
commit
26b8be1ff1
3
.gitignore
vendored
3
.gitignore
vendored
@ -15,6 +15,9 @@ src/main/resources/application-local.yml
|
||||
tokens/
|
||||
*.log
|
||||
|
||||
# yt-dlp 다운로드 캐시(영상 파일은 커밋하지 않음)
|
||||
/downloads/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@ -20,6 +21,7 @@ import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
@ -393,19 +395,34 @@ public class ChannelService {
|
||||
*/
|
||||
@Transactional
|
||||
public ScriptResponseDto transcribeFromFile(Long channelVideoId, MultipartFile file, String language) {
|
||||
try {
|
||||
return doTranscribe(channelVideoId, toFileResource(file), file.getSize(), language);
|
||||
} catch (java.io.IOException e) {
|
||||
throw new RuntimeException("업로드 파일을 읽지 못했습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 다운로드 캐시 파일(yt-dlp 결과)을 Whisper 로 전사한다 — 업로드 없이 동일 파이프라인 재사용. */
|
||||
@Transactional
|
||||
public ScriptResponseDto transcribeFromCached(Long channelVideoId, File file, String language) {
|
||||
return doTranscribe(channelVideoId, toFileResource(file), file.length(), language);
|
||||
}
|
||||
|
||||
/** 전사 핵심: 전송할 Resource(업로드 ByteArray / 캐시 File)만 다르고 나머지 로직은 공통. */
|
||||
private ScriptResponseDto doTranscribe(Long channelVideoId, Resource resource, long sizeBytes, String language) {
|
||||
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
|
||||
|
||||
String apiUrl = pythonBaseUrl + "/transcribe";
|
||||
log.info("Requesting whisper transcription for video {} ({} bytes, lang={})",
|
||||
channelVideoId, file.getSize(), language);
|
||||
channelVideoId, sizeBytes, language);
|
||||
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", toFileResource(file));
|
||||
body.add("file", resource);
|
||||
if (language != null && !language.isBlank()) {
|
||||
body.add("language", language.trim()); // 자동감지 오류 보정용(ko/en/zh/ja 등)
|
||||
}
|
||||
@ -478,6 +495,20 @@ public class ChannelService {
|
||||
* keep 구간은 저장된 세그먼트로 서버가 계산하므로 미리보기와 정확히 일치한다.
|
||||
*/
|
||||
public byte[] renderTrimmed(Long channelVideoId, MultipartFile file, double pad, double minGap, double speed) {
|
||||
try {
|
||||
return doRender(channelVideoId, toFileResource(file), pad, minGap, speed);
|
||||
} catch (java.io.IOException e) {
|
||||
throw new RuntimeException("업로드 파일을 읽지 못했습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 다운로드 캐시 파일(yt-dlp 결과)로 말 없는 구간 제거(+배속) 렌더 — 업로드 없이 동일 파이프라인 재사용. */
|
||||
public byte[] renderTrimmedFromCached(Long channelVideoId, File file, double pad, double minGap, double speed) {
|
||||
return doRender(channelVideoId, toFileResource(file), pad, minGap, speed);
|
||||
}
|
||||
|
||||
/** 렌더 핵심: 전송할 Resource(업로드 ByteArray / 캐시 File)만 다르고 나머지 로직은 공통. */
|
||||
private byte[] doRender(Long channelVideoId, Resource resource, double pad, double minGap, double speed) {
|
||||
List<ScriptSegment> segments = getSegments(channelVideoId);
|
||||
if (segments.isEmpty()) {
|
||||
// 사용자에게 이유가 보이도록 400(IllegalArgumentException → GlobalExceptionHandler 가 메시지 노출).
|
||||
@ -491,7 +522,7 @@ public class ChannelService {
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", toFileResource(file));
|
||||
body.add("file", resource);
|
||||
body.add("keep", objectMapper.writeValueAsString(plan.keep())); // [{"start":..,"end":..},...]
|
||||
body.add("speed", String.valueOf(speed));
|
||||
|
||||
@ -507,6 +538,11 @@ public class ChannelService {
|
||||
}
|
||||
}
|
||||
|
||||
/** File(다운로드 캐시) → multipart 전송용 Resource(파일명 보존, 디스크 스트리밍). */
|
||||
private Resource toFileResource(File file) {
|
||||
return new FileSystemResource(file);
|
||||
}
|
||||
|
||||
/** MultipartFile → multipart 전송용 Resource(파일명 보존). */
|
||||
private Resource toFileResource(MultipartFile file) throws java.io.IOException {
|
||||
final String filename = (file.getOriginalFilename() != null && !file.getOriginalFilename().isBlank())
|
||||
|
||||
@ -133,6 +133,16 @@ public class ChannelVideoCurationController {
|
||||
return ApiResponse.ok(Map.of("hasScript", t != null, "transcript", t == null ? "" : t));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/download")
|
||||
@Operation(summary = "원본 다운로드 + 자동 전사",
|
||||
description = "저장된 videoId 로 yt-dlp 가 원본 영상을 받아 서버에 캐시하고, 그 파일을 바로 Whisper 로 전사한다. "
|
||||
+ "쿼리: language(선택: ko|en|zh|ja… 비우면 자동감지). 응답: {downloaded, sizeBytes, hasScript, language, duration, transcript, segments}. "
|
||||
+ "수동 다운로드+업로드 단계를 제거. 이후 렌더도 같은 캐시 파일을 재사용한다.")
|
||||
public ApiResponse<Map<String, Object>> download(@PathVariable Long id,
|
||||
@RequestParam(value = "language", required = false) String language) {
|
||||
return ApiResponse.ok(curationService.downloadAndTranscribe(id, language));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{id}/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(summary = "업로드 영상 전사(Whisper)",
|
||||
description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. "
|
||||
@ -156,14 +166,17 @@ public class ChannelVideoCurationController {
|
||||
|
||||
@PostMapping(value = "/{id}/render", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(summary = "말 없는 구간 제거(+배속) 영상 렌더",
|
||||
description = "업로드 영상에서 말 없는 구간을 잘라낸(+배속) 영상을 ffmpeg로 만들어 mp4 다운로드. "
|
||||
+ "multipart: file, pad, minGap, speed. keep 구간은 저장 세그먼트로 서버가 계산(미리보기와 일치).")
|
||||
description = "영상에서 말 없는 구간을 잘라낸(+배속) 영상을 ffmpeg로 만들어 mp4 다운로드. "
|
||||
+ "multipart: file(선택), pad, minGap, speed. file 을 보내면 그 업로드본을, 없으면 '원본 다운로드'로 받은 "
|
||||
+ "서버 캐시 파일을 사용한다(캐시 없으면 400). keep 구간은 저장 세그먼트로 서버가 계산(미리보기와 일치).")
|
||||
public ResponseEntity<byte[]> render(@PathVariable Long id,
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "file", required = false) MultipartFile file,
|
||||
@RequestParam(defaultValue = "0.15") double pad,
|
||||
@RequestParam(defaultValue = "0.3") double minGap,
|
||||
@RequestParam(defaultValue = "1.0") double speed) {
|
||||
byte[] mp4 = curationService.renderTrimmed(id, file, pad, minGap, speed);
|
||||
byte[] mp4 = (file != null && !file.isEmpty())
|
||||
? curationService.renderTrimmed(id, file, pad, minGap, speed)
|
||||
: curationService.renderTrimmedFromCached(id, pad, minGap, speed);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(new MediaType("video", "mp4"));
|
||||
headers.setContentDisposition(
|
||||
|
||||
@ -24,6 +24,7 @@ public class ChannelVideoCurationService {
|
||||
private final ChannelVideoRepository channelVideoRepository;
|
||||
private final ChannelVideoScriptRepository channelVideoScriptRepository;
|
||||
private final ChannelService channelService;
|
||||
private final VideoDownloadService videoDownloadService;
|
||||
|
||||
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");
|
||||
@ -121,6 +122,36 @@ public class ChannelVideoCurationService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 videoId 로 원본을 yt-dlp 로 받아 서버에 캐시하고, 그 파일을 바로 Whisper 로 전사한다.
|
||||
* 수동 다운로드+업로드 단계를 제거한다. language=null 이면 자동감지.
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> downloadAndTranscribe(Long videoId, String language) {
|
||||
ChannelVideo v = find(videoId);
|
||||
java.io.File file = videoDownloadService.download(v.getId());
|
||||
ScriptResponseDto dto = channelService.transcribeFromCached(v.getId(), file, language);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("downloaded", true);
|
||||
result.put("sizeBytes", file.length());
|
||||
result.put("hasScript", true);
|
||||
result.put("language", dto.getLanguage());
|
||||
result.put("duration", dto.getDuration());
|
||||
result.put("transcript", dto.getTranscript() == null ? "" : dto.getTranscript());
|
||||
result.put("segments", channelService.getSegments(v.getId()));
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 다운로드 캐시 파일로 말 없는 구간 제거(+배속) 렌더. 캐시 없으면 안내 예외. */
|
||||
public byte[] renderTrimmedFromCached(Long videoId, double pad, double minGap, double speed) {
|
||||
ChannelVideo v = find(videoId);
|
||||
java.io.File file = videoDownloadService.cachedFile(v.getId())
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"원본 영상이 없습니다. 먼저 '원본 다운로드'를 실행하세요."));
|
||||
return channelService.renderTrimmedFromCached(v.getId(), file, pad, minGap, speed);
|
||||
}
|
||||
|
||||
/** 저장된 세그먼트로 SRT 문자열을 생성한다. speed 는 배속(1.0=원본). */
|
||||
public String buildSrt(Long videoId, double speed) {
|
||||
ChannelVideo v = find(videoId);
|
||||
|
||||
@ -0,0 +1,152 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
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.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 저장된 {@code videoId} 로 yt-dlp 를 <b>로컬에서 직접 실행(ProcessBuilder)</b>해 원본 영상을 받아
|
||||
* {@code {download.dir}/{channelVideoId}.mp4} 에 캐시한다. 받은 파일은 기존 Python 파이프라인
|
||||
* ({@code /transcribe}, {@code /render})에 그대로 투입된다(전사·렌더 양쪽에서 캐시 재사용).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class VideoDownloadService {
|
||||
|
||||
/** YouTube videoId 는 정확히 11자의 [A-Za-z0-9_-]. 셸 인젝션·오입력 차단용. */
|
||||
private static final Pattern VIDEO_ID = Pattern.compile("^[A-Za-z0-9_-]{11}$");
|
||||
|
||||
private final ChannelVideoRepository channelVideoRepository;
|
||||
|
||||
@Value("${ytdlp.bin:yt-dlp}")
|
||||
private String ytdlpBin;
|
||||
|
||||
@Value("${ytdlp.ffmpeg-location:}")
|
||||
private String ffmpegLocation;
|
||||
|
||||
@Value("${ytdlp.max-height:1080}")
|
||||
private int maxHeight;
|
||||
|
||||
@Value("${ytdlp.timeout-seconds:600}")
|
||||
private long timeoutSeconds;
|
||||
|
||||
@Value("${download.dir:downloads}")
|
||||
private String downloadDir;
|
||||
|
||||
/**
|
||||
* 저장된 videoId 로 영상을 받아 {@code {download.dir}/{channelVideoId}.mp4} 로 저장하고 그 File 을 반환한다.
|
||||
* 셸을 거치지 않고 인자 리스트를 ProcessBuilder 에 전달한다(인젝션 차단).
|
||||
*/
|
||||
public File download(Long channelVideoId) {
|
||||
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
|
||||
String videoId = video.getVideoId();
|
||||
|
||||
try {
|
||||
Path dir = Path.of(downloadDir);
|
||||
Files.createDirectories(dir);
|
||||
Path out = dir.resolve(channelVideoId + ".mp4");
|
||||
|
||||
List<String> cmd = buildCommand(ytdlpBin, ffmpegLocation, maxHeight, videoId, out);
|
||||
log.info("yt-dlp 다운로드 시작: videoId={} -> {}", videoId, out);
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||
pb.redirectErrorStream(true);
|
||||
Process process = pb.start();
|
||||
|
||||
// 출력을 별도 스레드로 흡수(버퍼 막힘 방지 + waitFor 타임아웃이 실제로 동작하도록).
|
||||
StringBuilder outBuf = new StringBuilder();
|
||||
Thread drainer = new Thread(() -> {
|
||||
try (BufferedReader r = new BufferedReader(
|
||||
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = r.readLine()) != null) {
|
||||
synchronized (outBuf) { outBuf.append(line).append('\n'); }
|
||||
}
|
||||
} catch (IOException ignored) { /* 프로세스 종료 시 정상 */ }
|
||||
});
|
||||
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);
|
||||
|
||||
int code = process.exitValue();
|
||||
if (code != 0) {
|
||||
String output = outBuf.toString();
|
||||
String tail = output.length() > 600 ? output.substring(output.length() - 600) : output;
|
||||
throw new RuntimeException("yt-dlp 다운로드 실패(exit " + code + "): " + tail.strip());
|
||||
}
|
||||
|
||||
File file = out.toFile();
|
||||
if (!file.exists() || file.length() == 0) {
|
||||
throw new RuntimeException("다운로드 결과 파일이 비어있습니다: " + out);
|
||||
}
|
||||
log.info("yt-dlp 다운로드 완료: {} ({} bytes)", out, file.length());
|
||||
return file;
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("yt-dlp 실행 실패 (설치/PATH 확인): " + e.getMessage(), e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("yt-dlp 다운로드가 중단되었습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 캐시된 다운로드 파일(있고 비어있지 않을 때만). 렌더가 업로드 없이 재사용한다. */
|
||||
public Optional<File> cachedFile(Long channelVideoId) {
|
||||
File file = Path.of(downloadDir).resolve(channelVideoId + ".mp4").toFile();
|
||||
return (file.exists() && file.length() > 0) ? Optional.of(file) : Optional.empty();
|
||||
}
|
||||
|
||||
/** 다운로드 캐시 존재 여부. */
|
||||
public boolean isCached(Long channelVideoId) {
|
||||
return cachedFile(channelVideoId).isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* yt-dlp 명령 인자 리스트를 조립한다(프로세스 실행 없음 — 단위테스트 대상).
|
||||
* videoId 는 {@code [A-Za-z0-9_-]{11}} 형식만 허용한다.
|
||||
*/
|
||||
static List<String> buildCommand(String ytdlpBin, String ffmpegLocation, int maxHeight,
|
||||
String videoId, Path outPath) {
|
||||
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
|
||||
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
|
||||
}
|
||||
String fmt = "bv*[height<=" + maxHeight + "]+ba/b[height<=" + maxHeight + "]/b";
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(ytdlpBin);
|
||||
cmd.add("--no-playlist");
|
||||
cmd.add("--force-overwrites");
|
||||
cmd.add("-f");
|
||||
cmd.add(fmt);
|
||||
cmd.add("--merge-output-format");
|
||||
cmd.add("mp4");
|
||||
if (ffmpegLocation != null && !ffmpegLocation.isBlank()) {
|
||||
cmd.add("--ffmpeg-location");
|
||||
cmd.add(ffmpegLocation.trim());
|
||||
}
|
||||
cmd.add("-o");
|
||||
cmd.add(outPath.toString());
|
||||
cmd.add("https://www.youtube.com/watch?v=" + videoId);
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@ -54,6 +54,15 @@ youtube:
|
||||
python:
|
||||
base-url: ${PYTHON_BASE_URL:http://h-python.tolag.shop}
|
||||
|
||||
# yt-dlp 원본 다운로드(로컬 ProcessBuilder 실행). 재가공 '원본 다운로드' 버튼이 사용.
|
||||
ytdlp:
|
||||
bin: ${YTDLP_BIN:yt-dlp} # PATH 에 있으면 그대로, 아니면 절대경로 지정
|
||||
ffmpeg-location: ${FFMPEG_LOCATION:} # 비우면 PATH 사용. 필요시 ffmpeg bin 폴더 경로(예: D:\\utils\\ffmpeg\\bin)
|
||||
max-height: ${YTDLP_MAX_HEIGHT:1080} # bv*[height<=N]+ba/b, mp4 머지
|
||||
timeout-seconds: ${YTDLP_TIMEOUT_SECONDS:600}
|
||||
download:
|
||||
dir: ${DOWNLOAD_DIR:downloads} # 다운로드 캐시 폴더(작업 디렉토리 기준 상대경로 가능)
|
||||
|
||||
hlab:
|
||||
# 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집
|
||||
scheduler:
|
||||
|
||||
@ -78,7 +78,10 @@
|
||||
<option value="zh">中文</option>
|
||||
<option value="ja">日本語</option>
|
||||
</select>
|
||||
<label class="btn btn-primary px-3 py-2 flex items-center gap-1" id="uploadBtn" style="cursor:pointer;">
|
||||
<button class="btn btn-primary px-3 py-2 flex items-center gap-1" id="downloadBtn" onclick="downloadOriginal()" title="저장된 URL로 원본을 yt-dlp로 받아 자동 전사(수동 다운로드+업로드 불필요)">
|
||||
<i data-lucide="download" style="width:15px;"></i> 원본 다운로드
|
||||
</button>
|
||||
<label class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="uploadBtn" style="cursor:pointer;" title="직접 받은 영상 파일을 올려 전사">
|
||||
<i data-lucide="upload" style="width:15px;"></i> 영상 업로드·전사
|
||||
<input type="file" id="videoFile" accept="video/*" style="display:none;" onchange="onVideoSelected(event)">
|
||||
</label>
|
||||
@ -236,6 +239,7 @@
|
||||
// ===== 세그먼트(영상 싱크) =====
|
||||
let SEGMENTS = []; // 전사 원본 세그먼트(원본 영상 재생에 싱크)
|
||||
let UPLOADED_FILE = null; // 업로드한 영상 File (렌더 재전송용)
|
||||
let HAS_SERVER_FILE = false; // '원본 다운로드'로 서버에 캐시된 영상 보유 여부(렌더가 업로드 없이 재사용)
|
||||
let trimApplied = false; // 말 없는 구간 제거 미리보기 적용 여부
|
||||
let REMAPPED = []; // 무음 제거 후(배속 전) 세그먼트
|
||||
|
||||
@ -349,7 +353,7 @@
|
||||
}
|
||||
|
||||
async function renderVideo(){
|
||||
if(!UPLOADED_FILE){ alert('먼저 영상을 업로드·전사하세요. (렌더에는 원본 영상 파일이 필요합니다)'); return; }
|
||||
if(!UPLOADED_FILE && !HAS_SERVER_FILE){ alert('먼저 "원본 다운로드" 또는 "영상 업로드·전사"를 실행하세요. (렌더에는 원본 영상이 필요합니다)'); return; }
|
||||
const pad = parseFloat(document.getElementById('trimPad').value) || 0;
|
||||
const gap = parseFloat(document.getElementById('trimGap').value) || 0;
|
||||
const speed = curSpeed();
|
||||
@ -360,7 +364,7 @@
|
||||
status.textContent = '영상 렌더링 중… (자르기+인코딩, 잠시 걸립니다)';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', UPLOADED_FILE);
|
||||
if(UPLOADED_FILE) fd.append('file', UPLOADED_FILE); // 없으면 서버 캐시(원본 다운로드본) 사용
|
||||
fd.append('pad', pad); fd.append('minGap', gap); fd.append('speed', speed);
|
||||
const res = await fetch(API + '/' + VIDEO_ID + '/render', { method:'POST', body: fd });
|
||||
if(!res.ok){
|
||||
@ -382,10 +386,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 저장된 URL로 원본을 서버에서 yt-dlp로 받아 자동 전사. 성공 시 렌더도 업로드 없이 가능.
|
||||
async function downloadOriginal(){
|
||||
const status = document.getElementById('transcribeStatus');
|
||||
const btn = document.getElementById('downloadBtn');
|
||||
const orig = btn.innerHTML;
|
||||
btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '다운로드 중…';
|
||||
status.style.display = 'block'; status.style.color = '#facc15';
|
||||
status.textContent = '원본 다운로드 중… (yt-dlp, 영상 길이에 따라 수십 초~수 분)';
|
||||
try {
|
||||
const lang = document.getElementById('langSel').value;
|
||||
const url = API + '/' + VIDEO_ID + '/download' + (lang ? ('?language=' + lang) : '');
|
||||
const s = await api(url, { method:'POST' });
|
||||
HAS_SERVER_FILE = true; // 렌더는 서버 캐시본 사용
|
||||
UPLOADED_FILE = null;
|
||||
renderSegments(s.segments);
|
||||
document.getElementById('transcript').value = s.transcript || '';
|
||||
status.style.color = '#4ade80';
|
||||
const mb = s.sizeBytes ? ((s.sizeBytes/1048576).toFixed(1) + 'MB · ') : '';
|
||||
status.textContent = '다운로드+전사 완료 · ' + mb + (s.segments ? s.segments.length : 0) + '개 세그먼트'
|
||||
+ (s.language ? (' · ' + s.language) : '')
|
||||
+ (s.duration ? (' · ' + mmss(s.duration)) : '');
|
||||
} catch(e){
|
||||
status.style.color = '#f87171';
|
||||
status.textContent = '다운로드 실패: ' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
|
||||
if(window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function onVideoSelected(ev){
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
if(!file) return;
|
||||
UPLOADED_FILE = file; // 렌더(자르기) 재전송용으로 보관
|
||||
HAS_SERVER_FILE = false; // 업로드본 우선
|
||||
|
||||
// 1) 업로드한 파일을 로컬에서 즉시 재생(<video>) + YouTube iframe 숨김
|
||||
const v = document.getElementById('localVideo');
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* yt-dlp 명령 인자 조립({@link VideoDownloadService#buildCommand})과 videoId 검증의 순수 로직 테스트.
|
||||
* 프로세스 실행 없이 검증 가능한 부분만 다룬다.
|
||||
*/
|
||||
class VideoDownloadServiceTest {
|
||||
|
||||
@Test
|
||||
void buildCommand_정상videoId면_ytdlp_인자를_조립한다() {
|
||||
List<String> cmd = VideoDownloadService.buildCommand(
|
||||
"yt-dlp", "", 1080, "dQw4w9WgXcQ", Path.of("downloads", "5.mp4"));
|
||||
|
||||
assertThat(cmd).startsWith("yt-dlp");
|
||||
assertThat(cmd).contains("--no-playlist");
|
||||
assertThat(cmd).containsSequence("-f", "bv*[height<=1080]+ba/b[height<=1080]/b");
|
||||
assertThat(cmd).containsSequence("--merge-output-format", "mp4");
|
||||
assertThat(cmd).contains("-o");
|
||||
assertThat(cmd).endsWith("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
|
||||
// ffmpeg-location 이 비어있으면 해당 플래그는 빠진다
|
||||
assertThat(cmd).doesNotContain("--ffmpeg-location");
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildCommand_ffmpeg경로가_있으면_플래그를_포함하고_maxHeight를_반영한다() {
|
||||
List<String> cmd = VideoDownloadService.buildCommand(
|
||||
"yt-dlp", "D:\\utils\\ffmpeg\\bin", 720, "dQw4w9WgXcQ", Path.of("out.mp4"));
|
||||
|
||||
assertThat(cmd).containsSequence("--ffmpeg-location", "D:\\utils\\ffmpeg\\bin");
|
||||
assertThat(cmd).containsSequence("-f", "bv*[height<=720]+ba/b[height<=720]/b");
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildCommand_잘못된videoId면_예외를_던진다() {
|
||||
// 셸 메타문자(인젝션 시도)
|
||||
assertThatThrownBy(() -> VideoDownloadService.buildCommand(
|
||||
"yt-dlp", "", 1080, "; rm -rf /", Path.of("x.mp4")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
// 길이 부족
|
||||
assertThatThrownBy(() -> VideoDownloadService.buildCommand(
|
||||
"yt-dlp", "", 1080, "short", Path.of("x.mp4")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
// null
|
||||
assertThatThrownBy(() -> VideoDownloadService.buildCommand(
|
||||
"yt-dlp", "", 1080, null, Path.of("x.mp4")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user