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:
hehihoho3@gmail.com 2026-06-24 15:58:50 +09:00
parent a177b0c987
commit 26b8be1ff1
8 changed files with 345 additions and 10 deletions

3
.gitignore vendored
View File

@ -15,6 +15,9 @@ src/main/resources/application-local.yml
tokens/ tokens/
*.log *.log
# yt-dlp 다운로드 캐시(영상 파일은 커밋하지 않음)
/downloads/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db

View File

@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; 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.multipart.MultipartFile;
import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
import java.io.File;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
@ -393,19 +395,34 @@ public class ChannelService {
*/ */
@Transactional @Transactional
public ScriptResponseDto transcribeFromFile(Long channelVideoId, MultipartFile file, String language) { 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) ChannelVideo video = channelVideoRepository.findById(channelVideoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId)); .orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
String apiUrl = pythonBaseUrl + "/transcribe"; String apiUrl = pythonBaseUrl + "/transcribe";
log.info("Requesting whisper transcription for video {} ({} bytes, lang={})", log.info("Requesting whisper transcription for video {} ({} bytes, lang={})",
channelVideoId, file.getSize(), language); channelVideoId, sizeBytes, language);
try { try {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA); headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>(); MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", toFileResource(file)); body.add("file", resource);
if (language != null && !language.isBlank()) { if (language != null && !language.isBlank()) {
body.add("language", language.trim()); // 자동감지 오류 보정용(ko/en/zh/ja ) body.add("language", language.trim()); // 자동감지 오류 보정용(ko/en/zh/ja )
} }
@ -478,6 +495,20 @@ public class ChannelService {
* keep 구간은 저장된 세그먼트로 서버가 계산하므로 미리보기와 정확히 일치한다. * keep 구간은 저장된 세그먼트로 서버가 계산하므로 미리보기와 정확히 일치한다.
*/ */
public byte[] renderTrimmed(Long channelVideoId, MultipartFile file, double pad, double minGap, double speed) { 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); List<ScriptSegment> segments = getSegments(channelVideoId);
if (segments.isEmpty()) { if (segments.isEmpty()) {
// 사용자에게 이유가 보이도록 400(IllegalArgumentException GlobalExceptionHandler 메시지 노출). // 사용자에게 이유가 보이도록 400(IllegalArgumentException GlobalExceptionHandler 메시지 노출).
@ -491,7 +522,7 @@ public class ChannelService {
headers.setContentType(MediaType.MULTIPART_FORM_DATA); headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>(); 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("keep", objectMapper.writeValueAsString(plan.keep())); // [{"start":..,"end":..},...]
body.add("speed", String.valueOf(speed)); 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(파일명 보존). */ /** MultipartFile → multipart 전송용 Resource(파일명 보존). */
private Resource toFileResource(MultipartFile file) throws java.io.IOException { private Resource toFileResource(MultipartFile file) throws java.io.IOException {
final String filename = (file.getOriginalFilename() != null && !file.getOriginalFilename().isBlank()) final String filename = (file.getOriginalFilename() != null && !file.getOriginalFilename().isBlank())

View File

@ -133,6 +133,16 @@ public class ChannelVideoCurationController {
return ApiResponse.ok(Map.of("hasScript", t != null, "transcript", t == null ? "" : t)); 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) @PostMapping(value = "/{id}/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "업로드 영상 전사(Whisper)", @Operation(summary = "업로드 영상 전사(Whisper)",
description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. " description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. "
@ -156,14 +166,17 @@ public class ChannelVideoCurationController {
@PostMapping(value = "/{id}/render", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/{id}/render", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "말 없는 구간 제거(+배속) 영상 렌더", @Operation(summary = "말 없는 구간 제거(+배속) 영상 렌더",
description = "업로드 영상에서 말 없는 구간을 잘라낸(+배속) 영상을 ffmpeg로 만들어 mp4 다운로드. " description = "영상에서 말 없는 구간을 잘라낸(+배속) 영상을 ffmpeg로 만들어 mp4 다운로드. "
+ "multipart: file, pad, minGap, speed. keep 구간은 저장 세그먼트로 서버가 계산(미리보기와 일치).") + "multipart: file(선택), pad, minGap, speed. file 을 보내면 그 업로드본을, 없으면 '원본 다운로드'로 받은 "
+ "서버 캐시 파일을 사용한다(캐시 없으면 400). keep 구간은 저장 세그먼트로 서버가 계산(미리보기와 일치).")
public ResponseEntity<byte[]> render(@PathVariable Long id, 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.15") double pad,
@RequestParam(defaultValue = "0.3") double minGap, @RequestParam(defaultValue = "0.3") double minGap,
@RequestParam(defaultValue = "1.0") double speed) { @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(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(new MediaType("video", "mp4")); headers.setContentType(new MediaType("video", "mp4"));
headers.setContentDisposition( headers.setContentDisposition(

View File

@ -24,6 +24,7 @@ public class ChannelVideoCurationService {
private final ChannelVideoRepository channelVideoRepository; private final ChannelVideoRepository channelVideoRepository;
private final ChannelVideoScriptRepository channelVideoScriptRepository; private final ChannelVideoScriptRepository channelVideoScriptRepository;
private final ChannelService channelService; 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_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");
@ -121,6 +122,36 @@ public class ChannelVideoCurationService {
return result; 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=원본). */ /** 저장된 세그먼트로 SRT 문자열을 생성한다. speed 는 배속(1.0=원본). */
public String buildSrt(Long videoId, double speed) { public String buildSrt(Long videoId, double speed) {
ChannelVideo v = find(videoId); ChannelVideo v = find(videoId);

View File

@ -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;
}
}

View File

@ -54,6 +54,15 @@ youtube:
python: python:
base-url: ${PYTHON_BASE_URL:http://h-python.tolag.shop} 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: hlab:
# 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집 # 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집
scheduler: scheduler:

View File

@ -78,7 +78,10 @@
<option value="zh">中文</option> <option value="zh">中文</option>
<option value="ja">日本語</option> <option value="ja">日本語</option>
</select> </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> 영상 업로드·전사 <i data-lucide="upload" style="width:15px;"></i> 영상 업로드·전사
<input type="file" id="videoFile" accept="video/*" style="display:none;" onchange="onVideoSelected(event)"> <input type="file" id="videoFile" accept="video/*" style="display:none;" onchange="onVideoSelected(event)">
</label> </label>
@ -236,6 +239,7 @@
// ===== 세그먼트(영상 싱크) ===== // ===== 세그먼트(영상 싱크) =====
let SEGMENTS = []; // 전사 원본 세그먼트(원본 영상 재생에 싱크) let SEGMENTS = []; // 전사 원본 세그먼트(원본 영상 재생에 싱크)
let UPLOADED_FILE = null; // 업로드한 영상 File (렌더 재전송용) let UPLOADED_FILE = null; // 업로드한 영상 File (렌더 재전송용)
let HAS_SERVER_FILE = false; // '원본 다운로드'로 서버에 캐시된 영상 보유 여부(렌더가 업로드 없이 재사용)
let trimApplied = false; // 말 없는 구간 제거 미리보기 적용 여부 let trimApplied = false; // 말 없는 구간 제거 미리보기 적용 여부
let REMAPPED = []; // 무음 제거 후(배속 전) 세그먼트 let REMAPPED = []; // 무음 제거 후(배속 전) 세그먼트
@ -349,7 +353,7 @@
} }
async function renderVideo(){ 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 pad = parseFloat(document.getElementById('trimPad').value) || 0;
const gap = parseFloat(document.getElementById('trimGap').value) || 0; const gap = parseFloat(document.getElementById('trimGap').value) || 0;
const speed = curSpeed(); const speed = curSpeed();
@ -360,7 +364,7 @@
status.textContent = '영상 렌더링 중… (자르기+인코딩, 잠시 걸립니다)'; status.textContent = '영상 렌더링 중… (자르기+인코딩, 잠시 걸립니다)';
try { try {
const fd = new FormData(); 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); fd.append('pad', pad); fd.append('minGap', gap); fd.append('speed', speed);
const res = await fetch(API + '/' + VIDEO_ID + '/render', { method:'POST', body: fd }); const res = await fetch(API + '/' + VIDEO_ID + '/render', { method:'POST', body: fd });
if(!res.ok){ 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){ async function onVideoSelected(ev){
const file = ev.target.files && ev.target.files[0]; const file = ev.target.files && ev.target.files[0];
if(!file) return; if(!file) return;
UPLOADED_FILE = file; // 렌더(자르기) 재전송용으로 보관 UPLOADED_FILE = file; // 렌더(자르기) 재전송용으로 보관
HAS_SERVER_FILE = false; // 업로드본 우선
// 1) 업로드한 파일을 로컬에서 즉시 재생(<video>) + YouTube iframe 숨김 // 1) 업로드한 파일을 로컬에서 즉시 재생(<video>) + YouTube iframe 숨김
const v = document.getElementById('localVideo'); const v = document.getElementById('localVideo');

View File

@ -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);
}
}