diff --git a/docs/python-service/ocr_video_endpoint.py b/docs/python-service/ocr_video_endpoint.py
deleted file mode 100644
index 72ec023..0000000
--- a/docs/python-service/ocr_video_endpoint.py
+++ /dev/null
@@ -1,141 +0,0 @@
-# ============================================================================
-# videocr(PaddleOCR) 기반 "영상 박힌 자막 → SRT/세그먼트" FastAPI 엔드포인트 예시
-# h-python 서비스(기존 /transcribe·/ocr 있는 FastAPI 앱)에 그대로 붙일 수 있게 작성.
-#
-# 응답 형식을 기존 /transcribe 와 동일하게 맞춤:
-# { "language": "korean", "segments": [ {"start":0.0,"end":2.7,"text":"..."} ], "srt": "..." }
-# → h-lab 쪽은 기존 전사 저장 로직을 거의 그대로 재사용 가능.
-#
-# ── 설치 ────────────────────────────────────────────────────────────────────
-# pip install videocr-PaddleOCR paddleocr
-# # CPU: pip install paddlepaddle
-# # GPU: pip install paddlepaddle-gpu (CUDA 환경일 때만, 훨씬 빠름)
-#
-# ── 언어 코드(PaddleOCR) ─────────────────────────────────────────────────────
-# korean / japan / en / ch / chinese_cht ...
-# (h-lab는 ko/ja/en/zh 로 보낼 수 있으니 아래 LANG_MAP 에서 변환)
-# ============================================================================
-
-import os
-import re
-import tempfile
-
-from fastapi import APIRouter, UploadFile, File, Form, HTTPException
-
-# videocr-PaddleOCR
-from videocr import get_subtitles
-
-router = APIRouter() # 기존 app 에 app.include_router(router) 하거나, 아래 데코를 @app.post 로 바꿔 사용
-
-# h-lab(ko/ja/en/zh) → PaddleOCR 언어 코드
-LANG_MAP = {
- "ko": "korean", "kor": "korean", "korean": "korean",
- "ja": "japan", "jpn": "japan", "japan": "japan",
- "en": "en", "eng": "en",
- "zh": "ch", "chi_sim": "ch", "ch": "ch",
-}
-
-
-def _to_seconds(ts: str) -> float:
- """'HH:MM:SS,mmm' → 초(float)."""
- ts = ts.strip().replace(".", ",")
- h, m, rest = ts.split(":")
- s, ms = (rest.split(",") + ["0"])[:2]
- return int(h) * 3600 + int(m) * 60 + int(s) + int(ms.ljust(3, "0")[:3]) / 1000.0
-
-
-def _parse_srt(srt_text: str):
- """SRT 문자열 → [{start, end, text}] 세그먼트 리스트."""
- segments = []
- # 블록: (선택)인덱스줄 / 시간줄 / 텍스트(여러 줄)
- blocks = re.split(r"\n\s*\n", srt_text.strip())
- time_re = re.compile(r"(\d{1,2}:\d{2}:\d{2}[,.]\d{1,3})\s*-->\s*(\d{1,2}:\d{2}:\d{2}[,.]\d{1,3})")
- for block in blocks:
- lines = [ln for ln in block.splitlines() if ln.strip() != ""]
- if not lines:
- continue
- # 시간줄 찾기(인덱스줄이 있을 수도/없을 수도)
- time_idx = next((i for i, ln in enumerate(lines) if time_re.search(ln)), None)
- if time_idx is None:
- continue
- m = time_re.search(lines[time_idx])
- text = " ".join(ln.strip() for ln in lines[time_idx + 1:]).strip()
- if not text:
- continue
- segments.append({
- "start": round(_to_seconds(m.group(1)), 3),
- "end": round(_to_seconds(m.group(2)), 3),
- "text": text,
- })
- return segments
-
-
-@router.post("/ocr_video")
-async def ocr_video(
- file: UploadFile = File(..., description="영상 파일(mp4 등)"),
- lang: str = Form("ko", description="ko|ja|en|zh (PaddleOCR 코드로 자동 변환)"),
- # 자막 영역만 OCR 하면 정확도·속도 둘 다 좋아짐. 비우면 하단 위주(use_fullframe=False).
- use_fullframe: bool = Form(False, description="True면 전체 프레임 OCR(느림), False면 하단 자막영역 위주"),
- crop_x: int = Form(None), crop_y: int = Form(None),
- crop_width: int = Form(None), crop_height: int = Form(None),
- # 성능/정확도 튜닝
- frames_to_skip: int = Form(1, description="프레임 N개 건너뛰며 OCR(클수록 빠름·덜 정밀)"),
- conf_threshold: int = Form(75, description="OCR 신뢰도 임계(0~100)"),
- sim_threshold: int = Form(80, description="연속 자막 병합 유사도(0~100)"),
- time_start: str = Form("0:00"), time_end: str = Form(""),
- use_gpu: bool = Form(False),
-):
- """영상에 박힌(하드코딩) 자막을 OCR로 추출해 SRT/세그먼트로 반환한다."""
- paddle_lang = LANG_MAP.get((lang or "ko").lower(), "korean")
-
- # 업로드 영상을 임시파일로 저장(videocr 는 파일 경로 필요)
- suffix = os.path.splitext(file.filename or "")[1] or ".mp4"
- tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
- try:
- tmp.write(await file.read())
- tmp.flush()
- tmp.close()
-
- # videocr: 영상 → SRT 문자열 (프레임 샘플링 + OCR + 연속자막 병합 내장)
- srt_text = get_subtitles(
- tmp.name,
- lang=paddle_lang,
- use_gpu=use_gpu,
- time_start=time_start,
- time_end=time_end,
- conf_threshold=conf_threshold,
- sim_threshold=sim_threshold,
- use_fullframe=use_fullframe,
- frames_to_skip=frames_to_skip,
- crop_x=crop_x, crop_y=crop_y,
- crop_width=crop_width, crop_height=crop_height,
- )
-
- segments = _parse_srt(srt_text)
- return {
- "language": paddle_lang,
- "segments": segments,
- "srt": srt_text,
- }
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"OCR 영상 처리 실패: {e}")
- finally:
- try:
- os.unlink(tmp.name)
- except OSError:
- pass
-
-
-# ── 기존 app 에 붙이는 방법 ───────────────────────────────────────────────────
-# from fastapi import FastAPI
-# app = FastAPI()
-# app.include_router(router) # → POST /ocr_video
-#
-# 또는 router 안 쓰고 직접:
-# @app.post("/ocr_video")
-# async def ocr_video(...): ...
-#
-# ── 빠른 테스트 ──────────────────────────────────────────────────────────────
-# curl -X POST http://localhost:8000/ocr_video \
-# -F "file=@shorts.mp4" -F "lang=ja" -F "frames_to_skip=2"
-# → {"language":"japan","segments":[{"start":0.0,"end":2.7,"text":"..."}],"srt":"..."}
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 9be19b6..c8cc30e 100644
--- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java
+++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java
@@ -446,69 +446,7 @@ public class ChannelService {
}
}
- /**
- * 받아둔 원본 영상에서 화면에 박힌(하드코딩) 자막을 Python /ocr_video(ffmpeg+Tesseract)로 추출해
- * 시간 싱크 세그먼트로 저장한다. 응답은 전사와 동일 형식({language, segments}, srt 무시).
- * 음성 전사와 같은 자리(ChannelVideoScript)에 저장 → 스크립트 리스트/SRT/번역에 그대로 흐른다.
- */
- @Transactional
- public ScriptResponseDto ocrFromCached(Long channelVideoId, File file, java.util.Map formParams) {
- 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 {} (params={})", channelVideoId, formParams);
-
- try {
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.MULTIPART_FORM_DATA);
-
- MultiValueMap body = new LinkedMultiValueMap<>();
- body.add("file", toFileResource(file));
- // 서버 필드명 그대로(lang, sample_fps, conf_threshold, use_fullframe, crop_x/y/width/height...).
- // 빈 값은 보내지 않아 /ocr_video 기본값(하단30% crop, sample_fps=3, conf=60)을 따른다.
- if (formParams != null) {
- for (java.util.Map.Entry e : formParams.entrySet()) {
- if (e.getValue() != null) body.add(e.getKey(), String.valueOf(e.getValue()));
- }
- }
-
- 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 승격. */
+ /** 전사 결과 저장: 기존 스크립트 제거 후 새 스크립트 저장 + hasScript 승격. */
private void persistScript(ChannelVideo video, Long channelVideoId, ScriptResponseDto dto)
throws com.fasterxml.jackson.core.JsonProcessingException {
// 재추출 시 기존 스크립트(중복 포함)를 먼저 제거해 videoId 당 1건만 유지한다.
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 4685e3a..bd9aa0b 100644
--- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java
+++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java
@@ -137,7 +137,7 @@ public class ChannelVideoCurationController {
@Operation(summary = "원본 다운로드(캐시만)",
description = "저장된 videoId 로 yt-dlp 가 원본 영상을 받아 서버에 캐시한다(빠름, 전사 제외). "
+ "전사는 분리된 /{id}/transcribe-cached 로 진행 → 전사 서버가 느려도 다운로드는 잃지 않는다. "
- + "응답: {downloaded, sizeBytes}. 이후 전사·렌더·OCR 가 이 캐시 파일을 재사용한다.")
+ + "응답: {downloaded, sizeBytes}. 이후 전사·렌더가 이 캐시 파일을 재사용한다.")
public ApiResponse