feat(rework): 한국어 SRT 내보내기 추가

세그먼트를 한국어로 일괄번역해 기존 타임라인(타임코드·배속·말없는구간 제거)에
그대로 끼워 SRT로 내보낸다. 원본 SRT와 별개로 '한국어 SRT' 버튼 추가.

- translateScript format=segments: 세그먼트와 1:1 정렬된 번역 배열 반환
- rework.html exportKoSrt(): texts 배열을 workingSegments에 매핑해 buildSrt로 생성
  (텍스트만 한국어로 교체, 타임코드/배속은 기존 로직 재사용)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-25 11:45:20 +09:00
parent 198334c6f8
commit e9ad800134
2 changed files with 43 additions and 15 deletions

View File

@ -188,9 +188,13 @@ public class ChannelVideoCurationService {
String source = scriptOpt.map(ChannelVideoScript::getLanguage).orElse(null); String source = scriptOpt.map(ChannelVideoScript::getLanguage).orElse(null);
String tgt = (target == null || target.isBlank()) ? "ko" : target.trim(); String tgt = (target == null || target.isBlank()) ? "ko" : target.trim();
boolean timeline = "timeline".equalsIgnoreCase(format); boolean timeline = "timeline".equalsIgnoreCase(format);
boolean segmentsMode = "segments".equalsIgnoreCase(format); // SRT용: 세그먼트별 번역 배열 반환
String translated; Map<String, Object> result = new LinkedHashMap<>();
if (timeline) { result.put("source", source);
result.put("target", tgt);
if (timeline || segmentsMode) {
List<ScriptSegment> segments = channelService.getSegments(v.getId()); List<ScriptSegment> segments = channelService.getSegments(v.getId());
if (segments.isEmpty()) { if (segments.isEmpty()) {
throw new IllegalArgumentException("타임라인(시간 싱크 세그먼트)이 없습니다. 먼저 원본 다운로드/업로드로 전사하세요."); throw new IllegalArgumentException("타임라인(시간 싱크 세그먼트)이 없습니다. 먼저 원본 다운로드/업로드로 전사하세요.");
@ -198,26 +202,28 @@ public class ChannelVideoCurationService {
List<String> texts = segments.stream().map(ScriptSegment::text).toList(); List<String> texts = segments.stream().map(ScriptSegment::text).toList();
List<String> ko = translateService.translateBatch(texts, source, tgt); List<String> ko = translateService.translateBatch(texts, source, tgt);
if (segmentsMode) {
// 입력 세그먼트와 1:1 정렬된 번역 배열(프론트가 기존 SRT 로직에 끼워 넣음)
result.put("format", "segments");
result.put("texts", ko);
} else {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
for (int i = 0; i < segments.size(); i++) { for (int i = 0; i < segments.size(); i++) {
String line = (i < ko.size()) ? ko.get(i) : ""; String line = (i < ko.size()) ? ko.get(i) : "";
sb.append('[').append(mmss(segments.get(i).start())).append("] ") sb.append('[').append(mmss(segments.get(i).start())).append("] ")
.append(line == null ? "" : line.trim()).append('\n'); .append(line == null ? "" : line.trim()).append('\n');
} }
translated = sb.toString().stripTrailing(); result.put("format", "timeline");
result.put("translatedText", sb.toString().stripTrailing());
}
} else { } else {
String text = scriptOpt.map(ChannelVideoScript::getTranscript).orElse(null); String text = scriptOpt.map(ChannelVideoScript::getTranscript).orElse(null);
if (text == null || text.isBlank()) { if (text == null || text.isBlank()) {
throw new IllegalArgumentException("번역할 원본 스크립트가 없습니다. 먼저 전사(원본 다운로드/업로드)하세요."); throw new IllegalArgumentException("번역할 원본 스크립트가 없습니다. 먼저 전사(원본 다운로드/업로드)하세요.");
} }
translated = translateService.translate(text, source, tgt); result.put("format", "plain");
result.put("translatedText", translateService.translate(text, source, tgt));
} }
Map<String, Object> result = new LinkedHashMap<>();
result.put("translatedText", translated);
result.put("source", source);
result.put("target", tgt);
result.put("format", timeline ? "timeline" : "plain");
return result; return result;
} }

View File

@ -201,6 +201,9 @@
<button class="btn btn-primary px-3 py-2 flex items-center gap-1" onclick="exportSrt()"> <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 내보내기 <i data-lucide="file-down" style="width:15px;"></i> SRT 내보내기
</button> </button>
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="koSrtBtn" onclick="exportKoSrt()" title="세그먼트를 한국어로 번역해 타임코드 맞춰 SRT로 내보내기">
<i data-lucide="languages" style="width:15px;"></i> 한국어 SRT
</button>
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="renderBtn" onclick="renderVideo()"> <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> 영상 내보내기 <i data-lucide="scissors" style="width:15px;"></i> 영상 내보내기
</button> </button>
@ -407,6 +410,25 @@
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href); document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href);
} }
// 세그먼트를 한국어로 번역해(서버 일괄번역) 기존 타임라인(배속·말없는구간 제거 반영)에 끼워 SRT 생성.
async function exportKoSrt(){
const btn = document.getElementById('koSrtBtn');
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '번역 중…';
try {
const r = await api(API + '/' + VIDEO_ID + '/translate?target=ko&format=segments', { method:'POST' });
const ko = (r && r.texts) || [];
if(!ko.length){ alert('번역할 세그먼트가 없습니다. 먼저 전사하세요.'); return; }
// 현재 작업 세그먼트(원본/무음제거 후)와 1:1 정렬 → 텍스트만 한국어로 교체(타임코드·배속 그대로)
const segs = workingSegments();
const koSegs = segs.map((sg, i) => ({ start: sg.start, end: sg.end, text: (ko[i] != null ? ko[i] : (sg.text || '')) }));
const blob = new Blob([buildSrt(koSegs, curSpeed())], {type:'application/x-subrip'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = 'script_ko_' + VIDEO_ID + '.srt';
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href);
} catch(e){ alert('한국어 SRT 실패: ' + e.message); }
finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); }
}
// ===== 말 없는 구간 제거 ===== // ===== 말 없는 구간 제거 =====
function onTrimToggle(){ function onTrimToggle(){
if(document.getElementById('trimOn').checked){ previewTrim(); } if(document.getElementById('trimOn').checked){ previewTrim(); }