h-lab/src/main/java/com/hlab/yanalyst/domain/channel/TimelineRemapper.java
hehihoho3@gmail.com 8178d45209 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>
2026-06-12 16:53:32 +09:00

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;
}
}