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:
parent
fbce1ec8ea
commit
7ebee1caa3
@ -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":"..."}
|
||||
@ -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건만 유지한다.
|
||||
|
||||
@ -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로 번역해 반환한다(프론트가 '재작성' 칸에 채움). "
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 = '추출 중...';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user