feat(rework): AI 자막 패널에 원문+번역 비교 표시

화면(Gemini)·음성 패널에서 원문과 한국어 번역을 함께 보여줘 비교 가능하게 함.
- ScriptResponseDto.Segment: original 필드 추가(NON_NULL)
- Gemini 프롬프트/스키마: original(화면 원문) + text(한국어 번역) 둘 다 요청,
  text 는 반드시 한국어(영어 방지) 명시
- rework.html: 각 줄에 한국어(위)+원문(아래 회색) 표시, 음성 패널은 Whisper 원문 사용

검증: 470 화면자막 → 원문 일본어 + 한국어 번역 + 진행형 타임스탬프 정상.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-26 09:12:31 +09:00
parent b151204267
commit d16f23a4c0
4 changed files with 21 additions and 6 deletions

View File

@ -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;
}
}

View File

@ -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 크게 준다.

View File

@ -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; }
</style>
</div>
@ -485,7 +488,10 @@
box.innerHTML = segs.map(sg =>
'<div class="ai-row" data-start="' + sg.start + '" onclick="seekTo(' + sg.start + ')">'
+ '<span class="seg-time">' + mmss(sg.start) + '</span>'
+ '<span class="seg-text">' + esc(sg.text) + '</span></div>').join('');
+ '<span class="ai-txt">'
+ '<span class="seg-text">' + esc(sg.text) + '</span>'
+ (sg.original ? '<span class="ai-orig">' + esc(sg.original) + '</span>' : '')
+ '</span></div>').join('');
return;
}
if(error){ box.innerHTML = '<div class="ai-empty" style="color:#f87171;">' + esc(error) + '</div>'; 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 화면 자막(한국어)

View File

@ -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<ScriptResponseDto.Segment> 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("반갑습니다");
}