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> 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> 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로 번역해 반환한다(프론트가 '재작성' 칸에 채움). " 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 73cb250..de04776 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java @@ -175,40 +175,6 @@ 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, - 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 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 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 85fa1e1..ef72105 100644 --- a/src/main/resources/templates/rework.html +++ b/src/main/resources/templates/rework.html @@ -152,18 +152,6 @@ - - 자막영역 하단 - - % - · 속도 fps - @@ -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 = '추출 중...';