From ff9609c9d1101d65999d4bf3bfd41cdc07826a38 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Thu, 25 Jun 2026 09:58:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(rework):=20=EB=B0=9B=EC=9D=80=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=EC=9D=84=20=EC=99=BC=EC=AA=BD=20=ED=94=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EC=97=90=20=EC=8B=A3=EA=B3=A0=20=ED=83=80?= =?UTF-8?q?=EC=9E=84=EB=9D=BC=EC=9D=B8=20=ED=81=B4=EB=A6=AD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20seek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원본 다운로드/캐시 보유 시 왼쪽 플레이어를 YouTube 임베드 대신 받은 원본 mp4로 전환한다. 세그먼트(타임라인) 클릭 시 기존 seekTo/하이라이트가 그대로 동작해 해당 지점으로 이동·재생된다. (업로드본이 있으면 업로드본 우선) - GET /{id}/download/file: 받은 원본 스트리밍(Resource 반환 → HTTP Range 206 자동 지원) - CurationService.cachedDownloadFile(id) - rework.html: showServerVideoInPlayer/showYoutubeInPlayer, updateCacheUI가 플레이어 전환, 다운로드 직후/진입 시 캐시 있으면 자동 적용, 삭제 시 YouTube로 복귀 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ChannelVideoCurationController.java | 13 +++++++ .../channel/ChannelVideoCurationService.java | 8 +++++ src/main/resources/templates/rework.html | 34 +++++++++++++++---- 3 files changed, 49 insertions(+), 6 deletions(-) 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 e0c7450..9ea3b03 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java @@ -156,6 +156,19 @@ public class ChannelVideoCurationController { return ApiResponse.ok(Map.of("deleted", deleted)); } + @GetMapping("/{id}/download/file") + @Operation(summary = "받은 원본 스트리밍", + description = "받아둔 원본 mp4를 왼쪽 플레이어 재생용으로 스트리밍한다. HTTP Range(구간요청) 지원으로 seek 가능. " + + "세그먼트(타임라인) 클릭 시 이 영상이 그 지점으로 이동/재생된다. 캐시 없으면 400.") + public ResponseEntity downloadFile(@PathVariable Long id) { + java.io.File file = curationService.cachedDownloadFile(id); + org.springframework.core.io.Resource resource = new org.springframework.core.io.FileSystemResource(file); + return ResponseEntity.ok() + .contentType(new MediaType("video", "mp4")) + .header(HttpHeaders.ACCEPT_RANGES, "bytes") + .body(resource); // Resource 반환 → Spring 이 Range 요청을 자동으로 206 처리 + } + @PostMapping(value = "/{id}/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "업로드 영상 전사(Whisper)", description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. " 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 7e726ff..92094a9 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java @@ -159,6 +159,14 @@ public class ChannelVideoCurationService { return videoDownloadService.deleteCache(v.getId()); } + /** 받아둔 원본 mp4 파일(왼쪽 플레이어 재생용 스트리밍). 캐시 없으면 안내 예외. */ + public java.io.File cachedDownloadFile(Long videoId) { + ChannelVideo v = find(videoId); + return videoDownloadService.cachedFile(v.getId()) + .orElseThrow(() -> new IllegalArgumentException( + "받은 원본이 없습니다. 먼저 '원본 다운로드'를 실행하세요.")); + } + /** 다운로드 캐시 파일로 말 없는 구간 제거(+배속) 렌더. 캐시 없으면 안내 예외. */ public byte[] renderTrimmedFromCached(Long videoId, double pad, double minGap, double speed) { ChannelVideo v = find(videoId); diff --git a/src/main/resources/templates/rework.html b/src/main/resources/templates/rework.html index 028b9ef..066a496 100644 --- a/src/main/resources/templates/rework.html +++ b/src/main/resources/templates/rework.html @@ -321,6 +321,22 @@ return (v && v.src) ? v : null; } + // 받아둔 원본 mp4를 왼쪽 플레이어에 싣는다(Range 스트리밍) → 세그먼트 클릭 시 그 지점 재생. + function showServerVideoInPlayer(){ + const v = document.getElementById('localVideo'); + const src = location.origin + API + '/' + VIDEO_ID + '/download/file'; + if(v.getAttribute('src') !== src){ v.src = src; } + v.style.display = 'block'; + document.getElementById('player').style.display = 'none'; + } + // 원본이 없을 때 YouTube 임베드로 되돌린다. + function showYoutubeInPlayer(){ + const v = document.getElementById('localVideo'); + v.removeAttribute('src'); if(v.load) v.load(); + v.style.display = 'none'; + document.getElementById('player').style.display = 'block'; + } + function renderSegments(segs){ SEGMENTS = Array.isArray(segs) ? segs : []; trimApplied = false; REMAPPED = []; @@ -462,8 +478,8 @@ const lang = document.getElementById('langSel').value; const url = API + '/' + VIDEO_ID + '/download' + (lang ? ('?language=' + lang) : ''); const s = await api(url, { method:'POST' }); - updateCacheUI(true, s.sizeBytes || 0); // 서버 캐시 보유 → 렌더 캐시 사용 + 삭제버튼 노출 - UPLOADED_FILE = null; + UPLOADED_FILE = null; // 다운로드본으로 전환(업로드본보다 우선순위 양보) + updateCacheUI(true, s.sizeBytes || 0); // 캐시 보유 → 삭제버튼 노출 + 왼쪽 플레이어를 받은 원본으로 renderSegments(s.segments); document.getElementById('transcript').value = s.transcript || ''; status.style.color = '#4ade80'; @@ -484,10 +500,16 @@ function updateCacheUI(cached, sizeBytes){ HAS_SERVER_FILE = !!cached; const btn = document.getElementById('deleteDlBtn'); - if(!btn) return; - btn.style.display = cached ? '' : 'none'; - if(cached && sizeBytes){ - btn.title = '서버에 받아둔 원본(' + (sizeBytes/1048576).toFixed(1) + 'MB) 삭제(자막은 유지)'; + if(btn){ + btn.style.display = cached ? '' : 'none'; + if(cached && sizeBytes){ + btn.title = '서버에 받아둔 원본(' + (sizeBytes/1048576).toFixed(1) + 'MB) 삭제(자막은 유지)'; + } + } + // 업로드본이 없을 때만 플레이어를 서버 원본/YouTube로 전환(업로드본이 우선) + if(!UPLOADED_FILE){ + if(cached) showServerVideoInPlayer(); + else showYoutubeInPlayer(); } }