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:
parent
b9aa04d4a3
commit
8178d45209
@ -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. 열린 항목
|
||||
|
||||
@ -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<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);
|
||||
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
|
||||
public void extractAllScripts(Long channelId) {
|
||||
List<ChannelVideo> videos = channelVideoRepository.findByChannelId(channelId);
|
||||
|
||||
@ -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<Map<String, Object>> 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<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")
|
||||
|
||||
@ -107,11 +107,11 @@ public class ChannelVideoCurationService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 업로드 영상 파일을 Whisper 로 전사해 영상 싱크 세그먼트를 저장하고 결과를 반환한다. */
|
||||
/** 업로드 영상 파일을 Whisper 로 전사해 영상 싱크 세그먼트를 저장하고 결과를 반환한다. language=null 이면 자동감지. */
|
||||
@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);
|
||||
ScriptResponseDto dto = channelService.transcribeFromFile(v.getId(), file);
|
||||
ScriptResponseDto dto = channelService.transcribeFromFile(v.getId(), file, language);
|
||||
|
||||
Map<String, Object> 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<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 이면). */
|
||||
@Transactional
|
||||
public ChannelVideo saveRework(Long videoId, String text) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
/**
|
||||
* 시간 구간(초 단위). 무음 제거 대상 구간 표현에 사용.
|
||||
*/
|
||||
public record TimeInterval(double start, double end) {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -36,7 +36,14 @@
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:8px;">
|
||||
<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;">
|
||||
<i data-lucide="upload" style="width:15px;"></i> 영상 업로드·전사
|
||||
<input type="file" id="videoFile" accept="video/*" style="display:none;" onchange="onVideoSelected(event)">
|
||||
@ -59,15 +66,40 @@
|
||||
<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>
|
||||
|
||||
<!-- SRT 내보내기 -->
|
||||
<div class="flex items-center gap-2 mt-3" id="srtBar" style="display:none; flex-wrap:wrap;">
|
||||
<!-- 내보내기 (배속 + 말 없는 구간 제거 + SRT/영상) -->
|
||||
<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>
|
||||
<input id="srtSpeed" type="number" value="1.0" step="0.1" min="0.1"
|
||||
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;">
|
||||
<input id="srtSpeed" type="number" value="1.0" step="0.1" min="0.1" max="2.0"
|
||||
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()">
|
||||
<i data-lucide="file-down" style="width:15px;"></i> SRT 내보내기
|
||||
</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>
|
||||
|
||||
@ -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){
|
||||
sec = Math.max(0, Math.floor(sec||0));
|
||||
@ -183,11 +221,13 @@
|
||||
|
||||
function renderSegments(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 srtBar = document.getElementById('srtBar');
|
||||
const exportBar = document.getElementById('exportBar');
|
||||
if(!SEGMENTS.length){
|
||||
box.style.display = 'none'; box.innerHTML = '';
|
||||
srtBar.style.display = 'none';
|
||||
exportBar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = SEGMENTS.map((sg,i)=>
|
||||
@ -195,7 +235,7 @@
|
||||
+ '<span class="seg-time">'+mmss(sg.start)+'</span>'
|
||||
+ '<span class="seg-text">'+esc(sg.text)+'</span></div>').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<a)b=a;
|
||||
out += (i++)+'\n'+srtTime(a)+' --> '+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) + '구간 · 결과 길이 ≈ <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){
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
if(!file) return;
|
||||
UPLOADED_FILE = file; // 렌더(자르기) 재전송용으로 보관
|
||||
|
||||
// 1) 업로드한 파일을 로컬에서 즉시 재생(<video>) + YouTube iframe 숨김
|
||||
const v = document.getElementById('localVideo');
|
||||
@ -244,6 +368,8 @@
|
||||
try {
|
||||
const fd = new FormData();
|
||||
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 });
|
||||
renderSegments(s.segments);
|
||||
document.getElementById('transcript').value = s.transcript || '';
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user