feat(rework): 타임라인 [00:00] 형식 번역 옵션 추가

'[00:00] 번역' 버튼 추가 — 세그먼트별로 번역해 '[mm:ss] 한국어' 줄로 재작성 칸에 내린다.
LibreTranslate의 q 배열 일괄번역(1회 호출, 입력순서 1:1 정렬)을 사용.
기존 '번역→재작성'(평문 흐름)은 그대로 유지(format=plain|timeline).

- TranslateService.translateBatch + buildBatchRequestBody/extractTranslatedTexts(+단위테스트)
- translateScript(id,target,format): timeline 분기, 세그먼트 시각 [mm:ss] 머리표시
- 컨트롤러 format 파라미터, rework.html '[00:00] 번역' 버튼

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-25 11:10:05 +09:00
parent 77e77894c9
commit 198334c6f8
5 changed files with 124 additions and 16 deletions

View File

@ -172,10 +172,12 @@ public class ChannelVideoCurationController {
@PostMapping("/{id}/translate") @PostMapping("/{id}/translate")
@Operation(summary = "원본 스크립트 번역 → 재가공 초안", @Operation(summary = "원본 스크립트 번역 → 재가공 초안",
description = "원본 전사 스크립트를 LibreTranslate로 번역해 반환한다(프론트가 '재작성' 칸에 채움). " description = "원본 전사 스크립트를 LibreTranslate로 번역해 반환한다(프론트가 '재작성' 칸에 채움). "
+ "쿼리: target(기본 ko). 소스 언어는 전사 언어로 자동. 응답: {translatedText, source, target}.") + "쿼리: target(기본 ko), format(plain=평문 흐름 | timeline=세그먼트별 '[mm:ss] 번역' 줄). "
+ "소스 언어는 전사 언어로 자동. 응답: {translatedText, source, target, format}.")
public ApiResponse<Map<String, Object>> translate(@PathVariable Long id, public ApiResponse<Map<String, Object>> translate(@PathVariable Long id,
@RequestParam(value = "target", required = false) String target) { @RequestParam(value = "target", required = false) String target,
return ApiResponse.ok(curationService.translateScript(id, target)); @RequestParam(value = "format", required = false) String format) {
return ApiResponse.ok(curationService.translateScript(id, target, format));
} }
@PostMapping(value = "/{id}/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/{id}/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)

View File

@ -178,28 +178,55 @@ public class ChannelVideoCurationService {
} }
/** /**
* 원본 스크립트(전사 평문) 번역해 재가공( 버전) 초안용 텍스트로 반환한다. * 원본 스크립트를 번역해 재가공( 버전) 초안용 텍스트로 반환한다.
* source 전사 언어(자동감지가 있으면 ), target 기본 ko. 결과 저장은 사용자가 검토 직접. * format="timeline" 이면 세그먼트별 "[mm:ss] 번역" 줄로, (plain) 전사 평문 흐름으로 번역.
* source 전사 언어(없으면 auto), target 기본 ko. 저장은 사용자가 검토 직접.
*/ */
public Map<String, Object> translateScript(Long videoId, String target) { public Map<String, Object> translateScript(Long videoId, String target, String format) {
ChannelVideo v = find(videoId); ChannelVideo v = find(videoId);
var scriptOpt = channelVideoScriptRepository.findFirstByVideoIdOrderByIdDesc(v.getVideoId()); var scriptOpt = channelVideoScriptRepository.findFirstByVideoIdOrderByIdDesc(v.getVideoId());
String source = scriptOpt.map(ChannelVideoScript::getLanguage).orElse(null);
String tgt = (target == null || target.isBlank()) ? "ko" : target.trim();
boolean timeline = "timeline".equalsIgnoreCase(format);
String translated;
if (timeline) {
List<ScriptSegment> segments = channelService.getSegments(v.getId());
if (segments.isEmpty()) {
throw new IllegalArgumentException("타임라인(시간 싱크 세그먼트)이 없습니다. 먼저 원본 다운로드/업로드로 전사하세요.");
}
List<String> texts = segments.stream().map(ScriptSegment::text).toList();
List<String> ko = translateService.translateBatch(texts, source, tgt);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < segments.size(); i++) {
String line = (i < ko.size()) ? ko.get(i) : "";
sb.append('[').append(mmss(segments.get(i).start())).append("] ")
.append(line == null ? "" : line.trim()).append('\n');
}
translated = sb.toString().stripTrailing();
} 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("번역할 원본 스크립트가 없습니다. 먼저 전사(원본 다운로드/업로드)하세요.");
} }
String source = scriptOpt.map(ChannelVideoScript::getLanguage).orElse(null); translated = translateService.translate(text, source, tgt);
String tgt = (target == null || target.isBlank()) ? "ko" : target.trim(); }
String translated = translateService.translate(text, source, tgt);
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("translatedText", translated); result.put("translatedText", translated);
result.put("source", source); result.put("source", source);
result.put("target", tgt); result.put("target", tgt);
result.put("format", timeline ? "timeline" : "plain");
return result; return result;
} }
/** 초 → mm:ss (타임라인 줄 머리표시용). */
private static String mmss(double seconds) {
int total = (int) Math.max(0, Math.floor(seconds));
return String.format("%02d:%02d", total / 60, total % 60);
}
/** 저장된 세그먼트로 SRT 문자열을 생성한다. speed 는 배속(1.0=원본). */ /** 저장된 세그먼트로 SRT 문자열을 생성한다. speed 는 배속(1.0=원본). */
public String buildSrt(Long videoId, double speed) { public String buildSrt(Long videoId, double speed) {
ChannelVideo v = find(videoId); ChannelVideo v = find(videoId);

View File

@ -53,6 +53,32 @@ public class TranslateService {
} }
} }
/**
* 여러 텍스트를 번에 번역(LibreTranslate q 배열로 받으면 정렬 맞춰 배열로 반환).
* 세그먼트별 번역에 사용해 입력 순서와 1:1 정렬을 보장한다.
*/
public java.util.List<String> translateBatch(java.util.List<String> texts, String source, String target) {
if (texts == null || texts.isEmpty()) {
return java.util.Collections.emptyList();
}
String apiUrl = baseUrl + "/translate";
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> request =
new HttpEntity<>(buildBatchRequestBody(texts, source, target), headers);
ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, request, String.class);
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw new RuntimeException("Translate API 실패: " + response.getStatusCode());
}
return extractTranslatedTexts(objectMapper, response.getBody());
} catch (Exception e) {
log.error("배치 번역 실패 (source={}, target={}, n={})", source, target, texts.size(), e);
throw new RuntimeException("번역 실패: " + e.getMessage(), e);
}
}
/** LibreTranslate /translate 요청 바디 조립(순수). source 빈값/null → "auto". */ /** LibreTranslate /translate 요청 바디 조립(순수). source 빈값/null → "auto". */
static Map<String, String> buildRequestBody(String text, String source, String target) { static Map<String, String> buildRequestBody(String text, String source, String target) {
Map<String, String> body = new LinkedHashMap<>(); Map<String, String> body = new LinkedHashMap<>();
@ -63,6 +89,16 @@ public class TranslateService {
return body; return body;
} }
/** 배치 요청 바디 조립(순수). q 를 배열로 담는다. source 빈값/null → "auto". */
static Map<String, Object> buildBatchRequestBody(java.util.List<String> texts, String source, String target) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("q", texts);
body.put("source", (source == null || source.isBlank()) ? "auto" : source.trim());
body.put("target", target);
body.put("format", "text");
return body;
}
/** 응답 JSON 에서 translatedText 추출(순수). 필드 없으면 빈 문자열, 파싱 실패 시 예외. */ /** 응답 JSON 에서 translatedText 추출(순수). 필드 없으면 빈 문자열, 파싱 실패 시 예외. */
static String extractTranslatedText(ObjectMapper objectMapper, String json) { static String extractTranslatedText(ObjectMapper objectMapper, String json) {
try { try {
@ -71,4 +107,20 @@ public class TranslateService {
throw new RuntimeException("번역 응답 파싱 실패", e); throw new RuntimeException("번역 응답 파싱 실패", e);
} }
} }
/** 응답의 translatedText 를 리스트로 추출(순수). 배열이면 그대로, 문자열이면 단일 원소. */
static java.util.List<String> extractTranslatedTexts(ObjectMapper objectMapper, String json) {
try {
com.fasterxml.jackson.databind.JsonNode node = objectMapper.readTree(json).path("translatedText");
java.util.List<String> out = new java.util.ArrayList<>();
if (node.isArray()) {
node.forEach(n -> out.add(n.asText("")));
} else {
out.add(node.asText(""));
}
return out;
} catch (Exception e) {
throw new RuntimeException("번역 응답 파싱 실패", e);
}
}
} }

View File

@ -152,9 +152,12 @@
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="copyBtn" onclick="copyToEditor()"> <button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="copyBtn" onclick="copyToEditor()">
<i data-lucide="copy" style="width:15px;"></i> 에디터로 복사 <i data-lucide="copy" style="width:15px;"></i> 에디터로 복사
</button> </button>
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="translateBtn" onclick="translateToEditor()" title="원본 스크립트를 한국어로 번역해 ‘재작성(내 버전) 칸에 넣기"> <button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="translateBtn" onclick="translateToEditor('plain')" title="원본 스크립트를 한국어로 번역해 ‘재작성(내 버전) 칸에 넣기(평문 흐름)">
<i data-lucide="languages" style="width:15px;"></i> 번역→재작성 <i data-lucide="languages" style="width:15px;"></i> 번역→재작성
</button> </button>
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="translateTlBtn" onclick="translateToEditor('timeline')" title="세그먼트별 [00:00] 타임스탬프를 붙여 번역해 ‘재작성’ 칸에 넣기">
<i data-lucide="clock" style="width:15px;"></i> [00:00] 번역
</button>
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="deleteDlBtn" onclick="deleteDownload()" style="display:none; color:#ef4444;" title="서버에 받아둔 원본 mp4 삭제(자막은 유지)"> <button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="deleteDlBtn" onclick="deleteDownload()" style="display:none; color:#ef4444;" title="서버에 받아둔 원본 mp4 삭제(자막은 유지)">
<i data-lucide="trash-2" style="width:15px;"></i> 받은 원본 삭제 <i data-lucide="trash-2" style="width:15px;"></i> 받은 원본 삭제
</button> </button>
@ -628,13 +631,16 @@
} }
// 원본 스크립트를 한국어로 번역해 '재작성(내 버전)' 칸에 넣는다. // 원본 스크립트를 한국어로 번역해 '재작성(내 버전)' 칸에 넣는다.
async function translateToEditor(){ // format: 'plain'(평문 흐름) | 'timeline'([00:00] 세그먼트별 줄)
const btn = document.getElementById('translateBtn'); async function translateToEditor(format){
const tl = format === 'timeline';
const btn = document.getElementById(tl ? 'translateTlBtn' : 'translateBtn');
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '번역 중…'; const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '번역 중…';
try { try {
const r = await api(API + '/' + VIDEO_ID + '/translate?target=ko', { method:'POST' }); const url = API + '/' + VIDEO_ID + '/translate?target=ko' + (tl ? '&format=timeline' : '');
const r = await api(url, { method:'POST' });
const t = (r && r.translatedText) || ''; const t = (r && r.translatedText) || '';
if(!t){ alert('번역 결과가 비었습니다. (원본 스크립트가 있는지 확인하세요)'); return; } if(!t){ alert('번역 결과가 비었습니다. (원본 스크립트' + (tl ? '/세그먼트' : '') + '가 있는지 확인하세요)'); return; }
const ed = document.getElementById('reworkText'); const ed = document.getElementById('reworkText');
ed.value = ed.value ? (ed.value + '\n\n' + t) : t; ed.value = ed.value ? (ed.value + '\n\n' + t) : t;
ed.focus(); ed.focus();

View File

@ -44,4 +44,25 @@ class TranslateServiceTest {
assertThatThrownBy(() -> TranslateService.extractTranslatedText(om, "not-json")) assertThatThrownBy(() -> TranslateService.extractTranslatedText(om, "not-json"))
.isInstanceOf(RuntimeException.class); .isInstanceOf(RuntimeException.class);
} }
@Test
void buildBatchRequestBody_q를_배열로_담고_source_정규화() {
Map<String, Object> b = TranslateService.buildBatchRequestBody(java.util.List.of("a", "b"), null, "ko");
assertThat(b.get("q")).isEqualTo(java.util.List.of("a", "b"));
assertThat(b.get("source")).isEqualTo("auto");
assertThat(b.get("target")).isEqualTo("ko");
assertThat(b.get("format")).isEqualTo("text");
}
@Test
void extractTranslatedTexts_배열응답을_리스트로() {
String json = "{\"translatedText\":[\"안녕\",\"세계\"]}";
assertThat(TranslateService.extractTranslatedTexts(om, json)).containsExactly("안녕", "세계");
}
@Test
void extractTranslatedTexts_문자열응답이면_단일원소리스트() {
assertThat(TranslateService.extractTranslatedTexts(om, "{\"translatedText\":\"안녕\"}"))
.containsExactly("안녕");
}
} }