h-lab/src/main/java/com/hlab/yanalyst/domain/channel/KeepIntervalPlanner.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

76 lines
2.9 KiB
Java

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