From f7ac96f1dd6f6698e7bf63b6e114e0a55692376e Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Thu, 25 Jun 2026 14:31:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(rework):=20=ED=99=94=EB=A9=B4=20=EC=9E=90?= =?UTF-8?q?=EB=A7=89=20OCR(=EC=98=81=EC=83=81=20=EB=B0=95=ED=9E=8C=20?= =?UTF-8?q?=EC=9E=90=EB=A7=89)=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 받은 원본 영상을 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) --- .../domain/channel/ChannelService.java | 99 ++++++++++++++++--- .../ChannelVideoCurationController.java | 13 +++ .../channel/ChannelVideoCurationService.java | 20 ++++ src/main/resources/templates/rework.html | 27 +++++ 4 files changed, 143 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java index 0313b70..76dcc08 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java @@ -435,22 +435,7 @@ public class ChannelService { } ScriptResponseDto dto = objectMapper.readValue(response.getBody(), ScriptResponseDto.class); - - // 재추출 시 기존 스크립트(중복 포함)를 먼저 제거해 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); + persistScript(video, channelVideoId, dto); log.info("Saved whisper transcript for channel video id: {} ({} segments)", channelVideoId, dto.getSegments() == null ? 0 : dto.getSegments().size()); @@ -461,6 +446,88 @@ public class ChannelService { } } + /** + * 받아둔 원본 영상에서 화면에 박힌(하드코딩) 자막을 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 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> request = new HttpEntity<>(body, headers); + ResponseEntity 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 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 getSegments(Long channelVideoId) { ChannelVideo video = channelVideoRepository.findById(channelVideoId) 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 6617a70..d9d963b 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java @@ -169,6 +169,19 @@ public class ChannelVideoCurationController { .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> 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") @Operation(summary = "원본 스크립트 번역 → 재가공 초안", description = "원본 전사 스크립트를 LibreTranslate로 번역해 반환한다(프론트가 '재작성' 칸에 채움). " 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 b796bb4..90d7f5d 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java @@ -160,6 +160,26 @@ public class ChannelVideoCurationService { return videoDownloadService.deleteCache(v.getId()); } + /** + * 받아둔 원본 영상의 화면 박힌 자막을 OCR(Python /ocr_video)로 추출해 세그먼트로 저장한다. + * 음성 전사와 같은 자리에 저장되어 스크립트 리스트/SRT/번역에 그대로 흐른다. (기존 스크립트는 덮어씀) + */ + @Transactional + public Map 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 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 파일(왼쪽 플레이어 재생용 스트리밍). 캐시 없으면 안내 예외. */ public java.io.File cachedDownloadFile(Long videoId) { ChannelVideo v = find(videoId); diff --git a/src/main/resources/templates/rework.html b/src/main/resources/templates/rework.html index da567e1..9227613 100644 --- a/src/main/resources/templates/rework.html +++ b/src/main/resources/templates/rework.html @@ -149,6 +149,9 @@ + @@ -634,6 +637,30 @@ 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(){ const btn = document.getElementById('extractBtn'); const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '추출 중...';