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:
parent
986dcffd55
commit
ff9609c9d1
@ -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)로 보내 영상 싱크 세그먼트를 추출·저장한다. "
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,10 +500,16 @@
|
|||||||
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user