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:
parent
77e77894c9
commit
198334c6f8
@ -172,10 +172,12 @@ public class ChannelVideoCurationController {
|
||||
@PostMapping("/{id}/translate")
|
||||
@Operation(summary = "원본 스크립트 번역 → 재가공 초안",
|
||||
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,
|
||||
@RequestParam(value = "target", required = false) String target) {
|
||||
return ApiResponse.ok(curationService.translateScript(id, target));
|
||||
@RequestParam(value = "target", required = false) String 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)
|
||||
|
||||
@ -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);
|
||||
var scriptOpt = channelVideoScriptRepository.findFirstByVideoIdOrderByIdDesc(v.getVideoId());
|
||||
String text = scriptOpt.map(ChannelVideoScript::getTranscript).orElse(null);
|
||||
if (text == null || text.isBlank()) {
|
||||
throw new IllegalArgumentException("번역할 원본 스크립트가 없습니다. 먼저 전사(원본 다운로드/업로드)하세요.");
|
||||
}
|
||||
String source = scriptOpt.map(ChannelVideoScript::getLanguage).orElse(null);
|
||||
String tgt = (target == null || target.isBlank()) ? "ko" : target.trim();
|
||||
boolean timeline = "timeline".equalsIgnoreCase(format);
|
||||
|
||||
String translated = translateService.translate(text, source, tgt);
|
||||
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);
|
||||
if (text == null || text.isBlank()) {
|
||||
throw new IllegalArgumentException("번역할 원본 스크립트가 없습니다. 먼저 전사(원본 다운로드/업로드)하세요.");
|
||||
}
|
||||
translated = 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;
|
||||
}
|
||||
|
||||
/** 초 → 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=원본). */
|
||||
public String buildSrt(Long videoId, double speed) {
|
||||
ChannelVideo v = find(videoId);
|
||||
|
||||
@ -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". */
|
||||
static Map<String, String> buildRequestBody(String text, String source, String target) {
|
||||
Map<String, String> body = new LinkedHashMap<>();
|
||||
@ -63,6 +89,16 @@ public class TranslateService {
|
||||
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 추출(순수). 필드 없으면 빈 문자열, 파싱 실패 시 예외. */
|
||||
static String extractTranslatedText(ObjectMapper objectMapper, String json) {
|
||||
try {
|
||||
@ -71,4 +107,20 @@ public class TranslateService {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,9 +152,12 @@
|
||||
<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> 에디터로 복사
|
||||
</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> 번역→재작성
|
||||
</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 삭제(자막은 유지)">
|
||||
<i data-lucide="trash-2" style="width:15px;"></i> 받은 원본 삭제
|
||||
</button>
|
||||
@ -628,13 +631,16 @@
|
||||
}
|
||||
|
||||
// 원본 스크립트를 한국어로 번역해 '재작성(내 버전)' 칸에 넣는다.
|
||||
async function translateToEditor(){
|
||||
const btn = document.getElementById('translateBtn');
|
||||
// format: 'plain'(평문 흐름) | 'timeline'([00:00] 세그먼트별 줄)
|
||||
async function translateToEditor(format){
|
||||
const tl = format === 'timeline';
|
||||
const btn = document.getElementById(tl ? 'translateTlBtn' : 'translateBtn');
|
||||
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '번역 중…';
|
||||
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) || '';
|
||||
if(!t){ alert('번역 결과가 비었습니다. (원본 스크립트가 있는지 확인하세요)'); return; }
|
||||
if(!t){ alert('번역 결과가 비었습니다. (원본 스크립트' + (tl ? '/세그먼트' : '') + '가 있는지 확인하세요)'); return; }
|
||||
const ed = document.getElementById('reworkText');
|
||||
ed.value = ed.value ? (ed.value + '\n\n' + t) : t;
|
||||
ed.focus();
|
||||
|
||||
@ -44,4 +44,25 @@ class TranslateServiceTest {
|
||||
assertThatThrownBy(() -> TranslateService.extractTranslatedText(om, "not-json"))
|
||||
.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("안녕");
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user