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>
92 lines
3.5 KiB
Java
92 lines
3.5 KiB
Java
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;
|
|
}
|
|
}
|