feat(rework): 받은 원본을 왼쪽 플레이어에 싣고 타임라인 클릭으로 seek

원본 다운로드/캐시 보유 시 왼쪽 플레이어를 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) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-25 09:58:23 +09:00
parent 986dcffd55
commit ff9609c9d1
3 changed files with 49 additions and 6 deletions

View File

@ -156,6 +156,19 @@ public class ChannelVideoCurationController {
return ApiResponse.ok(Map.of("deleted", deleted)); return ApiResponse.ok(Map.of("deleted", deleted));
} }
@GetMapping("/{id}/download/file")
@Operation(summary = "받은 원본 스트리밍",
description = "받아둔 원본 mp4를 왼쪽 플레이어 재생용으로 스트리밍한다. HTTP Range(구간요청) 지원으로 seek 가능. "
+ "세그먼트(타임라인) 클릭 시 이 영상이 그 지점으로 이동/재생된다. 캐시 없으면 400.")
public ResponseEntity<org.springframework.core.io.Resource> 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) @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)로 보내 영상 싱크 세그먼트를 추출·저장한다. "

View File

@ -159,6 +159,14 @@ public class ChannelVideoCurationService {
return videoDownloadService.deleteCache(v.getId()); 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) { public byte[] renderTrimmedFromCached(Long videoId, double pad, double minGap, double speed) {
ChannelVideo v = find(videoId); ChannelVideo v = find(videoId);

View File

@ -321,6 +321,22 @@
return (v && v.src) ? v : null; 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){ function renderSegments(segs){
SEGMENTS = Array.isArray(segs) ? segs : []; SEGMENTS = Array.isArray(segs) ? segs : [];
trimApplied = false; REMAPPED = []; trimApplied = false; REMAPPED = [];
@ -462,8 +478,8 @@
const lang = document.getElementById('langSel').value; const lang = document.getElementById('langSel').value;
const url = API + '/' + VIDEO_ID + '/download' + (lang ? ('?language=' + lang) : ''); const url = API + '/' + VIDEO_ID + '/download' + (lang ? ('?language=' + lang) : '');
const s = await api(url, { method:'POST' }); 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); renderSegments(s.segments);
document.getElementById('transcript').value = s.transcript || ''; document.getElementById('transcript').value = s.transcript || '';
status.style.color = '#4ade80'; status.style.color = '#4ade80';
@ -484,12 +500,18 @@
function updateCacheUI(cached, sizeBytes){ function updateCacheUI(cached, sizeBytes){
HAS_SERVER_FILE = !!cached; HAS_SERVER_FILE = !!cached;
const btn = document.getElementById('deleteDlBtn'); const btn = document.getElementById('deleteDlBtn');
if(!btn) return; if(btn){
btn.style.display = cached ? '' : 'none'; btn.style.display = cached ? '' : 'none';
if(cached && sizeBytes){ if(cached && sizeBytes){
btn.title = '서버에 받아둔 원본(' + (sizeBytes/1048576).toFixed(1) + 'MB) 삭제(자막은 유지)'; btn.title = '서버에 받아둔 원본(' + (sizeBytes/1048576).toFixed(1) + 'MB) 삭제(자막은 유지)';
} }
} }
// 업로드본이 없을 때만 플레이어를 서버 원본/YouTube로 전환(업로드본이 우선)
if(!UPLOADED_FILE){
if(cached) showServerVideoInPlayer();
else showYoutubeInPlayer();
}
}
// 받아둔 원본 mp4 삭제(자막/세그먼트는 유지) // 받아둔 원본 mp4 삭제(자막/세그먼트는 유지)
async function deleteDownload(){ async function deleteDownload(){