feat(rework): 화면 자막 OCR(영상 박힌 자막) 연동
받은 원본 영상을 Python /ocr_video(ffmpeg+Tesseract)로 보내 화면에 박힌 자막을
시간 싱크 세그먼트로 추출·저장한다. 음성 전사와 같은 자리(ChannelVideoScript)에
저장되어 스크립트 리스트·SRT·번역·한국어 SRT에 그대로 흐른다.
- ChannelService.ocrFromCached + persistScript 공통 추출(전사/OCR 공유), joinSegmentText
- CurationService.ocrScreenSubtitles(받은 원본 캐시 사용), POST /{id}/ocr
- rework.html '화면 자막 OCR' 버튼 + ocrScreen()
- sampleFps/confThreshold null이면 미전송 → 서버 기본값 사용
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a2934af1a3
commit
f7ac96f1dd
@ -435,22 +435,7 @@ public class ChannelService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ScriptResponseDto dto = objectMapper.readValue(response.getBody(), ScriptResponseDto.class);
|
ScriptResponseDto dto = objectMapper.readValue(response.getBody(), ScriptResponseDto.class);
|
||||||
|
persistScript(video, channelVideoId, dto);
|
||||||
// 재추출 시 기존 스크립트(중복 포함)를 먼저 제거해 videoId 당 1건만 유지한다.
|
|
||||||
channelVideoScriptRepository.deleteAll(
|
|
||||||
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
|
||||||
|
|
||||||
ChannelVideoScript script = new ChannelVideoScript();
|
|
||||||
script.setChannelVideoId(channelVideoId);
|
|
||||||
script.setVideoId(video.getVideoId());
|
|
||||||
script.setLanguage(dto.getLanguage());
|
|
||||||
script.setTranscript(dto.getTranscript());
|
|
||||||
script.setSegmentsJson(objectMapper.writeValueAsString(
|
|
||||||
dto.getSegments() == null ? Collections.emptyList() : dto.getSegments()));
|
|
||||||
channelVideoScriptRepository.save(script);
|
|
||||||
|
|
||||||
video.setHasScript(true);
|
|
||||||
channelVideoRepository.save(video);
|
|
||||||
|
|
||||||
log.info("Saved whisper transcript for channel video id: {} ({} segments)",
|
log.info("Saved whisper transcript for channel video id: {} ({} segments)",
|
||||||
channelVideoId, dto.getSegments() == null ? 0 : dto.getSegments().size());
|
channelVideoId, dto.getSegments() == null ? 0 : dto.getSegments().size());
|
||||||
@ -461,6 +446,88 @@ public class ChannelService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 받아둔 원본 영상에서 <b>화면에 박힌(하드코딩) 자막</b>을 Python /ocr_video(ffmpeg+Tesseract)로 추출해
|
||||||
|
* 시간 싱크 세그먼트로 저장한다. 응답은 전사와 동일 형식({language, segments}, srt 무시).
|
||||||
|
* 음성 전사와 같은 자리(ChannelVideoScript)에 저장 → 스크립트 리스트/SRT/번역에 그대로 흐른다.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ScriptResponseDto ocrFromCached(Long channelVideoId, File file, String lang,
|
||||||
|
Integer sampleFps, Integer confThreshold) {
|
||||||
|
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
|
||||||
|
|
||||||
|
String apiUrl = pythonBaseUrl + "/ocr_video";
|
||||||
|
log.info("Requesting screen-subtitle OCR for video {} (lang={}, fps={})", channelVideoId, lang, sampleFps);
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||||
|
|
||||||
|
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||||
|
body.add("file", toFileResource(file));
|
||||||
|
if (lang != null && !lang.isBlank()) {
|
||||||
|
body.add("lang", lang.trim());
|
||||||
|
}
|
||||||
|
// null 이면 보내지 않아 Python /ocr_video 의 기본값(sample_fps=3, conf=60)을 따른다.
|
||||||
|
if (sampleFps != null) body.add("sample_fps", String.valueOf(sampleFps));
|
||||||
|
if (confThreshold != null) body.add("conf_threshold", String.valueOf(confThreshold));
|
||||||
|
|
||||||
|
HttpEntity<MultiValueMap<String, Object>> request = new HttpEntity<>(body, headers);
|
||||||
|
ResponseEntity<String> response = pythonRestTemplate.postForEntity(apiUrl, request, String.class);
|
||||||
|
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
||||||
|
throw new RuntimeException("OCR API failed with status: " + response.getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
ScriptResponseDto dto = objectMapper.readValue(response.getBody(), ScriptResponseDto.class);
|
||||||
|
// OCR 응답엔 평문 transcript 가 없으므로 세그먼트 텍스트를 이어 붙여 채운다.
|
||||||
|
if (dto.getTranscript() == null || dto.getTranscript().isBlank()) {
|
||||||
|
dto.setTranscript(joinSegmentText(dto.getSegments()));
|
||||||
|
}
|
||||||
|
persistScript(video, channelVideoId, dto);
|
||||||
|
|
||||||
|
log.info("Saved screen-subtitle OCR for channel video id: {} ({} segments)",
|
||||||
|
channelVideoId, dto.getSegments() == null ? 0 : dto.getSegments().size());
|
||||||
|
return dto;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error OCR-ing screen subtitles for video " + channelVideoId, e);
|
||||||
|
throw new RuntimeException("화면 자막 OCR 실패: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 세그먼트 텍스트를 공백으로 이어 평문 transcript 를 만든다. */
|
||||||
|
private String joinSegmentText(List<ScriptResponseDto.Segment> segments) {
|
||||||
|
if (segments == null || segments.isEmpty()) return "";
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (ScriptResponseDto.Segment s : segments) {
|
||||||
|
if (s.getText() != null && !s.getText().isBlank()) {
|
||||||
|
if (sb.length() > 0) sb.append(' ');
|
||||||
|
sb.append(s.getText().trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전사/OCR 공통: 기존 스크립트 제거 후 새 스크립트 저장 + hasScript 승격. */
|
||||||
|
private void persistScript(ChannelVideo video, Long channelVideoId, ScriptResponseDto dto)
|
||||||
|
throws com.fasterxml.jackson.core.JsonProcessingException {
|
||||||
|
// 재추출 시 기존 스크립트(중복 포함)를 먼저 제거해 videoId 당 1건만 유지한다.
|
||||||
|
channelVideoScriptRepository.deleteAll(
|
||||||
|
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
||||||
|
|
||||||
|
ChannelVideoScript script = new ChannelVideoScript();
|
||||||
|
script.setChannelVideoId(channelVideoId);
|
||||||
|
script.setVideoId(video.getVideoId());
|
||||||
|
script.setLanguage(dto.getLanguage());
|
||||||
|
script.setTranscript(dto.getTranscript());
|
||||||
|
script.setSegmentsJson(objectMapper.writeValueAsString(
|
||||||
|
dto.getSegments() == null ? Collections.emptyList() : dto.getSegments()));
|
||||||
|
channelVideoScriptRepository.save(script);
|
||||||
|
|
||||||
|
video.setHasScript(true);
|
||||||
|
channelVideoRepository.save(video);
|
||||||
|
}
|
||||||
|
|
||||||
/** 최신 스크립트의 세그먼트 목록을 반환한다. 없으면 빈 리스트. */
|
/** 최신 스크립트의 세그먼트 목록을 반환한다. 없으면 빈 리스트. */
|
||||||
public List<ScriptSegment> getSegments(Long channelVideoId) {
|
public List<ScriptSegment> getSegments(Long channelVideoId) {
|
||||||
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
|
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
|
||||||
|
|||||||
@ -169,6 +169,19 @@ public class ChannelVideoCurationController {
|
|||||||
.body(resource); // Resource 반환 → Spring 이 Range 요청을 자동으로 206 처리
|
.body(resource); // Resource 반환 → Spring 이 Range 요청을 자동으로 206 처리
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/ocr")
|
||||||
|
@Operation(summary = "화면 자막 OCR(영상에 박힌 자막)",
|
||||||
|
description = "받아둔 원본 영상의 화면 박힌 자막을 Python /ocr_video(ffmpeg+Tesseract)로 추출해 "
|
||||||
|
+ "시간 싱크 세그먼트로 저장한다(음성 전사와 같은 자리, 기존 스크립트 덮어씀). "
|
||||||
|
+ "쿼리: lang(ko|ja|en|zh), sampleFps(기본3), confThreshold(기본60). "
|
||||||
|
+ "응답: {hasScript, language, transcript, segments}. 먼저 '원본 다운로드' 필요.")
|
||||||
|
public ApiResponse<Map<String, Object>> ocr(@PathVariable Long id,
|
||||||
|
@RequestParam(value = "lang", required = false) String lang,
|
||||||
|
@RequestParam(value = "sampleFps", required = false) Integer sampleFps,
|
||||||
|
@RequestParam(value = "confThreshold", required = false) Integer confThreshold) {
|
||||||
|
return ApiResponse.ok(curationService.ocrScreenSubtitles(id, lang, sampleFps, confThreshold));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/translate")
|
@PostMapping("/{id}/translate")
|
||||||
@Operation(summary = "원본 스크립트 번역 → 재가공 초안",
|
@Operation(summary = "원본 스크립트 번역 → 재가공 초안",
|
||||||
description = "원본 전사 스크립트를 LibreTranslate로 번역해 반환한다(프론트가 '재작성' 칸에 채움). "
|
description = "원본 전사 스크립트를 LibreTranslate로 번역해 반환한다(프론트가 '재작성' 칸에 채움). "
|
||||||
|
|||||||
@ -160,6 +160,26 @@ public class ChannelVideoCurationService {
|
|||||||
return videoDownloadService.deleteCache(v.getId());
|
return videoDownloadService.deleteCache(v.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 받아둔 원본 영상의 화면 박힌 자막을 OCR(Python /ocr_video)로 추출해 세그먼트로 저장한다.
|
||||||
|
* 음성 전사와 같은 자리에 저장되어 스크립트 리스트/SRT/번역에 그대로 흐른다. (기존 스크립트는 덮어씀)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> ocrScreenSubtitles(Long videoId, String lang, Integer sampleFps, Integer confThreshold) {
|
||||||
|
ChannelVideo v = find(videoId);
|
||||||
|
java.io.File file = videoDownloadService.cachedFile(v.getId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException(
|
||||||
|
"받은 원본이 없습니다. 먼저 '원본 다운로드'를 실행하세요."));
|
||||||
|
ScriptResponseDto dto = channelService.ocrFromCached(v.getId(), file, lang, sampleFps, confThreshold);
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("hasScript", true);
|
||||||
|
result.put("language", dto.getLanguage());
|
||||||
|
result.put("transcript", dto.getTranscript() == null ? "" : dto.getTranscript());
|
||||||
|
result.put("segments", channelService.getSegments(v.getId()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** 받아둔 원본 mp4 파일(왼쪽 플레이어 재생용 스트리밍). 캐시 없으면 안내 예외. */
|
/** 받아둔 원본 mp4 파일(왼쪽 플레이어 재생용 스트리밍). 캐시 없으면 안내 예외. */
|
||||||
public java.io.File cachedDownloadFile(Long videoId) {
|
public java.io.File cachedDownloadFile(Long videoId) {
|
||||||
ChannelVideo v = find(videoId);
|
ChannelVideo v = find(videoId);
|
||||||
|
|||||||
@ -149,6 +149,9 @@
|
|||||||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="extractBtn" onclick="extractScript()" title="YouTube 자막에서 평문 추출(타임스탬프 없음)">
|
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="extractBtn" onclick="extractScript()" title="YouTube 자막에서 평문 추출(타임스탬프 없음)">
|
||||||
<i data-lucide="download-cloud" style="width:15px;"></i> URL자막
|
<i data-lucide="download-cloud" style="width:15px;"></i> URL자막
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="ocrBtn" onclick="ocrScreen()" title="받은 원본 영상에 박힌 자막을 OCR로 추출(시간 싱크). 먼저 ‘원본 다운로드’ 필요">
|
||||||
|
<i data-lucide="scan-text" style="width:15px;"></i> 화면 자막 OCR
|
||||||
|
</button>
|
||||||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="copyBtn" onclick="copyToEditor()">
|
<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> 에디터로 복사
|
<i data-lucide="copy" style="width:15px;"></i> 에디터로 복사
|
||||||
</button>
|
</button>
|
||||||
@ -634,6 +637,30 @@
|
|||||||
if(window.lucide) lucide.createIcons();
|
if(window.lucide) lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 받은 원본 영상의 화면 박힌 자막을 OCR로 추출 → 시간 싱크 세그먼트로 표시(전사와 동일 자리).
|
||||||
|
async function ocrScreen(){
|
||||||
|
const status = document.getElementById('transcribeStatus');
|
||||||
|
const btn = document.getElementById('ocrBtn');
|
||||||
|
const orig = btn.innerHTML; btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = 'OCR 중…';
|
||||||
|
status.style.display = 'block'; status.style.color = '#facc15';
|
||||||
|
status.textContent = '화면 자막 OCR 중… (프레임 분석, 영상 길이에 따라 수십 초~)';
|
||||||
|
try {
|
||||||
|
const lang = document.getElementById('langSel').value || 'ko';
|
||||||
|
const s = await api(API + '/' + VIDEO_ID + '/ocr?lang=' + encodeURIComponent(lang), { method:'POST' });
|
||||||
|
renderSegments(s.segments);
|
||||||
|
document.getElementById('transcript').value = s.transcript || '';
|
||||||
|
status.style.color = '#4ade80';
|
||||||
|
status.textContent = '화면 자막 OCR 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트'
|
||||||
|
+ (s.language ? (' · ' + s.language) : '');
|
||||||
|
} catch(e){
|
||||||
|
status.style.color = '#f87171';
|
||||||
|
status.textContent = 'OCR 실패: ' + e.message;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
|
||||||
|
if(window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function extractScript(){
|
async function extractScript(){
|
||||||
const btn = document.getElementById('extractBtn');
|
const btn = document.getElementById('extractBtn');
|
||||||
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '추출 중...';
|
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '추출 중...';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user