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>
76 lines
2.9 KiB
Java
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;
|
|
}
|
|
}
|