diff --git a/docs/superpowers/specs/2026-06-12-subtitle-timeline-studio-design.md b/docs/superpowers/specs/2026-06-12-subtitle-timeline-studio-design.md index 96bed64..9f078e6 100644 --- a/docs/superpowers/specs/2026-06-12-subtitle-timeline-studio-design.md +++ b/docs/superpowers/specs/2026-06-12-subtitle-timeline-studio-design.md @@ -149,17 +149,27 @@ async def transcribe(file: UploadFile = File(...), - SRT 내보내기에 `speed` 적용: 모든 `start/end`를 `/ speed`. (예: 1.2x → 시간 0.833배) - 에디터에 배속 미리보기(선택). 저장 데이터는 원본 타임스탬프 유지, 내보내기 시점에만 변환. -## 7. Phase 3 — 무음 제거 +## 7. Phase 3 — "말 없는 구간" 제거 (구현됨) -- 업로드 영상을 서버에 영속화(또는 재업로드) → Python `/silence`(ffmpeg `silencedetect`)로 무음 구간 목록 확보. -- 무음 구간 제거 후 남는 구간을 이어붙이는 타임라인 재매핑 → 세그먼트 타임스탬프 보정 → SRT 반영. -- 배속과 조합 가능(무음 제거 후 배속). +> **실측 기반 설계 교정**: 당초 오디오 무음(ffmpeg `silencedetect`)을 가정했으나, 사용자 콘텐츠(음악 깔린 Shorts)는 `mean_volume -12dB`로 **오디오 무음이 0개**라 아무것도 못 자른다. 따라서 제거 기준을 **Whisper 세그먼트 기반 "말(speech) 없는 구간"** 으로 변경했다. 음악과 무관하게 "아무도 말 안 하는 구간"을 잘라 타이트하게 만든다. (실측: 영상테스트.mp4 29s → 말 0~9.75s만 남김) + +- **keep/remove 계산(순수, Java)**: `KeepIntervalPlanner.plan(segments, pad, minGap)`. + - `pad`: 말 구간 앞뒤 여백(기본 0.15s). `minGap`: 이하 간격은 안 끊음(기본 0.3s). + - keep = 패딩·병합된 말 구간. remove = 선행 공백 + keep 사이 간격. +- **자막 재매핑(순수, Java)**: `TimelineRemapper.remap(segments, removeIntervals)` — `f(t)=t-(t이전 제거량)`. 둘 다 단위테스트 완비. +- **미리보기**: `GET /{id}/trim-plan?pad=&minGap=` → keep/remove/재매핑 세그먼트/예상 길이(영상 파일 불필요). +- **영상 렌더**: `POST /{id}/render`(multipart: file, pad, minGap, speed) → 서버가 keep 계산 → Python `/render` 호출 → ffmpeg `trim/atrim+concat`(+`setpts`/`atempo` 배속) → mp4 스트림 다운로드. 필터그래프 로컬 실측 검증(29s+1.2x → 8.17s). +- **SRT**: 프론트에서 작업 세그먼트(말-구간 제거 시 재매핑본)+배속으로 클라이언트 생성 → 영상과 동일 타임라인. CapCut에 영상+SRT 함께 import. +- **Python `/render`**: trim/atrim+concat 필터그래프, atempo는 0.5~2.0 범위 분해. (사용자가 적용) + +### 언어 선택 (Phase 1 보강) +- Whisper 자동감지가 틀릴 수 있음(영상테스트.mp4가 zh로 감지됨) → `/transcribe`에 `language` 폼 필드 전달, 에디터에 언어 선택(자동/ko/en/zh/ja) 노출. ## 8. 범위 밖(YAGNI) - 가로 파형 타임라인 + 클립 드래그 같은 본격 CapCut형 UI(무거움). Phase 1은 "재생 동기 세그먼트 리스트"로 시작. - CapCut 네이티브 draft(.json) 직접 생성. 표준 SRT import로 충분. -- 실제 영상 렌더링(배속/컷 적용된 mp4 출력)은 CapCut이 담당. +- 오디오 무음(silencedetect) 기반 제거 — 음악 깔린 콘텐츠엔 무용. 필요 시 향후 옵션으로. - 플랫폼 업로드 API 연동. ## 9. 열린 항목 diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java index 82fc9fc..2af9c35 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java @@ -391,28 +391,23 @@ public class ChannelService { * @return 추출 결과 DTO(segments/transcript/language/duration) */ @Transactional - public ScriptResponseDto transcribeFromFile(Long channelVideoId, MultipartFile file) { + public ScriptResponseDto transcribeFromFile(Long channelVideoId, MultipartFile file, String language) { ChannelVideo video = channelVideoRepository.findById(channelVideoId) .orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId)); String apiUrl = pythonBaseUrl + "/transcribe"; - log.info("Requesting whisper transcription for video {} ({} bytes)", channelVideoId, file.getSize()); + log.info("Requesting whisper transcription for video {} ({} bytes, lang={})", + channelVideoId, file.getSize(), language); try { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); - final String filename = (file.getOriginalFilename() != null && !file.getOriginalFilename().isBlank()) - ? file.getOriginalFilename() : "upload.mp4"; - Resource fileResource = new ByteArrayResource(file.getBytes()) { - @Override - public String getFilename() { - return filename; // multipart filename 보존 - } - }; - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("file", fileResource); + body.add("file", toFileResource(file)); + if (language != null && !language.isBlank()) { + body.add("language", language.trim()); // 자동감지 오류 보정용(ko/en/zh/ja 등) + } HttpEntity> request = new HttpEntity<>(body, headers); ResponseEntity response = restTemplate.postForEntity(apiUrl, request, String.class); @@ -477,6 +472,51 @@ public class ChannelService { } } + /** + * 업로드 영상에서 "말 없는 구간"을 잘라낸(+선택 배속) 영상을 Python /render(ffmpeg)로 만들어 바이트로 반환한다. + * keep 구간은 저장된 세그먼트로 서버가 계산하므로 미리보기와 정확히 일치한다. + */ + public byte[] renderTrimmed(Long channelVideoId, MultipartFile file, double pad, double minGap, double speed) { + List segments = getSegments(channelVideoId); + if (segments.isEmpty()) { + throw new IllegalStateException("세그먼트가 없습니다. 먼저 전사를 실행하세요."); + } + KeepIntervalPlanner.Plan plan = KeepIntervalPlanner.plan(segments, pad, minGap); + + String apiUrl = pythonBaseUrl + "/render"; + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", toFileResource(file)); + body.add("keep", objectMapper.writeValueAsString(plan.keep())); // [{"start":..,"end":..},...] + body.add("speed", String.valueOf(speed)); + + HttpEntity> request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(apiUrl, request, byte[].class); + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + throw new RuntimeException("Render API failed with status: " + response.getStatusCode()); + } + return response.getBody(); + } catch (Exception e) { + log.error("Error rendering trimmed video for " + channelVideoId, e); + throw new RuntimeException("Error rendering trimmed video", e); + } + } + + /** MultipartFile → multipart 전송용 Resource(파일명 보존). */ + private Resource toFileResource(MultipartFile file) throws java.io.IOException { + final String filename = (file.getOriginalFilename() != null && !file.getOriginalFilename().isBlank()) + ? file.getOriginalFilename() : "upload.mp4"; + return new ByteArrayResource(file.getBytes()) { + @Override + public String getFilename() { + return filename; + } + }; + } + @Transactional public void extractAllScripts(Long channelId) { List videos = channelVideoRepository.findByChannelId(channelId); 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 9a8a6a6..06be788 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java @@ -136,10 +136,40 @@ public class ChannelVideoCurationController { @PostMapping(value = "/{id}/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "업로드 영상 전사(Whisper)", description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. " - + "multipart 필드: file. 응답: {hasScript, language, duration, transcript, segments}.") + + "multipart 필드: file, language(선택: ko|en|zh|ja… 비우면 자동감지). " + + "응답: {hasScript, language, duration, transcript, segments}.") public ApiResponse> transcribe(@PathVariable Long id, - @RequestParam("file") MultipartFile file) { - return ApiResponse.ok(curationService.transcribeFromFile(id, file)); + @RequestParam("file") MultipartFile file, + @RequestParam(value = "language", required = false) String language) { + return ApiResponse.ok(curationService.transcribeFromFile(id, file, language)); + } + + @GetMapping("/{id}/trim-plan") + @Operation(summary = "말 없는 구간 제거 미리보기", + description = "저장된 세그먼트로 keep/remove 구간과 재매핑된 세그먼트, 예상 길이를 계산(영상 파일 불필요). " + + "pad(말 앞뒤 여백초, 기본0.15), minGap(이하 간격은 안 끊음, 기본0.3).") + public ApiResponse> trimPlan(@PathVariable Long id, + @RequestParam(defaultValue = "0.15") double pad, + @RequestParam(defaultValue = "0.3") double minGap) { + return ApiResponse.ok(curationService.trimPlan(id, pad, minGap)); + } + + @PostMapping(value = "/{id}/render", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "말 없는 구간 제거(+배속) 영상 렌더", + description = "업로드 영상에서 말 없는 구간을 잘라낸(+배속) 영상을 ffmpeg로 만들어 mp4 다운로드. " + + "multipart: file, pad, minGap, speed. keep 구간은 저장 세그먼트로 서버가 계산(미리보기와 일치).") + public ResponseEntity render(@PathVariable Long id, + @RequestParam("file") MultipartFile file, + @RequestParam(defaultValue = "0.15") double pad, + @RequestParam(defaultValue = "0.3") double minGap, + @RequestParam(defaultValue = "1.0") double speed) { + byte[] mp4 = curationService.renderTrimmed(id, file, pad, minGap, speed); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(new MediaType("video", "mp4")); + headers.setContentDisposition( + org.springframework.http.ContentDisposition.attachment() + .filename("trimmed_" + id + ".mp4", StandardCharsets.UTF_8).build()); + return ResponseEntity.ok().headers(headers).body(mp4); } @GetMapping("/{id}/script.srt") 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 45f2be6..3e92fb1 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java @@ -107,11 +107,11 @@ public class ChannelVideoCurationService { return result; } - /** 업로드 영상 파일을 Whisper 로 전사해 영상 싱크 세그먼트를 저장하고 결과를 반환한다. */ + /** 업로드 영상 파일을 Whisper 로 전사해 영상 싱크 세그먼트를 저장하고 결과를 반환한다. language=null 이면 자동감지. */ @Transactional - public Map transcribeFromFile(Long videoId, MultipartFile file) { + public Map transcribeFromFile(Long videoId, MultipartFile file, String language) { ChannelVideo v = find(videoId); - ScriptResponseDto dto = channelService.transcribeFromFile(v.getId(), file); + ScriptResponseDto dto = channelService.transcribeFromFile(v.getId(), file, language); Map result = new LinkedHashMap<>(); result.put("hasScript", true); @@ -128,6 +128,31 @@ public class ChannelVideoCurationService { return SrtFormatter.toSrt(channelService.getSegments(v.getId()), speed); } + /** + * 말 없는 구간 제거 계획(미리보기): keep/remove 구간 + 재매핑된 세그먼트 + 예상 길이. + * 영상 파일 없이 저장된 세그먼트만으로 계산한다. + */ + public Map trimPlan(Long videoId, double pad, double minGap) { + ChannelVideo v = find(videoId); + List segments = channelService.getSegments(v.getId()); + KeepIntervalPlanner.Plan plan = KeepIntervalPlanner.plan(segments, pad, minGap); + List remapped = TimelineRemapper.remap(segments, plan.remove()); + + Map result = new LinkedHashMap<>(); + result.put("keep", plan.keep()); + result.put("remove", plan.remove()); + result.put("removedCount", plan.remove().size()); + result.put("keptDuration", KeepIntervalPlanner.keptDuration(plan)); + result.put("segments", remapped); // 무음 제거 후 타임라인(배속 전) + return result; + } + + /** 말 없는 구간 제거(+배속) 영상을 렌더링해 바이트로 반환한다. */ + public byte[] renderTrimmed(Long videoId, MultipartFile file, double pad, double minGap, double speed) { + ChannelVideo v = find(videoId); + return channelService.renderTrimmed(v.getId(), file, pad, minGap, speed); + } + /** 재작성 초안 저장 + 상태를 TARGET 으로 승격(아직 NEW/REVIEWING 이면). */ @Transactional public ChannelVideo saveRework(Long videoId, String text) { diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/KeepIntervalPlanner.java b/src/main/java/com/hlab/yanalyst/domain/channel/KeepIntervalPlanner.java new file mode 100644 index 0000000..21989bf --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/KeepIntervalPlanner.java @@ -0,0 +1,75 @@ +package com.hlab.yanalyst.domain.channel; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * 말(speech) 세그먼트로부터 "남길 구간(keep)"과 "잘라낼 구간(remove)"을 계산하는 순수 유틸. + * + *

오디오 무음(silencedetect)이 아니라 Whisper 세그먼트 기반으로 "아무도 말하지 않는 구간"을 + * 잘라 영상을 타이트하게 만든다(배경음악이 깔려 있어도 동작). + * + *

    + *
  • {@code pad}: 각 말 구간 앞뒤로 남길 여백(초). 말 잘림 방지.
  • + *
  • {@code minGap}: 이 값 이하의 짧은 간격은 끊지 않고 이어 붙인다(잦은 컷 방지).
  • + *
+ * + *

{@code remove} 는 첫 keep 시작 전(leading)과 keep 사이 간격만 포함한다(마지막 keep 이후 + * 꼬리는 어떤 자막에도 영향을 주지 않으므로 재매핑 대상이 아니며, 영상 컷은 keep 만으로 충분). + */ +public final class KeepIntervalPlanner { + + private KeepIntervalPlanner() { + } + + public record Plan(List keep, List remove) { + } + + public static Plan plan(List segments, double pad, double minGap) { + if (segments == null || segments.isEmpty()) { + return new Plan(List.of(), List.of()); + } + // 1) 패딩 적용(시작 0 클램프) + List padded = new ArrayList<>(segments.size()); + for (ScriptSegment s : segments) { + padded.add(new TimeInterval(Math.max(0, s.start() - pad), s.end() + pad)); + } + padded.sort(Comparator.comparingDouble(TimeInterval::start)); + + // 2) 겹치거나 간격이 minGap 이하면 병합 → keep + List keep = new ArrayList<>(); + double curStart = padded.get(0).start(); + double curEnd = padded.get(0).end(); + for (int i = 1; i < padded.size(); i++) { + TimeInterval iv = padded.get(i); + if (iv.start() - curEnd <= minGap) { + curEnd = Math.max(curEnd, iv.end()); + } else { + keep.add(new TimeInterval(curStart, curEnd)); + curStart = iv.start(); + curEnd = iv.end(); + } + } + keep.add(new TimeInterval(curStart, curEnd)); + + // 3) remove = [0, firstKeepStart) + keep 사이 간격 + List remove = new ArrayList<>(); + if (keep.get(0).start() > 1e-9) { + remove.add(new TimeInterval(0.0, keep.get(0).start())); + } + for (int i = 1; i < keep.size(); i++) { + remove.add(new TimeInterval(keep.get(i - 1).end(), keep.get(i).start())); + } + return new Plan(keep, remove); + } + + /** keep 구간 총 길이 = 무음 제거 후 예상 영상 길이(배속 전). */ + public static double keptDuration(Plan plan) { + double total = 0; + for (TimeInterval iv : plan.keep()) { + total += iv.end() - iv.start(); + } + return total; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/TimeInterval.java b/src/main/java/com/hlab/yanalyst/domain/channel/TimeInterval.java new file mode 100644 index 0000000..a29b54d --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/TimeInterval.java @@ -0,0 +1,7 @@ +package com.hlab.yanalyst.domain.channel; + +/** + * 시간 구간(초 단위). 무음 제거 대상 구간 표현에 사용. + */ +public record TimeInterval(double start, double end) { +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/TimelineRemapper.java b/src/main/java/com/hlab/yanalyst/domain/channel/TimelineRemapper.java new file mode 100644 index 0000000..732dbcb --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/TimelineRemapper.java @@ -0,0 +1,91 @@ +package com.hlab.yanalyst.domain.channel; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * 무음 구간 제거에 따른 자막 타임스탬프 재매핑(순수 유틸). + * + *

무음을 잘라낸 뒤 남는 구간을 이어붙이면 전체 타임라인이 앞으로 당겨진다. + * 원본 시각 {@code t} 는 그 앞에 제거된 무음 길이만큼 빼서 새 시각으로 매핑된다: + * {@code f(t) = t - (t 이전에 제거된 무음 총량)}. + * + *

배속은 여기서 적용하지 않는다. 재매핑은 "무음 제거 후, 배속 전" 타임라인을 만들고, + * 배속은 {@link SrtFormatter}(÷speed)와 ffmpeg(atempo) 단계에서 일관되게 적용한다. + */ +public final class TimelineRemapper { + + private TimelineRemapper() { + } + + public static List remap(List segments, List silences) { + if (segments == null || segments.isEmpty()) { + return List.of(); + } + List merged = merge(silences); + List out = new ArrayList<>(segments.size()); + for (ScriptSegment seg : segments) { + double ns = mapPoint(seg.start(), merged); + double ne = mapPoint(seg.end(), merged); + if (ne < ns) { + ne = ns; + } + out.add(new ScriptSegment(ns, ne, seg.text())); + } + return out; + } + + /** 원본 길이에서 (병합된) 무음 총량을 뺀 새 전체 길이. */ + public static double newDuration(double originalDuration, List silences) { + double removed = 0; + for (TimeInterval s : merge(silences)) { + double a = Math.max(0, s.start()); + double b = Math.min(originalDuration, s.end()); + if (b > a) { + removed += b - a; + } + } + return Math.max(0, originalDuration - removed); + } + + /** f(t) = t - (t 이전에 제거된 무음 총량). 무음 내부의 점은 그 무음 시작으로 클램프된다. */ + private static double mapPoint(double t, List merged) { + double removed = 0; + for (TimeInterval s : merged) { + if (s.end() <= t) { + removed += s.end() - s.start(); + } else if (s.start() < t) { + removed += t - s.start(); // t 가 무음 내부 → 경계로 클램프 + break; + } else { + break; // 정렬되어 있으니 이후는 t 이후 + } + } + return t - removed; + } + + /** 겹치거나 정렬 안 된 구간을 정렬·병합한다. */ + private static List merge(List silences) { + if (silences == null || silences.isEmpty()) { + return List.of(); + } + List sorted = new ArrayList<>(silences); + sorted.sort(Comparator.comparingDouble(TimeInterval::start)); + List merged = new ArrayList<>(); + double curStart = sorted.get(0).start(); + double curEnd = sorted.get(0).end(); + for (int i = 1; i < sorted.size(); i++) { + TimeInterval iv = sorted.get(i); + if (iv.start() <= curEnd) { + curEnd = Math.max(curEnd, iv.end()); + } else { + merged.add(new TimeInterval(curStart, curEnd)); + curStart = iv.start(); + curEnd = iv.end(); + } + } + merged.add(new TimeInterval(curStart, curEnd)); + return merged; + } +} diff --git a/src/main/resources/templates/rework.html b/src/main/resources/templates/rework.html index 3d7af73..1d5f087 100644 --- a/src/main/resources/templates/rework.html +++ b/src/main/resources/templates/rework.html @@ -36,7 +36,14 @@

원본 스크립트

-
+
+
').join(''); box.style.display = 'block'; - srtBar.style.display = 'flex'; + exportBar.style.display = 'block'; } function seekTo(sec){ @@ -217,17 +257,101 @@ }); } + function curSpeed(){ const s = parseFloat(document.getElementById('srtSpeed').value); return (s>0)?s:1.0; } + + // 현재 작업 세그먼트(+배속)로 SRT 생성 → 다운로드. 말-구간 제거 적용 시 재매핑된 타임라인 반영. + function srtTime(sec){ + sec = Math.max(0, sec); + const ms = Math.round(sec*1000); + const p=(n,l)=>String(n).padStart(l||2,'0'); + return p(Math.floor(ms/3600000))+':'+p(Math.floor(ms%3600000/60000))+':'+p(Math.floor(ms%60000/1000))+','+p(ms%1000,3); + } + function buildSrt(segs, speed){ + speed = speed>0?speed:1.0; + let out='', i=1; + for(const sg of segs){ + let a=sg.start/speed, b=sg.end/speed; + if(a<0)a=0; if(b '+srtTime(b)+'\n'+((sg.text||'').trim())+'\n\n'; + } + return out; + } function exportSrt(){ - const speed = parseFloat(document.getElementById('srtSpeed').value) || 1.0; + const blob = new Blob([buildSrt(workingSegments(), curSpeed())], {type:'application/x-subrip'}); const a = document.createElement('a'); - a.href = API + '/' + VIDEO_ID + '/script.srt?speed=' + encodeURIComponent(speed); - a.download = 'script_' + VIDEO_ID + '.srt'; - document.body.appendChild(a); a.click(); a.remove(); + a.href = URL.createObjectURL(blob); a.download = 'script_' + VIDEO_ID + '.srt'; + document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href); + } + + // ===== 말 없는 구간 제거 ===== + function onTrimToggle(){ + if(document.getElementById('trimOn').checked){ previewTrim(); } + else { + trimApplied = false; REMAPPED = []; + document.getElementById('trimInfo').textContent = ''; + } + } + + async function previewTrim(){ + const pad = parseFloat(document.getElementById('trimPad').value) || 0; + const gap = parseFloat(document.getElementById('trimGap').value) || 0; + const info = document.getElementById('trimInfo'); + try { + const p = await api(API + '/' + VIDEO_ID + '/trim-plan?pad=' + pad + '&minGap=' + gap); + REMAPPED = p.segments || []; + trimApplied = true; + document.getElementById('trimOn').checked = true; + const sp = curSpeed(); + const kept = (p.keptDuration||0) / sp; + info.style.color = '#cbd5e1'; + info.innerHTML = '제거 ' + (p.removedCount||0) + '구간 · 결과 길이 ≈ ' + kept.toFixed(1) + 's' + + (sp!==1 ? (' (배속 ' + sp + 'x 포함)') : '') + + ' · SRT/영상이 이 타임라인으로 출력됩니다'; + } catch(e){ + trimApplied = false; + info.style.color = '#f87171'; + info.textContent = '미리보기 실패: ' + e.message; + } + } + + async function renderVideo(){ + if(!UPLOADED_FILE){ alert('먼저 영상을 업로드·전사하세요. (렌더에는 원본 영상 파일이 필요합니다)'); return; } + const pad = parseFloat(document.getElementById('trimPad').value) || 0; + const gap = parseFloat(document.getElementById('trimGap').value) || 0; + const speed = curSpeed(); + const btn = document.getElementById('renderBtn'); + const status = document.getElementById('renderStatus'); + const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '렌더 중…'; + status.style.display = 'block'; status.style.color = '#facc15'; + status.textContent = '영상 렌더링 중… (자르기+인코딩, 잠시 걸립니다)'; + try { + const fd = new FormData(); + fd.append('file', UPLOADED_FILE); + fd.append('pad', pad); fd.append('minGap', gap); fd.append('speed', speed); + const res = await fetch(API + '/' + VIDEO_ID + '/render', { method:'POST', body: fd }); + if(!res.ok){ + let msg = 'HTTP ' + res.status; + try { const j = await res.json(); if(j && j.message) msg = j.message; } catch(e){} + throw new Error(msg); + } + const blob = await res.blob(); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); a.download = 'trimmed_' + VIDEO_ID + '.mp4'; + document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href); + status.style.color = '#4ade80'; + status.textContent = '영상 내보내기 완료 · SRT도 함께 내보내 CapCut에 두 파일을 import하세요'; + } catch(e){ + status.style.color = '#f87171'; + status.textContent = '렌더 실패: ' + e.message; + } finally { + btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); + } } async function onVideoSelected(ev){ const file = ev.target.files && ev.target.files[0]; if(!file) return; + UPLOADED_FILE = file; // 렌더(자르기) 재전송용으로 보관 // 1) 업로드한 파일을 로컬에서 즉시 재생(