diff --git a/src/main/java/com/hlab/yanalyst/domain/production/dto/ScriptResponseDto.java b/src/main/java/com/hlab/yanalyst/domain/production/dto/ScriptResponseDto.java index 26503a0..4958a34 100644 --- a/src/main/java/com/hlab/yanalyst/domain/production/dto/ScriptResponseDto.java +++ b/src/main/java/com/hlab/yanalyst/domain/production/dto/ScriptResponseDto.java @@ -24,5 +24,9 @@ public class ScriptResponseDto { private double start; private double end; private String text; + + /** 화면 자막(Gemini): 번역 전 원문. 음성 전사엔 없으므로 null 이면 직렬화 생략. */ + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + private String original; } } diff --git a/src/main/java/com/hlab/yanalyst/service/GeminiSubtitleService.java b/src/main/java/com/hlab/yanalyst/service/GeminiSubtitleService.java index 8e03306..43c357c 100644 --- a/src/main/java/com/hlab/yanalyst/service/GeminiSubtitleService.java +++ b/src/main/java/com/hlab/yanalyst/service/GeminiSubtitleService.java @@ -32,8 +32,11 @@ public class GeminiSubtitleService { + "음성/말소리는 무시하고 화면의 글자만 대상으로 합니다. " + "각 자막이 화면에 나타나는 시작 시각과 사라지는 끝 시각을 '영상 시작 기준 초'로 최대한 정확히 적으세요(예: 0, 2.5, 8.1). " + "중요: 모든 항목의 시간을 0으로 두지 마세요. 자막이 바뀌는 순서대로 start 가 증가해야 합니다. " - + "JSON 배열로만 출력: [{\"start\": 시작초, \"end\": 끝초, \"text\": \"한국어 번역\"}]. " - + "text 는 한국어 번역. 화면 자막이 전혀 없으면 빈 배열 []."; + + "각 자막의 화면 원문(그대로)과 한국어 번역을 함께 주세요. " + + "JSON 배열로만 출력: [{\"start\": 시작초, \"end\": 끝초, \"original\": \"화면 원문 그대로\", \"text\": \"한국어 번역\"}]. " + + "original 은 화면에 보이는 원문 그대로(번역하지 말 것). " + + "text 는 그 원문을 반드시 '한국어'로 번역한 결과입니다 — 영어가 아니라 한국어로 쓰세요. " + + "화면 자막이 전혀 없으면 빈 배열 []."; private final RestTemplate geminiRestTemplate; private final ObjectMapper objectMapper; @@ -124,6 +127,7 @@ public class GeminiSubtitleService { "properties", Map.of( "start", Map.of("type", "NUMBER"), "end", Map.of("type", "NUMBER"), + "original", Map.of("type", "STRING"), "text", Map.of("type", "STRING"))); // Gemini 2.5 Flash 는 'thinking' 모델: 정확한 타임스탬프는 thinking 으로 추론한다(끄면 시간이 전부 0). // 단 thinking 토큰이 출력 한도를 먹어 JSON 이 잘리면 0개가 되므로 maxOutputTokens 를 크게 준다. diff --git a/src/main/resources/templates/rework.html b/src/main/resources/templates/rework.html index 92044b4..e36282c 100644 --- a/src/main/resources/templates/rework.html +++ b/src/main/resources/templates/rework.html @@ -255,6 +255,9 @@ .ai-row { display:flex; gap:8px; padding:6px 10px; cursor:pointer; border-bottom:1px solid var(--surface); font-size:0.85rem; line-height:1.5; } .ai-row:hover { background:var(--surface); } + .ai-txt { display:flex; flex-direction:column; min-width:0; } + .ai-txt .seg-text { color:var(--text); } + .ai-orig { color:var(--text-2); font-size:0.78rem; opacity:0.8; margin-top:1px; } .ai-empty { padding:14px 10px; color:var(--text-2); font-size:0.85rem; text-align:center; } @@ -485,7 +488,10 @@ box.innerHTML = segs.map(sg => '
' + '' + mmss(sg.start) + '' - + '' + esc(sg.text) + '
').join(''); + + '' + + '' + esc(sg.text) + '' + + (sg.original ? '' + esc(sg.original) + '' : '') + + '').join(''); return; } if(error){ box.innerHTML = '
' + esc(error) + '
'; return; } @@ -506,7 +512,7 @@ if(!SEGMENTS || !SEGMENTS.length) return { note: '음성 없음 — 말소리가 없거나(음악·화면자막만) ‘자막 추출’ 미실행' }; const r = await api(API + '/' + VIDEO_ID + '/translate?target=ko&format=segments', { method:'POST' }); const ko = (r && r.texts) || []; - return { segments: SEGMENTS.map((sg, i) => ({ start: sg.start, end: sg.end, text: (ko[i] != null ? ko[i] : (sg.text || '')) })) }; + return { segments: SEGMENTS.map((sg, i) => ({ start: sg.start, end: sg.end, text: (ko[i] != null ? ko[i] : (sg.text || '')), original: sg.text })) }; })().catch(e => ({ error: '음성 번역 실패: ' + e.message })); // 오른쪽: Gemini 화면 자막(한국어) diff --git a/src/test/java/com/hlab/yanalyst/service/GeminiSubtitleServiceTest.java b/src/test/java/com/hlab/yanalyst/service/GeminiSubtitleServiceTest.java index 4593bce..f16c84c 100644 --- a/src/test/java/com/hlab/yanalyst/service/GeminiSubtitleServiceTest.java +++ b/src/test/java/com/hlab/yanalyst/service/GeminiSubtitleServiceTest.java @@ -28,15 +28,16 @@ class GeminiSubtitleServiceTest { } @Test - void extractSegments_정상응답의_JSON배열을_세그먼트로() { + void extractSegments_정상응답의_JSON배열을_세그먼트로_원문포함() { String resp = "{\"candidates\":[{\"content\":{\"parts\":[{\"text\":" - + "\"[{\\\"start\\\":0.0,\\\"end\\\":3.0,\\\"text\\\":\\\"안녕하세요\\\"}," + + "\"[{\\\"start\\\":0.0,\\\"end\\\":3.0,\\\"original\\\":\\\"こんにちは\\\",\\\"text\\\":\\\"안녕하세요\\\"}," + "{\\\"start\\\":3.0,\\\"end\\\":5.5,\\\"text\\\":\\\"반갑습니다\\\"}]\"}]}}]}"; List segs = GeminiSubtitleService.extractSegments(om, resp); assertThat(segs).hasSize(2); assertThat(segs.get(0).getStart()).isEqualTo(0.0); assertThat(segs.get(0).getEnd()).isEqualTo(3.0); + assertThat(segs.get(0).getOriginal()).isEqualTo("こんにちは"); assertThat(segs.get(0).getText()).isEqualTo("안녕하세요"); assertThat(segs.get(1).getText()).isEqualTo("반갑습니다"); }