diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java index e7f662f..4685e3a 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java @@ -134,13 +134,21 @@ public class ChannelVideoCurationController { } @PostMapping("/{id}/download") - @Operation(summary = "원본 다운로드 + 자동 전사", - description = "저장된 videoId 로 yt-dlp 가 원본 영상을 받아 서버에 캐시하고, 그 파일을 바로 Whisper 로 전사한다. " - + "쿼리: language(선택: ko|en|zh|ja… 비우면 자동감지). 응답: {downloaded, sizeBytes, hasScript, language, duration, transcript, segments}. " - + "수동 다운로드+업로드 단계를 제거. 이후 렌더도 같은 캐시 파일을 재사용한다.") - public ApiResponse> download(@PathVariable Long id, - @RequestParam(value = "language", required = false) String language) { - return ApiResponse.ok(curationService.downloadAndTranscribe(id, language)); + @Operation(summary = "원본 다운로드(캐시만)", + description = "저장된 videoId 로 yt-dlp 가 원본 영상을 받아 서버에 캐시한다(빠름, 전사 제외). " + + "전사는 분리된 /{id}/transcribe-cached 로 진행 → 전사 서버가 느려도 다운로드는 잃지 않는다. " + + "응답: {downloaded, sizeBytes}. 이후 전사·렌더·OCR 가 이 캐시 파일을 재사용한다.") + public ApiResponse> download(@PathVariable Long id) { + return ApiResponse.ok(curationService.downloadOriginal(id)); + } + + @PostMapping("/{id}/transcribe-cached") + @Operation(summary = "받은 원본 전사(Whisper)", + description = "'원본 다운로드'로 받아둔 캐시 파일을 Whisper 로 전사해 세그먼트를 저장한다(다운로드와 분리). " + + "쿼리: language(선택, 비우면 자동감지). 응답: {hasScript, language, duration, transcript, segments}.") + public ApiResponse> transcribeCached(@PathVariable Long id, + @RequestParam(value = "language", required = false) String language) { + return ApiResponse.ok(curationService.transcribeCached(id, language)); } @GetMapping("/{id}/download") diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java index 980de56..73cb250 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java @@ -124,18 +124,33 @@ public class ChannelVideoCurationService { } /** - * 저장된 videoId 로 원본을 yt-dlp 로 받아 서버에 캐시하고, 그 파일을 바로 Whisper 로 전사한다. - * 수동 다운로드+업로드 단계를 제거한다. language=null 이면 자동감지. + * 저장된 videoId 로 원본을 yt-dlp 로 받아 서버에 캐시만 한다(빠름). 전사는 분리된 단계로 진행. + * → 전사 서버가 느리거나 실패해도 다운로드 결과(영상)는 잃지 않는다. */ @Transactional - public Map downloadAndTranscribe(Long videoId, String language) { + public Map downloadOriginal(Long videoId) { ChannelVideo v = find(videoId); java.io.File file = videoDownloadService.download(v.getId()); - ScriptResponseDto dto = channelService.transcribeFromCached(v.getId(), file, language); Map result = new LinkedHashMap<>(); result.put("downloaded", true); result.put("sizeBytes", file.length()); + return result; + } + + /** + * 받아둔 원본 캐시 파일을 Whisper 로 전사한다(다운로드와 분리된 단계). language=null 이면 자동감지. + * 캐시 없으면 안내 예외. + */ + @Transactional + public Map 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 result = new LinkedHashMap<>(); result.put("hasScript", true); result.put("language", dto.getLanguage()); result.put("duration", dto.getDuration()); diff --git a/src/main/resources/templates/rework.html b/src/main/resources/templates/rework.html index de5f173..3f054f7 100644 --- a/src/main/resources/templates/rework.html +++ b/src/main/resources/templates/rework.html @@ -510,22 +510,36 @@ btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '다운로드 중…'; status.style.display = 'block'; status.style.color = '#facc15'; status.textContent = '원본 다운로드 중… (yt-dlp, 영상 길이에 따라 수십 초~수 분)'; + // 1단계: 다운로드(빠름). 실패하면 여기서 종료. try { - const lang = document.getElementById('langSel').value; - const url = API + '/' + VIDEO_ID + '/download' + (lang ? ('?language=' + lang) : ''); - const s = await api(url, { method:'POST' }); + const d = await api(API + '/' + VIDEO_ID + '/download', { method:'POST' }); UPLOADED_FILE = null; // 다운로드본으로 전환(업로드본보다 우선순위 양보) - updateCacheUI(true, s.sizeBytes || 0); // 캐시 보유 → 삭제버튼 노출 + 왼쪽 플레이어를 받은 원본으로 - renderSegments(s.segments); - document.getElementById('transcript').value = s.transcript || ''; + updateCacheUI(true, d.sizeBytes || 0); // 캐시 보유 → 삭제버튼 노출 + 왼쪽 플레이어를 받은 원본으로 + const mb = d.sizeBytes ? ((d.sizeBytes/1048576).toFixed(1) + 'MB') : ''; 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)) : ''); + status.textContent = '다운로드 완료 ✓ ' + mb + ' · 전사 중…'; } catch(e){ status.style.color = '#f87171'; 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 { btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig; if(window.lucide) lucide.createIcons();