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