diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java index 8143e86..6617a70 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java @@ -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> 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) diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java index 683a244..99357cb 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java @@ -178,28 +178,55 @@ public class ChannelVideoCurationService { } /** - * 원본 스크립트(전사 평문)를 번역해 재가공(내 버전) 초안용 텍스트로 반환한다. - * source 는 전사 언어(자동감지가 있으면 그 값), target 기본 ko. 결과 저장은 사용자가 검토 후 직접. + * 원본 스크립트를 번역해 재가공(내 버전) 초안용 텍스트로 반환한다. + * format="timeline" 이면 세그먼트별 "[mm:ss] 번역" 줄로, 그 외(plain)는 전사 평문 흐름으로 번역. + * source 는 전사 언어(없으면 auto), target 기본 ko. 저장은 사용자가 검토 후 직접. */ - public Map translateScript(Long videoId, String target) { + public Map 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 segments = channelService.getSegments(v.getId()); + if (segments.isEmpty()) { + throw new IllegalArgumentException("타임라인(시간 싱크 세그먼트)이 없습니다. 먼저 원본 다운로드/업로드로 전사하세요."); + } + List texts = segments.stream().map(ScriptSegment::text).toList(); + List 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 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); diff --git a/src/main/java/com/hlab/yanalyst/service/TranslateService.java b/src/main/java/com/hlab/yanalyst/service/TranslateService.java index 5048580..7e8b031 100644 --- a/src/main/java/com/hlab/yanalyst/service/TranslateService.java +++ b/src/main/java/com/hlab/yanalyst/service/TranslateService.java @@ -53,6 +53,32 @@ public class TranslateService { } } + /** + * 여러 텍스트를 한 번에 번역(LibreTranslate 는 q 를 배열로 받으면 정렬 맞춰 배열로 반환). + * 세그먼트별 번역에 사용해 입력 순서와 1:1 정렬을 보장한다. + */ + public java.util.List translateBatch(java.util.List 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> request = + new HttpEntity<>(buildBatchRequestBody(texts, source, target), headers); + + ResponseEntity 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 buildRequestBody(String text, String source, String target) { Map body = new LinkedHashMap<>(); @@ -63,6 +89,16 @@ public class TranslateService { return body; } + /** 배치 요청 바디 조립(순수). q 를 배열로 담는다. source 빈값/null → "auto". */ + static Map buildBatchRequestBody(java.util.List texts, String source, String target) { + Map 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 extractTranslatedTexts(ObjectMapper objectMapper, String json) { + try { + com.fasterxml.jackson.databind.JsonNode node = objectMapper.readTree(json).path("translatedText"); + java.util.List 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); + } + } } diff --git a/src/main/resources/templates/rework.html b/src/main/resources/templates/rework.html index 01d9e42..09815b2 100644 --- a/src/main/resources/templates/rework.html +++ b/src/main/resources/templates/rework.html @@ -152,9 +152,12 @@ - + @@ -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(); diff --git a/src/test/java/com/hlab/yanalyst/service/TranslateServiceTest.java b/src/test/java/com/hlab/yanalyst/service/TranslateServiceTest.java index 7f1b948..544906e 100644 --- a/src/test/java/com/hlab/yanalyst/service/TranslateServiceTest.java +++ b/src/test/java/com/hlab/yanalyst/service/TranslateServiceTest.java @@ -44,4 +44,25 @@ class TranslateServiceTest { assertThatThrownBy(() -> TranslateService.extractTranslatedText(om, "not-json")) .isInstanceOf(RuntimeException.class); } + + @Test + void buildBatchRequestBody_q를_배열로_담고_source_정규화() { + Map 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("안녕"); + } }