fix(rework): 원본 다운로드와 전사 분리 — 전사 지연/실패에 안 갇히게
기존엔 '원본 다운로드'가 다운로드+Whisper전사를 한 동기호출로 처리해, 전사 서버
(h-python)가 느리거나 타임아웃나면 화면이 '다운로드 중'에서 안 풀렸다.
다운로드(빠름)와 전사(분리)를 나눠, 다운로드는 즉시 끝나고 영상이 바로 뜨며
전사가 실패해도 다운로드 결과는 유지(화면자막 OCR/재시도 가능).
- POST /{id}/download: 다운로드만(캐시), 응답 {downloaded, sizeBytes}
- POST /{id}/transcribe-cached: 받은 원본 전사(분리)
- downloadOriginal(): 1)다운로드 즉시완료·영상표시 2)전사 별도(실패해도 다운로드 유지)
검증: /download 2.7초 완료. 전사는 분리 단계로 진행/실패 격리.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6a0a7294bb
commit
bba81760a2
@ -134,13 +134,21 @@ public class ChannelVideoCurationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/download")
|
@PostMapping("/{id}/download")
|
||||||
@Operation(summary = "원본 다운로드 + 자동 전사",
|
@Operation(summary = "원본 다운로드(캐시만)",
|
||||||
description = "저장된 videoId 로 yt-dlp 가 원본 영상을 받아 서버에 캐시하고, 그 파일을 바로 Whisper 로 전사한다. "
|
description = "저장된 videoId 로 yt-dlp 가 원본 영상을 받아 서버에 캐시한다(빠름, 전사 제외). "
|
||||||
+ "쿼리: language(선택: ko|en|zh|ja… 비우면 자동감지). 응답: {downloaded, sizeBytes, hasScript, language, duration, transcript, segments}. "
|
+ "전사는 분리된 /{id}/transcribe-cached 로 진행 → 전사 서버가 느려도 다운로드는 잃지 않는다. "
|
||||||
+ "수동 다운로드+업로드 단계를 제거. 이후 렌더도 같은 캐시 파일을 재사용한다.")
|
+ "응답: {downloaded, sizeBytes}. 이후 전사·렌더·OCR 가 이 캐시 파일을 재사용한다.")
|
||||||
public ApiResponse<Map<String, Object>> download(@PathVariable Long id,
|
public ApiResponse<Map<String, Object>> download(@PathVariable Long id) {
|
||||||
@RequestParam(value = "language", required = false) String language) {
|
return ApiResponse.ok(curationService.downloadOriginal(id));
|
||||||
return ApiResponse.ok(curationService.downloadAndTranscribe(id, language));
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/transcribe-cached")
|
||||||
|
@Operation(summary = "받은 원본 전사(Whisper)",
|
||||||
|
description = "'원본 다운로드'로 받아둔 캐시 파일을 Whisper 로 전사해 세그먼트를 저장한다(다운로드와 분리). "
|
||||||
|
+ "쿼리: language(선택, 비우면 자동감지). 응답: {hasScript, language, duration, transcript, segments}.")
|
||||||
|
public ApiResponse<Map<String, Object>> transcribeCached(@PathVariable Long id,
|
||||||
|
@RequestParam(value = "language", required = false) String language) {
|
||||||
|
return ApiResponse.ok(curationService.transcribeCached(id, language));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/download")
|
@GetMapping("/{id}/download")
|
||||||
|
|||||||
@ -124,18 +124,33 @@ public class ChannelVideoCurationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 저장된 videoId 로 원본을 yt-dlp 로 받아 서버에 캐시하고, 그 파일을 바로 Whisper 로 전사한다.
|
* 저장된 videoId 로 원본을 yt-dlp 로 받아 서버에 캐시만 한다(빠름). 전사는 분리된 단계로 진행.
|
||||||
* 수동 다운로드+업로드 단계를 제거한다. language=null 이면 자동감지.
|
* → 전사 서버가 느리거나 실패해도 다운로드 결과(영상)는 잃지 않는다.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public Map<String, Object> downloadAndTranscribe(Long videoId, String language) {
|
public Map<String, Object> downloadOriginal(Long videoId) {
|
||||||
ChannelVideo v = find(videoId);
|
ChannelVideo v = find(videoId);
|
||||||
java.io.File file = videoDownloadService.download(v.getId());
|
java.io.File file = videoDownloadService.download(v.getId());
|
||||||
ScriptResponseDto dto = channelService.transcribeFromCached(v.getId(), file, language);
|
|
||||||
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.put("downloaded", true);
|
result.put("downloaded", true);
|
||||||
result.put("sizeBytes", file.length());
|
result.put("sizeBytes", file.length());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 받아둔 원본 캐시 파일을 Whisper 로 전사한다(다운로드와 분리된 단계). language=null 이면 자동감지.
|
||||||
|
* 캐시 없으면 안내 예외.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> transcribeCached(Long videoId, String language) {
|
||||||
|
ChannelVideo v = find(videoId);
|
||||||
|
java.io.File file = videoDownloadService.cachedFile(v.getId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException(
|
||||||
|
"받은 원본이 없습니다. 먼저 '원본 다운로드'를 실행하세요."));
|
||||||
|
ScriptResponseDto dto = channelService.transcribeFromCached(v.getId(), file, language);
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.put("hasScript", true);
|
result.put("hasScript", true);
|
||||||
result.put("language", dto.getLanguage());
|
result.put("language", dto.getLanguage());
|
||||||
result.put("duration", dto.getDuration());
|
result.put("duration", dto.getDuration());
|
||||||
|
|||||||
@ -510,22 +510,36 @@
|
|||||||
btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '다운로드 중…';
|
btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '다운로드 중…';
|
||||||
status.style.display = 'block'; status.style.color = '#facc15';
|
status.style.display = 'block'; status.style.color = '#facc15';
|
||||||
status.textContent = '원본 다운로드 중… (yt-dlp, 영상 길이에 따라 수십 초~수 분)';
|
status.textContent = '원본 다운로드 중… (yt-dlp, 영상 길이에 따라 수십 초~수 분)';
|
||||||
|
// 1단계: 다운로드(빠름). 실패하면 여기서 종료.
|
||||||
try {
|
try {
|
||||||
const lang = document.getElementById('langSel').value;
|
const d = await api(API + '/' + VIDEO_ID + '/download', { method:'POST' });
|
||||||
const url = API + '/' + VIDEO_ID + '/download' + (lang ? ('?language=' + lang) : '');
|
|
||||||
const s = await api(url, { method:'POST' });
|
|
||||||
UPLOADED_FILE = null; // 다운로드본으로 전환(업로드본보다 우선순위 양보)
|
UPLOADED_FILE = null; // 다운로드본으로 전환(업로드본보다 우선순위 양보)
|
||||||
updateCacheUI(true, s.sizeBytes || 0); // 캐시 보유 → 삭제버튼 노출 + 왼쪽 플레이어를 받은 원본으로
|
updateCacheUI(true, d.sizeBytes || 0); // 캐시 보유 → 삭제버튼 노출 + 왼쪽 플레이어를 받은 원본으로
|
||||||
renderSegments(s.segments);
|
const mb = d.sizeBytes ? ((d.sizeBytes/1048576).toFixed(1) + 'MB') : '';
|
||||||
document.getElementById('transcript').value = s.transcript || '';
|
|
||||||
status.style.color = '#4ade80';
|
status.style.color = '#4ade80';
|
||||||
const mb = s.sizeBytes ? ((s.sizeBytes/1048576).toFixed(1) + 'MB · ') : '';
|
status.textContent = '다운로드 완료 ✓ ' + mb + ' · 전사 중…';
|
||||||
status.textContent = '다운로드+전사 완료 · ' + mb + (s.segments ? s.segments.length : 0) + '개 세그먼트'
|
|
||||||
+ (s.language ? (' · ' + s.language) : '')
|
|
||||||
+ (s.duration ? (' · ' + mmss(s.duration)) : '');
|
|
||||||
} catch(e){
|
} catch(e){
|
||||||
status.style.color = '#f87171';
|
status.style.color = '#f87171';
|
||||||
status.textContent = '다운로드 실패: ' + e.message;
|
status.textContent = '다운로드 실패: ' + e.message;
|
||||||
|
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
|
||||||
|
if(window.lucide) lucide.createIcons();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 2단계: 전사(분리). 실패해도 다운로드(영상)는 유지 → 화면자막 OCR/재시도 가능.
|
||||||
|
try {
|
||||||
|
const lang = document.getElementById('langSel').value;
|
||||||
|
const url = API + '/' + VIDEO_ID + '/transcribe-cached' + (lang ? ('?language=' + lang) : '');
|
||||||
|
const s = await api(url, { method:'POST' });
|
||||||
|
renderSegments(s.segments);
|
||||||
|
document.getElementById('transcript').value = s.transcript || '';
|
||||||
|
status.style.color = '#4ade80';
|
||||||
|
status.textContent = '다운로드+전사 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트'
|
||||||
|
+ (s.language ? (' · ' + s.language) : '')
|
||||||
|
+ (s.duration ? (' · ' + mmss(s.duration)) : '');
|
||||||
|
} catch(e){
|
||||||
|
status.style.color = '#fbbf24';
|
||||||
|
status.textContent = '다운로드는 완료 ✓ · 전사 실패(서버 혼잡일 수 있음): ' + e.message
|
||||||
|
+ ' — 잠시 후 다시 전사하거나 ‘화면 자막 OCR’을 쓰세요';
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
|
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
|
||||||
if(window.lucide) lucide.createIcons();
|
if(window.lucide) lucide.createIcons();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user