revert(rework): 화면 자막 OCR 기능 제거

Tesseract 기반 OCR이 실제 쇼츠의 스타일 자막을 거의 못 읽어 실효성이 없고,
URL→Gemini 방향으로 전환하기로 해 h-lab의 OCR 연동을 제거한다.

- 컨트롤러 POST /{id}/ocr 제거
- CurationService.ocrScreenSubtitles 제거
- ChannelService.ocrFromCached/joinSegmentText 제거(persistScript는 전사가 계속 사용)
- rework.html '화면 자막 OCR' 버튼·crop/fps 입력·ocrScreen() 제거
- docs/python-service/ocr_video_endpoint.py(videocr 예시) 삭제

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-25 16:19:32 +09:00
parent fbce1ec8ea
commit 7ebee1caa3
5 changed files with 4 additions and 309 deletions

View File

@ -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":"..."}

View File

@ -446,69 +446,7 @@ public class ChannelService {
}
}
/**
* 받아둔 원본 영상에서 <b>화면에 박힌(하드코딩) 자막</b> Python /ocr_video(ffmpeg+Tesseract) 추출해
* 시간 싱크 세그먼트로 저장한다. 응답은 전사와 동일 형식({language, segments}, srt 무시).
* 음성 전사와 같은 자리(ChannelVideoScript) 저장 스크립트 리스트/SRT/번역에 그대로 흐른다.
*/
@Transactional
public ScriptResponseDto ocrFromCached(Long channelVideoId, File file, java.util.Map<String, Object> 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<String, Object> 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<String, Object> e : formParams.entrySet()) {
if (e.getValue() != null) body.add(e.getKey(), String.valueOf(e.getValue()));
}
}
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 승격. */
/** 전사 결과 저장: 기존 스크립트 제거 후 새 스크립트 저장 + hasScript 승격. */
private void persistScript(ChannelVideo video, Long channelVideoId, ScriptResponseDto dto)
throws com.fasterxml.jackson.core.JsonProcessingException {
// 재추출 기존 스크립트(중복 포함) 먼저 제거해 videoId 1건만 유지한다.

View File

@ -137,7 +137,7 @@ public class ChannelVideoCurationController {
@Operation(summary = "원본 다운로드(캐시만)",
description = "저장된 videoId 로 yt-dlp 가 원본 영상을 받아 서버에 캐시한다(빠름, 전사 제외). "
+ "전사는 분리된 /{id}/transcribe-cached 로 진행 → 전사 서버가 느려도 다운로드는 잃지 않는다. "
+ "응답: {downloaded, sizeBytes}. 이후 전사·렌더·OCR 가 이 캐시 파일을 재사용한다.")
+ "응답: {downloaded, sizeBytes}. 이후 전사·렌더가 이 캐시 파일을 재사용한다.")
public ApiResponse<Map<String, Object>> download(@PathVariable Long id) {
return ApiResponse.ok(curationService.downloadOriginal(id));
}
@ -177,25 +177,6 @@ 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<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,
@RequestParam(value = "useFullframe", required = false) Boolean useFullframe,
@RequestParam(value = "cropX", required = false) Integer cropX,
@RequestParam(value = "cropY", required = false) Integer cropY,
@RequestParam(value = "cropWidth", required = false) Integer cropWidth,
@RequestParam(value = "cropHeight", required = false) Integer cropHeight) {
return ApiResponse.ok(curationService.ocrScreenSubtitles(
id, lang, sampleFps, confThreshold, useFullframe, cropX, cropY, cropWidth, cropHeight));
}
@PostMapping("/{id}/translate")
@Operation(summary = "원본 스크립트 번역 → 재가공 초안",
description = "원본 전사 스크립트를 LibreTranslate로 번역해 반환한다(프론트가 '재작성' 칸에 채움). "

View File

@ -175,40 +175,6 @@ public class ChannelVideoCurationService {
return videoDownloadService.deleteCache(v.getId());
}
/**
* 받아둔 원본 영상의 화면 박힌 자막을 OCR(Python /ocr_video) 추출해 세그먼트로 저장한다.
* 음성 전사와 같은 자리에 저장되어 스크립트 리스트/SRT/번역에 그대로 흐른다. (기존 스크립트는 덮어씀)
*/
@Transactional
public Map<String, Object> ocrScreenSubtitles(Long videoId, String lang, Integer sampleFps, Integer confThreshold,
Boolean useFullframe, Integer cropX, Integer cropY,
Integer cropWidth, Integer cropHeight) {
ChannelVideo v = find(videoId);
java.io.File file = videoDownloadService.cachedFile(v.getId())
.orElseThrow(() -> new IllegalArgumentException(
"받은 원본이 없습니다. 먼저 '원본 다운로드'를 실행하세요."));
// /ocr_video 서버 필드명으로 조립(null 빼서 서버 기본값 사용).
Map<String, Object> form = new LinkedHashMap<>();
if (lang != null && !lang.isBlank()) form.put("lang", lang.trim());
if (sampleFps != null) form.put("sample_fps", sampleFps);
if (confThreshold != null) form.put("conf_threshold", confThreshold);
if (useFullframe != null) form.put("use_fullframe", useFullframe);
if (cropX != null) form.put("crop_x", cropX);
if (cropY != null) form.put("crop_y", cropY);
if (cropWidth != null) form.put("crop_width", cropWidth);
if (cropHeight != null) form.put("crop_height", cropHeight);
ScriptResponseDto dto = channelService.ocrFromCached(v.getId(), file, form);
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 파일(왼쪽 플레이어 재생용 스트리밍). 캐시 없으면 안내 예외. */
public java.io.File cachedDownloadFile(Long videoId) {
ChannelVideo v = find(videoId);

View File

@ -152,18 +152,6 @@
<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자막
</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>
<span class="text-xs text-muted" title="OCR 대상 영역(하단). 좁힐수록 정확·빠름. 100=전체화면">자막영역 하단</span>
<input id="ocrCropPct" type="number" value="30" min="10" max="100" step="5"
title="OCR 대상 영역(하단 %). 100=전체화면"
style="width:54px; padding:6px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:6px; color:var(--text); font-size:0.85rem;">
<span class="text-xs text-muted">%</span>
<span class="text-xs text-muted" title="초당 OCR 프레임 수. 낮을수록 빠름(저사양 서버는 1 권장). 자막이 빨리 바뀌면 2~3">· 속도 fps</span>
<input id="ocrFps" type="number" value="1" min="1" max="5" step="1"
title="초당 OCR 프레임 수(낮을수록 빠름). N150 같은 저사양은 1 권장"
style="width:46px; padding:6px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:6px; color:var(--text); font-size:0.85rem;">
<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> 에디터로 복사
</button>
@ -532,7 +520,7 @@
if(window.lucide) lucide.createIcons();
return;
}
// 2단계: 전사(분리). 실패해도 다운로드(영상)는 유지 → 화면자막 OCR/재시도 가능.
// 2단계: 전사(분리). 실패해도 다운로드(영상)는 유지 → '전사' 버튼으로 재시도 가능.
try {
const s = await runTranscribeCached();
status.style.color = '#4ade80';
@ -542,7 +530,7 @@
} catch(e){
status.style.color = '#fbbf24';
status.textContent = '다운로드는 완료 ✓ · 전사 실패(서버 혼잡일 수 있음): ' + e.message
+ ' — ‘전사’ 버튼으로 다시 시도하거나 ‘화면 자막 OCR을 쓰세요';
+ ' — ‘전사’ 버튼으로 다시 시도하세요';
} finally {
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
if(window.lucide) lucide.createIcons();
@ -696,43 +684,6 @@
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 fps = Math.min(5, Math.max(1, parseInt(document.getElementById('ocrFps').value) || 1));
let q = '?lang=' + encodeURIComponent(lang) + '&sampleFps=' + fps;
// 자막영역(하단 N%)을 영상 실제 해상도로 환산해 crop 전달 → 정확도·속도↑
const pct = Math.min(100, Math.max(10, parseInt(document.getElementById('ocrCropPct').value) || 30));
const v = document.getElementById('localVideo');
const vw = v ? v.videoWidth : 0, vh = v ? v.videoHeight : 0;
if (pct >= 100) {
q += '&useFullframe=true';
} else if (vw > 0 && vh > 0) {
const ch = Math.round(vh * pct / 100);
q += '&useFullframe=false&cropX=0&cropWidth=' + vw + '&cropHeight=' + ch + '&cropY=' + (vh - ch);
}
// 영상 해상도를 못 읽으면 crop 생략 → 서버 기본(하단 30%) 적용
const s = await api(API + '/' + VIDEO_ID + '/ocr' + q, { 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 = '추출 중...';