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