feat(rework): speech-gap trimming + render, language override

Phase 3: remove "no-talk" gaps (Whisper-segment based, not audio silencedetect
which finds nothing under background music) and render a trimmed (+speed) video
via ffmpeg, with subtitles remapped to match.

- KeepIntervalPlanner + TimelineRemapper (pure, unit-tested): keep/remove plan
  from segments (pad/minGap) and timestamp remap f(t)=t-removedBefore(t)
- GET /{id}/trim-plan (preview: keep/remove/remapped segments/kept duration)
- POST /{id}/render (multipart: file,pad,minGap,speed) -> proxy Python /render
  (ffmpeg trim/atrim+concat+atempo) -> mp4 download; ffmpeg graph validated locally
- rework.html: export panel (speed + speech-gap trim preview + SRT/video export),
  client-side SRT from working segments, language selector (auto/ko/en/zh/ja)
- transcribeFromFile forwards optional language (Whisper auto-detect misfired -> zh)

Spec updated with the audio-silence -> speech-gap design correction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-12 16:53:32 +09:00
parent b9aa04d4a3
commit 8178d45209
10 changed files with 633 additions and 41 deletions

View File

@ -149,17 +149,27 @@ async def transcribe(file: UploadFile = File(...),
- SRT 내보내기에 `speed` 적용: 모든 `start/end``/ speed`. (예: 1.2x → 시간 0.833배) - SRT 내보내기에 `speed` 적용: 모든 `start/end``/ speed`. (예: 1.2x → 시간 0.833배)
- 에디터에 배속 미리보기(선택). 저장 데이터는 원본 타임스탬프 유지, 내보내기 시점에만 변환. - 에디터에 배속 미리보기(선택). 저장 데이터는 원본 타임스탬프 유지, 내보내기 시점에만 변환.
## 7. Phase 3 — 무음 제거 ## 7. Phase 3 — "말 없는 구간" 제거 (구현됨)
- 업로드 영상을 서버에 영속화(또는 재업로드) → Python `/silence`(ffmpeg `silencedetect`)로 무음 구간 목록 확보. > **실측 기반 설계 교정**: 당초 오디오 무음(ffmpeg `silencedetect`)을 가정했으나, 사용자 콘텐츠(음악 깔린 Shorts)는 `mean_volume -12dB`**오디오 무음이 0개**라 아무것도 못 자른다. 따라서 제거 기준을 **Whisper 세그먼트 기반 "말(speech) 없는 구간"** 으로 변경했다. 음악과 무관하게 "아무도 말 안 하는 구간"을 잘라 타이트하게 만든다. (실측: 영상테스트.mp4 29s → 말 0~9.75s만 남김)
- 무음 구간 제거 후 남는 구간을 이어붙이는 타임라인 재매핑 → 세그먼트 타임스탬프 보정 → SRT 반영.
- 배속과 조합 가능(무음 제거 후 배속). - **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) ## 8. 범위 밖(YAGNI)
- 가로 파형 타임라인 + 클립 드래그 같은 본격 CapCut형 UI(무거움). Phase 1은 "재생 동기 세그먼트 리스트"로 시작. - 가로 파형 타임라인 + 클립 드래그 같은 본격 CapCut형 UI(무거움). Phase 1은 "재생 동기 세그먼트 리스트"로 시작.
- CapCut 네이티브 draft(.json) 직접 생성. 표준 SRT import로 충분. - CapCut 네이티브 draft(.json) 직접 생성. 표준 SRT import로 충분.
- 실제 영상 렌더링(배속/컷 적용된 mp4 출력)은 CapCut이 담당. - 오디오 무음(silencedetect) 기반 제거 — 음악 깔린 콘텐츠엔 무용. 필요 시 향후 옵션으로.
- 플랫폼 업로드 API 연동. - 플랫폼 업로드 API 연동.
## 9. 열린 항목 ## 9. 열린 항목

View File

@ -391,28 +391,23 @@ public class ChannelService {
* @return 추출 결과 DTO(segments/transcript/language/duration) * @return 추출 결과 DTO(segments/transcript/language/duration)
*/ */
@Transactional @Transactional
public ScriptResponseDto transcribeFromFile(Long channelVideoId, MultipartFile file) { public ScriptResponseDto transcribeFromFile(Long channelVideoId, MultipartFile file, String language) {
ChannelVideo video = channelVideoRepository.findById(channelVideoId) ChannelVideo video = channelVideoRepository.findById(channelVideoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId)); .orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
String apiUrl = pythonBaseUrl + "/transcribe"; 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 { try {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA); 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<String, Object> body = new LinkedMultiValueMap<>(); MultiValueMap<String, Object> 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<MultiValueMap<String, Object>> request = new HttpEntity<>(body, headers); HttpEntity<MultiValueMap<String, Object>> request = new HttpEntity<>(body, headers);
ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, request, String.class); ResponseEntity<String> 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<ScriptSegment> 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<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", toFileResource(file));
body.add("keep", objectMapper.writeValueAsString(plan.keep())); // [{"start":..,"end":..},...]
body.add("speed", String.valueOf(speed));
HttpEntity<MultiValueMap<String, Object>> request = new HttpEntity<>(body, headers);
ResponseEntity<byte[]> 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 @Transactional
public void extractAllScripts(Long channelId) { public void extractAllScripts(Long channelId) {
List<ChannelVideo> videos = channelVideoRepository.findByChannelId(channelId); List<ChannelVideo> videos = channelVideoRepository.findByChannelId(channelId);

View File

@ -136,10 +136,40 @@ public class ChannelVideoCurationController {
@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)로 보내 영상 싱크 세그먼트를 추출·저장한다. "
+ "multipart 필드: file. 응답: {hasScript, language, duration, transcript, segments}.") + "multipart 필드: file, language(선택: ko|en|zh|ja… 비우면 자동감지). "
+ "응답: {hasScript, language, duration, transcript, segments}.")
public ApiResponse<Map<String, Object>> transcribe(@PathVariable Long id, public ApiResponse<Map<String, Object>> transcribe(@PathVariable Long id,
@RequestParam("file") MultipartFile file) { @RequestParam("file") MultipartFile file,
return ApiResponse.ok(curationService.transcribeFromFile(id, 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<Map<String, Object>> 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<byte[]> 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") @GetMapping("/{id}/script.srt")

View File

@ -107,11 +107,11 @@ public class ChannelVideoCurationService {
return result; return result;
} }
/** 업로드 영상 파일을 Whisper 로 전사해 영상 싱크 세그먼트를 저장하고 결과를 반환한다. */ /** 업로드 영상 파일을 Whisper 로 전사해 영상 싱크 세그먼트를 저장하고 결과를 반환한다. language=null 이면 자동감지. */
@Transactional @Transactional
public Map<String, Object> transcribeFromFile(Long videoId, MultipartFile file) { public Map<String, Object> transcribeFromFile(Long videoId, MultipartFile file, String language) {
ChannelVideo v = find(videoId); ChannelVideo v = find(videoId);
ScriptResponseDto dto = channelService.transcribeFromFile(v.getId(), file); ScriptResponseDto dto = channelService.transcribeFromFile(v.getId(), file, language);
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("hasScript", true); result.put("hasScript", true);
@ -128,6 +128,31 @@ public class ChannelVideoCurationService {
return SrtFormatter.toSrt(channelService.getSegments(v.getId()), speed); return SrtFormatter.toSrt(channelService.getSegments(v.getId()), speed);
} }
/**
* 없는 구간 제거 계획(미리보기): keep/remove 구간 + 재매핑된 세그먼트 + 예상 길이.
* 영상 파일 없이 저장된 세그먼트만으로 계산한다.
*/
public Map<String, Object> trimPlan(Long videoId, double pad, double minGap) {
ChannelVideo v = find(videoId);
List<ScriptSegment> segments = channelService.getSegments(v.getId());
KeepIntervalPlanner.Plan plan = KeepIntervalPlanner.plan(segments, pad, minGap);
List<ScriptSegment> remapped = TimelineRemapper.remap(segments, plan.remove());
Map<String, Object> 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 이면). */ /** 재작성 초안 저장 + 상태를 TARGET 으로 승격(아직 NEW/REVIEWING 이면). */
@Transactional @Transactional
public ChannelVideo saveRework(Long videoId, String text) { public ChannelVideo saveRework(Long videoId, String text) {

View File

@ -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)" 계산하는 순수 유틸.
*
* <p>오디오 무음(silencedetect) 아니라 Whisper 세그먼트 기반으로 "아무도 말하지 않는 구간"
* 잘라 영상을 타이트하게 만든다(배경음악이 깔려 있어도 동작).
*
* <ul>
* <li>{@code pad}: 구간 앞뒤로 남길 여백(). 잘림 방지.</li>
* <li>{@code minGap}: 이하의 짧은 간격은 끊지 않고 이어 붙인다(잦은 방지).</li>
* </ul>
*
* <p>{@code remove} keep 시작 (leading) keep 사이 간격만 포함한다(마지막 keep 이후
* 꼬리는 어떤 자막에도 영향을 주지 않으므로 재매핑 대상이 아니며, 영상 컷은 keep 만으로 충분).
*/
public final class KeepIntervalPlanner {
private KeepIntervalPlanner() {
}
public record Plan(List<TimeInterval> keep, List<TimeInterval> remove) {
}
public static Plan plan(List<ScriptSegment> segments, double pad, double minGap) {
if (segments == null || segments.isEmpty()) {
return new Plan(List.of(), List.of());
}
// 1) 패딩 적용(시작 0 클램프)
List<TimeInterval> 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<TimeInterval> 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<TimeInterval> 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;
}
}

View File

@ -0,0 +1,7 @@
package com.hlab.yanalyst.domain.channel;
/**
* 시간 구간( 단위). 무음 제거 대상 구간 표현에 사용.
*/
public record TimeInterval(double start, double end) {
}

View File

@ -0,0 +1,91 @@
package com.hlab.yanalyst.domain.channel;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* 무음 구간 제거에 따른 자막 타임스탬프 재매핑(순수 유틸).
*
* <p>무음을 잘라낸 남는 구간을 이어붙이면 전체 타임라인이 앞으로 당겨진다.
* 원본 시각 {@code t} 앞에 제거된 무음 길이만큼 빼서 시각으로 매핑된다:
* {@code f(t) = t - (t 이전에 제거된 무음 총량)}.
*
* <p>배속은 여기서 적용하지 않는다. 재매핑은 "무음 제거 후, 배속 전" 타임라인을 만들고,
* 배속은 {@link SrtFormatter}(÷speed) ffmpeg(atempo) 단계에서 일관되게 적용한다.
*/
public final class TimelineRemapper {
private TimelineRemapper() {
}
public static List<ScriptSegment> remap(List<ScriptSegment> segments, List<TimeInterval> silences) {
if (segments == null || segments.isEmpty()) {
return List.of();
}
List<TimeInterval> merged = merge(silences);
List<ScriptSegment> 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<TimeInterval> 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<TimeInterval> 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<TimeInterval> merge(List<TimeInterval> silences) {
if (silences == null || silences.isEmpty()) {
return List.of();
}
List<TimeInterval> sorted = new ArrayList<>(silences);
sorted.sort(Comparator.comparingDouble(TimeInterval::start));
List<TimeInterval> 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;
}
}

View File

@ -36,7 +36,14 @@
<div class="card"> <div class="card">
<div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:8px;"> <div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:8px;">
<h3 class="text-lg font-bold">원본 스크립트</h3> <h3 class="text-lg font-bold">원본 스크립트</h3>
<div class="flex gap-2" style="flex-wrap:wrap;"> <div class="flex gap-2" style="flex-wrap:wrap; align-items:center;">
<select id="langSel" title="전사 언어(자동감지가 틀릴 때 강제)" class="pub-in" style="width:auto; padding:7px 9px;">
<option value="">자동감지</option>
<option value="ko">한국어</option>
<option value="en">English</option>
<option value="zh">中文</option>
<option value="ja">日本語</option>
</select>
<label class="btn btn-primary px-3 py-2 flex items-center gap-1" id="uploadBtn" style="cursor:pointer;"> <label class="btn btn-primary px-3 py-2 flex items-center gap-1" id="uploadBtn" style="cursor:pointer;">
<i data-lucide="upload" style="width:15px;"></i> 영상 업로드·전사 <i data-lucide="upload" style="width:15px;"></i> 영상 업로드·전사
<input type="file" id="videoFile" accept="video/*" style="display:none;" onchange="onVideoSelected(event)"> <input type="file" id="videoFile" accept="video/*" style="display:none;" onchange="onVideoSelected(event)">
@ -59,15 +66,40 @@
<textarea id="transcript" readonly placeholder="‘영상 업로드·전사’로 영상에서 싱크된 자막을 추출하거나, URL자막으로 YouTube 자막을 가져오세요." <textarea id="transcript" readonly placeholder="‘영상 업로드·전사’로 영상에서 싱크된 자막을 추출하거나, URL자막으로 YouTube 자막을 가져오세요."
style="width:100%; min-height:140px; resize:vertical; padding:12px; background:rgba(255,255,255,0.03); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:#cbd5e1; outline:none; font-size:0.9rem; line-height:1.6;"></textarea> style="width:100%; min-height:140px; resize:vertical; padding:12px; background:rgba(255,255,255,0.03); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:#cbd5e1; outline:none; font-size:0.9rem; line-height:1.6;"></textarea>
<!-- SRT 내보내기 --> <!-- 내보내기 (배속 + 말 없는 구간 제거 + SRT/영상) -->
<div class="flex items-center gap-2 mt-3" id="srtBar" style="display:none; flex-wrap:wrap;"> <div id="exportBar" style="display:none; margin-top:14px; padding-top:12px; border-top:1px solid var(--glass-border);">
<div class="flex items-center gap-2 mb-3" style="flex-wrap:wrap;">
<label class="text-sm text-muted">배속</label> <label class="text-sm text-muted">배속</label>
<input id="srtSpeed" type="number" value="1.0" step="0.1" min="0.1" <input id="srtSpeed" type="number" value="1.0" step="0.1" min="0.1" max="2.0"
style="width:72px; padding:7px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:white; outline:none; font-size:0.9rem;"> style="width:64px; padding:7px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:white; outline:none; font-size:0.9rem;">
<span class="text-xs text-muted">배속 적용 시 타임스탬프 자동 보정 (0.5~2.0)</span>
</div>
<div style="background:rgba(255,255,255,0.03); border:1px solid var(--glass-border); border-radius:var(--radius-md); padding:10px 12px;">
<div class="flex items-center gap-2 mb-2" style="flex-wrap:wrap;">
<label class="flex items-center gap-1 text-sm" style="cursor:pointer;">
<input type="checkbox" id="trimOn" onchange="onTrimToggle()"> 말 없는 구간 제거
</label>
<span class="text-xs text-muted">앞뒤 여백</span>
<input id="trimPad" type="number" value="0.15" step="0.05" min="0" onchange="if(trimApplied)previewTrim()"
style="width:60px; padding:5px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:6px; color:white; font-size:0.85rem;">
<span class="text-xs text-muted">최소 간격</span>
<input id="trimGap" type="number" value="0.3" step="0.1" min="0" onchange="if(trimApplied)previewTrim()"
style="width:60px; padding:5px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:6px; color:white; font-size:0.85rem;">
</div>
<div id="trimInfo" class="text-xs text-muted"></div>
</div>
<div class="flex items-center gap-2 mt-3" style="flex-wrap:wrap;">
<button class="btn btn-primary px-3 py-2 flex items-center gap-1" onclick="exportSrt()"> <button class="btn btn-primary px-3 py-2 flex items-center gap-1" onclick="exportSrt()">
<i data-lucide="file-down" style="width:15px;"></i> SRT 내보내기 <i data-lucide="file-down" style="width:15px;"></i> SRT 내보내기
</button> </button>
<span class="text-xs text-muted">CapCut import용 · 배속 적용 시 타임스탬프 자동 보정</span> <button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="renderBtn" onclick="renderVideo()">
<i data-lucide="scissors" style="width:15px;"></i> 영상 내보내기
</button>
<span class="text-xs text-muted">CapCut에 영상+SRT 함께 import</span>
</div>
<div id="renderStatus" class="text-sm mt-2" style="display:none;"></div>
</div> </div>
</div> </div>
@ -168,7 +200,13 @@
} }
// ===== 세그먼트(영상 싱크) ===== // ===== 세그먼트(영상 싱크) =====
let SEGMENTS = []; let SEGMENTS = []; // 전사 원본 세그먼트(원본 영상 재생에 싱크)
let UPLOADED_FILE = null; // 업로드한 영상 File (렌더 재전송용)
let trimApplied = false; // 말 없는 구간 제거 미리보기 적용 여부
let REMAPPED = []; // 무음 제거 후(배속 전) 세그먼트
// SRT/렌더에 쓸 현재 작업 세그먼트
function workingSegments(){ return trimApplied ? REMAPPED : SEGMENTS; }
function mmss(sec){ function mmss(sec){
sec = Math.max(0, Math.floor(sec||0)); sec = Math.max(0, Math.floor(sec||0));
@ -183,11 +221,13 @@
function renderSegments(segs){ function renderSegments(segs){
SEGMENTS = Array.isArray(segs) ? segs : []; SEGMENTS = Array.isArray(segs) ? segs : [];
trimApplied = false; REMAPPED = [];
const cb = document.getElementById('trimOn'); if(cb) cb.checked = false;
const box = document.getElementById('segmentList'); const box = document.getElementById('segmentList');
const srtBar = document.getElementById('srtBar'); const exportBar = document.getElementById('exportBar');
if(!SEGMENTS.length){ if(!SEGMENTS.length){
box.style.display = 'none'; box.innerHTML = ''; box.style.display = 'none'; box.innerHTML = '';
srtBar.style.display = 'none'; exportBar.style.display = 'none';
return; return;
} }
box.innerHTML = SEGMENTS.map((sg,i)=> box.innerHTML = SEGMENTS.map((sg,i)=>
@ -195,7 +235,7 @@
+ '<span class="seg-time">'+mmss(sg.start)+'</span>' + '<span class="seg-time">'+mmss(sg.start)+'</span>'
+ '<span class="seg-text">'+esc(sg.text)+'</span></div>').join(''); + '<span class="seg-text">'+esc(sg.text)+'</span></div>').join('');
box.style.display = 'block'; box.style.display = 'block';
srtBar.style.display = 'flex'; exportBar.style.display = 'block';
} }
function seekTo(sec){ 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<a)b=a;
out += (i++)+'\n'+srtTime(a)+' --> '+srtTime(b)+'\n'+((sg.text||'').trim())+'\n\n';
}
return out;
}
function exportSrt(){ 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'); const a = document.createElement('a');
a.href = API + '/' + VIDEO_ID + '/script.srt?speed=' + encodeURIComponent(speed); a.href = URL.createObjectURL(blob); a.download = 'script_' + VIDEO_ID + '.srt';
a.download = 'script_' + VIDEO_ID + '.srt'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href);
document.body.appendChild(a); a.click(); a.remove(); }
// ===== 말 없는 구간 제거 =====
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) + '구간 · 결과 길이 ≈ <b>' + kept.toFixed(1) + 's</b>'
+ (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){ async function onVideoSelected(ev){
const file = ev.target.files && ev.target.files[0]; const file = ev.target.files && ev.target.files[0];
if(!file) return; if(!file) return;
UPLOADED_FILE = file; // 렌더(자르기) 재전송용으로 보관
// 1) 업로드한 파일을 로컬에서 즉시 재생(<video>) + YouTube iframe 숨김 // 1) 업로드한 파일을 로컬에서 즉시 재생(<video>) + YouTube iframe 숨김
const v = document.getElementById('localVideo'); const v = document.getElementById('localVideo');
@ -244,6 +368,8 @@
try { try {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
const lang = document.getElementById('langSel').value;
if(lang) fd.append('language', lang);
const s = await api(API + '/' + VIDEO_ID + '/transcribe', { method:'POST', body: fd }); const s = await api(API + '/' + VIDEO_ID + '/transcribe', { method:'POST', body: fd });
renderSegments(s.segments); renderSegments(s.segments);
document.getElementById('transcript').value = s.transcript || ''; document.getElementById('transcript').value = s.transcript || '';

View File

@ -0,0 +1,83 @@
package com.hlab.yanalyst.domain.channel;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
/**
* -구간 기반 keep/remove 계획기(순수) 단위 테스트.
*/
class KeepIntervalPlannerTest {
@Test
void plan_singleSegment_removesLeadingGap() {
var plan = KeepIntervalPlanner.plan(List.of(new ScriptSegment(5.0, 8.0, "x")), 0.0, 0.0);
assertThat(plan.keep()).hasSize(1);
assertThat(plan.keep().get(0).start()).isCloseTo(5.0, within(1e-9));
assertThat(plan.keep().get(0).end()).isCloseTo(8.0, within(1e-9));
assertThat(plan.remove()).hasSize(1);
assertThat(plan.remove().get(0).start()).isCloseTo(0.0, within(1e-9));
assertThat(plan.remove().get(0).end()).isCloseTo(5.0, within(1e-9));
}
@Test
void plan_twoSegments_removesInterGap_noLeadingWhenStartsAtZero() {
var plan = KeepIntervalPlanner.plan(List.of(
new ScriptSegment(0.0, 2.0, "a"),
new ScriptSegment(5.0, 7.0, "b")), 0.0, 0.0);
assertThat(plan.keep()).hasSize(2);
assertThat(plan.remove()).hasSize(1);
assertThat(plan.remove().get(0).start()).isCloseTo(2.0, within(1e-9));
assertThat(plan.remove().get(0).end()).isCloseTo(5.0, within(1e-9));
}
@Test
void plan_minGapMergesNearbyBlocks() {
// 0-2, 2.2-4 : 간격 0.2 <= minGap 0.3 블록으로 병합, 제거 없음
var plan = KeepIntervalPlanner.plan(List.of(
new ScriptSegment(0.0, 2.0, "a"),
new ScriptSegment(2.2, 4.0, "b")), 0.0, 0.3);
assertThat(plan.keep()).hasSize(1);
assertThat(plan.keep().get(0).end()).isCloseTo(4.0, within(1e-9));
assertThat(plan.remove()).isEmpty();
}
@Test
void plan_paddingExtendsSegmentsAndClampsAtZero() {
// 0.2-1 pad 0.5 [0,1.5] (시작 0 클램프), 선행 제거 없음
var plan = KeepIntervalPlanner.plan(List.of(new ScriptSegment(0.2, 1.0, "x")), 0.5, 0.0);
assertThat(plan.keep().get(0).start()).isCloseTo(0.0, within(1e-9));
assertThat(plan.keep().get(0).end()).isCloseTo(1.5, within(1e-9));
assertThat(plan.remove()).isEmpty();
}
@Test
void plan_paddingCreatesLeadingRemoveWhenStartHigh() {
var plan = KeepIntervalPlanner.plan(List.of(new ScriptSegment(3.0, 4.0, "x")), 0.5, 0.0);
// keep [2.5,4.5], remove [0,2.5]
assertThat(plan.keep().get(0).start()).isCloseTo(2.5, within(1e-9));
assertThat(plan.remove().get(0).end()).isCloseTo(2.5, within(1e-9));
}
@Test
void keptDuration_sumsKeepLengths() {
var plan = KeepIntervalPlanner.plan(List.of(
new ScriptSegment(0.0, 2.0, "a"),
new ScriptSegment(5.0, 7.0, "b")), 0.0, 0.0);
assertThat(KeepIntervalPlanner.keptDuration(plan)).isCloseTo(4.0, within(1e-9));
}
@Test
void plan_emptySegments_yieldsEmptyPlan() {
var plan = KeepIntervalPlanner.plan(List.of(), 0.2, 0.3);
assertThat(plan.keep()).isEmpty();
assertThat(plan.remove()).isEmpty();
}
}

View File

@ -0,0 +1,105 @@
package com.hlab.yanalyst.domain.channel;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
/**
* 무음 제거에 따른 타임스탬프 재매핑(순수) 단위 테스트.
*/
class TimelineRemapperTest {
@Test
void remap_noSilence_returnsSegmentsUnchanged() {
List<ScriptSegment> segs = List.of(
new ScriptSegment(0.0, 2.0, "a"),
new ScriptSegment(5.0, 7.0, "b"));
List<ScriptSegment> out = TimelineRemapper.remap(segs, List.of());
assertThat(out).containsExactlyElementsOf(segs);
}
@Test
void remap_silenceBetweenSegments_shiftsLaterSegmentLeft() {
// 0-2 , 2-5 무음(3초), 5-7 무음 제거 : 0-2, 2-4
List<ScriptSegment> segs = List.of(
new ScriptSegment(0.0, 2.0, "a"),
new ScriptSegment(5.0, 7.0, "b"));
List<TimeInterval> silence = List.of(new TimeInterval(2.0, 5.0));
List<ScriptSegment> out = TimelineRemapper.remap(segs, silence);
assertThat(out.get(0).start()).isCloseTo(0.0, within(1e-9));
assertThat(out.get(0).end()).isCloseTo(2.0, within(1e-9));
assertThat(out.get(1).start()).isCloseTo(2.0, within(1e-9));
assertThat(out.get(1).end()).isCloseTo(4.0, within(1e-9));
}
@Test
void remap_leadingSilence_shiftsEverythingLeft() {
// 0-3 무음 제거 5-8 2-5
List<ScriptSegment> segs = List.of(new ScriptSegment(5.0, 8.0, "x"));
List<TimeInterval> silence = List.of(new TimeInterval(0.0, 3.0));
List<ScriptSegment> out = TimelineRemapper.remap(segs, silence);
assertThat(out.get(0).start()).isCloseTo(2.0, within(1e-9));
assertThat(out.get(0).end()).isCloseTo(5.0, within(1e-9));
}
@Test
void remap_multipleSilences_accumulate() {
// 무음 1-2(1s), 4-6(2s). 세그먼트 7-9 앞에서 3초 제거 4-6
List<ScriptSegment> segs = List.of(new ScriptSegment(7.0, 9.0, "x"));
List<TimeInterval> silence = List.of(
new TimeInterval(1.0, 2.0), new TimeInterval(4.0, 6.0));
List<ScriptSegment> out = TimelineRemapper.remap(segs, silence);
assertThat(out.get(0).start()).isCloseTo(4.0, within(1e-9));
assertThat(out.get(0).end()).isCloseTo(6.0, within(1e-9));
}
@Test
void remap_unsortedSilenceIsHandled() {
List<ScriptSegment> segs = List.of(new ScriptSegment(7.0, 9.0, "x"));
List<TimeInterval> silence = List.of(
new TimeInterval(4.0, 6.0), new TimeInterval(1.0, 2.0)); // 역순
List<ScriptSegment> out = TimelineRemapper.remap(segs, silence);
assertThat(out.get(0).start()).isCloseTo(4.0, within(1e-9));
}
@Test
void remap_pointInsideSilenceClampsToBoundary() {
// 세그먼트 시작이 무음(2-5) (3) 걸침 3은 무음 시작 2로 클램프 f(3)=2
List<ScriptSegment> segs = List.of(new ScriptSegment(3.0, 6.0, "x"));
List<TimeInterval> silence = List.of(new TimeInterval(2.0, 5.0));
List<ScriptSegment> out = TimelineRemapper.remap(segs, silence);
// f(3): removedBefore=3-2=1 3-1=2 ; f(6): removed=3 6-3=3
assertThat(out.get(0).start()).isCloseTo(2.0, within(1e-9));
assertThat(out.get(0).end()).isCloseTo(3.0, within(1e-9));
}
@Test
void newDuration_subtractsSilenceWithinClip() {
double d = TimelineRemapper.newDuration(29.0, List.of(
new TimeInterval(9.6, 29.0))); // 19.4초 무음 제거
assertThat(d).isCloseTo(9.6, within(1e-9));
}
@Test
void mergedSilences_overlappingDoNotDoubleCount() {
// 겹치는 무음 2-5, 4-7 실제 제거는 2-7(5초)
double removed = 10.0 - TimelineRemapper.newDuration(10.0, List.of(
new TimeInterval(2.0, 5.0), new TimeInterval(4.0, 7.0)));
assertThat(removed).isCloseTo(5.0, within(1e-9));
}
}