diff --git a/1eee6fcc4d86 b/1eee6fcc4d86 new file mode 100644 index 0000000..e69de29 diff --git a/Running b/Running new file mode 100644 index 0000000..e69de29 diff --git a/app/Dockerfile b/app/Dockerfile index ea4ac1f..ee2cfda 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -13,6 +13,7 @@ RUN apt-get update && apt-get install -y \ libegl1 \ wget \ curl \ + openjdk-21-jre-headless \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/app/main.py b/app/main.py index e9fbe12..e0de3cd 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,7 @@ from auth import (authenticate, create_access_token, init_users, list_users, create_user, update_user, delete_user) from tasks import celery_app, transcribe_task, subtitle_pipeline_task from ocr_tasks import ocr_task +from pdf_tasks import pdf_convert_task app = FastAPI(title="VoiceScript API") @@ -32,6 +33,7 @@ os.makedirs(OUTPUT_DIR, exist_ok=True) AUDIO_EXT = {"mp3","mp4","wav","m4a","ogg","flac","aac","wma","webm", "mkv","avi","mov","ts","mts","m2ts","wmv","flv","h264","h265","hevc","264","265","m4v"} IMAGE_EXT = {"jpg","jpeg","png","bmp","tiff","tif","webp","gif"} +PDF_FMT = {"html","docx","xlsx","pptx"} _DEFAULT_SETTINGS = { "stt_ollama_model":"","ocr_ollama_model":"granite3.2-vision:latest", @@ -122,6 +124,13 @@ def _update_history_by_task(task_id:str, result:dict, success:bool, error_msg:st "srt_trans":result.get("srt_trans",""), "vtt_trans":result.get("vtt_trans",""), } + elif h["type"]=="pdf": + h["output"]={ + "output_file":result.get("output_file",""), + "target_fmt":result.get("target_fmt",""), + "file_size":result.get("file_size",0), + "pdf_name":result.get("pdf_name",""), + } else: ft=result.get("full_text","") h["output"]={ @@ -307,6 +316,44 @@ async def transcribe_batch(request:Request,files:List[UploadFile]=File(...), # ════════════════════════════════════════════════════════════════ # 자막 # ════════════════════════════════════════════════════════════════ +async def _dispatch_subtitle(request,files,src_language,subtitle_fmt,stt_engine, + refine_model,refine_via,translate_to,trans_model,trans_via,user): + if subtitle_fmt not in ("srt","vtt","both"): subtitle_fmt="srt" + s=_load_settings() + if not stt_engine: stt_engine=s.get("default_stt_engine","local") + _rm=refine_model if refine_model.strip() else ( + s.get("openrouter_stt_model","") if refine_via=="openrouter" else s.get("stt_ollama_model","")) + _tm=trans_model if trans_model.strip() else ( + s.get("openrouter_stt_model","") if trans_via=="openrouter" else s.get("stt_ollama_model","")) + subtitle_timeout=int(s.get("subtitle_timeout",600)) + results=[] + for file in files: + _check_size(request) + ext=_ext(file.filename) + if ext not in AUDIO_EXT: + results.append({"error":f"{file.filename}: 지원하지 않는 형식","filename":file.filename}); continue + file_id=str(uuid.uuid4()) + save_path=os.path.join(UPLOAD_DIR,f"{file_id}.{ext}") + await _save_upload(file,save_path) + file_size=os.path.getsize(save_path) + task=subtitle_pipeline_task.delay( + file_id,save_path,src_language,subtitle_fmt, + stt_engine,s.get("groq_api_key",""),s.get("openai_api_key",""), + _rm,refine_via,translate_to,_tm,trans_via, + s.get("openrouter_url",""),s.get("openrouter_api_key",""), + subtitle_timeout, + ) + append_history({"id":file_id,"task_id":task.id,"type":"subtitle","status":"processing", + "timestamp":datetime.now().strftime("%Y-%m-%d %H:%M:%S"),"username":user["username"], + "input":{"filename":file.filename,"size_bytes":file_size,"format":ext.upper()}, + "settings":{"src_language":src_language or "auto","subtitle_fmt":subtitle_fmt, + "stt_engine":stt_engine,"refine_model":_rm,"refine_via":refine_via, + "translate_to":translate_to,"trans_model":_tm,"trans_via":trans_via, + "subtitle_timeout":subtitle_timeout}, + "output":None}) + results.append({"task_id":task.id,"file_id":file_id,"filename":file.filename}) + return results + @app.post("/api/subtitle") async def create_subtitle( request:Request, file:UploadFile=File(...), @@ -316,39 +363,25 @@ async def create_subtitle( translate_to:str=Form(""),trans_model:str=Form(""),trans_via:str=Form("ollama"), user:dict=Depends(require_subtitle), ): - _check_size(request) - ext=_ext(file.filename) - if ext not in AUDIO_EXT: raise HTTPException(400,"지원하지 않는 형식입니다") - if subtitle_fmt not in ("srt","vtt","both"): subtitle_fmt="srt" - s=_load_settings() - if not stt_engine: stt_engine=s.get("default_stt_engine","local") - if not refine_model.strip(): - refine_model=(s.get("openrouter_stt_model","") if refine_via=="openrouter" - else s.get("stt_ollama_model","")) - if not trans_model.strip(): - trans_model=(s.get("openrouter_stt_model","") if trans_via=="openrouter" - else s.get("stt_ollama_model","")) - file_id=str(uuid.uuid4()) - save_path=os.path.join(UPLOAD_DIR,f"{file_id}.{ext}") - await _save_upload(file,save_path) - file_size=os.path.getsize(save_path) - subtitle_timeout=int(s.get("subtitle_timeout",600)) - task=subtitle_pipeline_task.delay( - file_id,save_path,src_language,subtitle_fmt, - stt_engine,s.get("groq_api_key",""),s.get("openai_api_key",""), - refine_model,refine_via,translate_to,trans_model,trans_via, - s.get("openrouter_url",""),s.get("openrouter_api_key",""), - subtitle_timeout, - ) - append_history({"id":file_id,"task_id":task.id,"type":"subtitle","status":"processing", - "timestamp":datetime.now().strftime("%Y-%m-%d %H:%M:%S"),"username":user["username"], - "input":{"filename":file.filename,"size_bytes":file_size,"format":ext.upper()}, - "settings":{"src_language":src_language or "auto","subtitle_fmt":subtitle_fmt, - "stt_engine":stt_engine,"refine_model":refine_model,"refine_via":refine_via, - "translate_to":translate_to,"trans_model":trans_model,"trans_via":trans_via, - "subtitle_timeout":subtitle_timeout}, - "output":None}) - return {"task_id":task.id,"file_id":file_id,"filename":file.filename} + items=await _dispatch_subtitle(request,[file],src_language,subtitle_fmt,stt_engine, + refine_model,refine_via,translate_to,trans_model,trans_via,user) + if "error" in items[0]: raise HTTPException(400,items[0]["error"]) + return items[0] + +@app.post("/api/subtitle/batch") +async def create_subtitle_batch( + request:Request, files:List[UploadFile]=File(...), + src_language:str=Form(""),subtitle_fmt:str=Form("srt"), + stt_engine:str=Form("local"), + refine_model:str=Form(""),refine_via:str=Form("ollama"), + translate_to:str=Form(""),trans_model:str=Form(""),trans_via:str=Form("ollama"), + user:dict=Depends(require_subtitle), +): + if not files: raise HTTPException(400,"파일이 없습니다") + if len(files)>10: raise HTTPException(400,"최대 10개까지") + items=await _dispatch_subtitle(request,files,src_language,subtitle_fmt,stt_engine, + refine_model,refine_via,translate_to,trans_model,trans_via,user) + return {"items":items,"total":len(items)} # ════════════════════════════════════════════════════════════════ @@ -401,6 +434,44 @@ async def ocr_batch(request:Request,files:List[UploadFile]=File(...), return {"items":items,"total":len(items)} +# ════════════════════════════════════════════════════════════════ +# PDF 변환 +# ════════════════════════════════════════════════════════════════ +async def _dispatch_pdf(request, files, target_fmt, user): + if target_fmt not in PDF_FMT: target_fmt="html" + results=[] + for file in files: + _check_size(request) + if not file.filename.lower().endswith(".pdf"): + results.append({"error":f"{file.filename}: PDF 파일만 지원합니다","filename":file.filename}); continue + file_id=str(uuid.uuid4()) + save_path=os.path.join(UPLOAD_DIR,f"{file_id}.pdf") + await _save_upload(file,save_path); file_size=os.path.getsize(save_path) + task=pdf_convert_task.delay(file_id,save_path,target_fmt) + append_history({"id":file_id,"task_id":task.id,"type":"pdf","status":"processing", + "timestamp":datetime.now().strftime("%Y-%m-%d %H:%M:%S"),"username":user["username"], + "input":{"filename":file.filename,"size_bytes":file_size,"format":"PDF"}, + "settings":{"target_fmt":target_fmt}, + "output":None}) + results.append({"task_id":task.id,"file_id":file_id,"filename":file.filename}) + return results + +@app.post("/api/pdf/convert") +async def pdf_convert(request:Request,file:UploadFile=File(...), + target_fmt:str=Form("html"),user:dict=Depends(require_auth)): + items=await _dispatch_pdf(request,[file],target_fmt,user) + if "error" in items[0]: raise HTTPException(400,items[0]["error"]) + return items[0] + +@app.post("/api/pdf/convert/batch") +async def pdf_convert_batch(request:Request,files:List[UploadFile]=File(...), + target_fmt:str=Form("html"),user:dict=Depends(require_auth)): + if not files: raise HTTPException(400,"파일이 없습니다") + if len(files)>10: raise HTTPException(400,"최대 10개까지") + items=await _dispatch_pdf(request,files,target_fmt,user) + return {"items":items,"total":len(items)} + + # ════════════════════════════════════════════════════════════════ # 이력 # ════════════════════════════════════════════════════════════════ @@ -431,6 +502,9 @@ def download(filename:str,user:dict=Depends(require_auth)): path=os.path.join(OUTPUT_DIR,filename) if not os.path.exists(path): raise HTTPException(404,"파일을 찾을 수 없습니다") if filename.endswith(".xlsx"): media="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + elif filename.endswith(".docx"): media="application/vnd.openxmlformats-officedocument.wordprocessingml.document" + elif filename.endswith(".pptx"): media="application/vnd.openxmlformats-officedocument.presentationml.presentation" + elif filename.endswith(".html"): media="text/html; charset=utf-8" elif filename.endswith(".vtt"): media="text/vtt" elif filename.endswith(".srt"): media="text/plain; charset=utf-8" else: media="text/plain; charset=utf-8" diff --git a/app/pdf_tasks.py b/app/pdf_tasks.py new file mode 100644 index 0000000..b65485c --- /dev/null +++ b/app/pdf_tasks.py @@ -0,0 +1,357 @@ +""" +PDF 변환 Celery Tasks — opendataloader-pdf (Java) 기반 +지원 출력 포맷: html, docx, xlsx, pptx +""" +import os, tempfile +from pathlib import Path +from celery import Celery + +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") +celery_app = Celery("whisper_tasks", broker=REDIS_URL, backend=REDIS_URL) +celery_app.conf.update( + task_serializer="json", result_serializer="json", + accept_content=["json"], task_track_started=True, result_expires=86400, +) + +OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/data/outputs") + + +# ── 출력 포맷 상수 ──────────────────────────────────────────────── +SUPPORTED_FORMATS = ("html", "docx", "xlsx", "pptx") + + +# ── opendataloader-pdf 실행 ──────────────────────────────────────── +def _run_odpdf(pdf_path: str, out_dir: str, formats: list) -> dict: + """ + opendataloader-pdf로 PDF를 변환하고 생성된 파일 경로 dict 반환. + formats: ["html", "json"] 형태 (odpdf 네이티브 포맷) + """ + try: + from opendataloader_pdf import convert + except ImportError: + raise Exception("opendataloader-pdf 패키지가 설치되지 않았습니다.") + + try: + convert( + input_path=pdf_path, + output_dir=out_dir, + format=formats, + quiet=True, + ) + except FileNotFoundError: + raise Exception("Java가 설치되지 않았습니다. Docker 이미지를 재빌드하세요.") + except Exception as e: + raise Exception(f"PDF 파싱 실패: {e}") + + stem = Path(pdf_path).stem + result = {} + for fmt in formats: + ext = "json" if fmt == "json" else fmt + candidate = os.path.join(out_dir, f"{stem}.{ext}") + if os.path.exists(candidate): + result[fmt] = candidate + return result + + +# ── HTML 파일 정리 ───────────────────────────────────────────────── +def _read_html(path: str) -> str: + with open(path, "r", encoding="utf-8", errors="replace") as f: + return f.read() + + +# ── HTML → DOCX ─────────────────────────────────────────────────── +def _html_to_docx(html_content: str, output_path: str): + from docx import Document + from docx.shared import Pt, RGBColor + from bs4 import BeautifulSoup + + soup = BeautifulSoup(html_content, "lxml") + doc = Document() + + HEADING_MAP = {"h1": 1, "h2": 2, "h3": 3, "h4": 4, "h5": 5, "h6": 6} + + def _add_table(tag): + rows = tag.find_all("tr") + if not rows: + return + cols = max(len(r.find_all(["td", "th"])) for r in rows) + if cols == 0: + return + t = doc.add_table(rows=len(rows), cols=cols) + t.style = "Table Grid" + for ri, row in enumerate(rows): + cells = row.find_all(["td", "th"]) + for ci, cell in enumerate(cells): + if ci < cols: + t.cell(ri, ci).text = cell.get_text(strip=True) + + body = soup.find("body") or soup + for el in body.find_all( + ["h1", "h2", "h3", "h4", "h5", "h6", "p", "table", "ul", "ol"], + recursive=False, + ) or body.children: + name = getattr(el, "name", None) + if name in HEADING_MAP: + text = el.get_text(strip=True) + if text: + doc.add_heading(text, level=HEADING_MAP[name]) + elif name == "p": + text = el.get_text(strip=True) + if text: + doc.add_paragraph(text) + elif name == "table": + _add_table(el) + elif name in ("ul", "ol"): + for li in el.find_all("li"): + text = li.get_text(strip=True) + if text: + doc.add_paragraph(text, style="List Bullet") + + # 본문에 태그 없는 경우 전체 텍스트 추가 + if not any(p.text.strip() for p in doc.paragraphs): + doc.add_paragraph(soup.get_text(separator="\n", strip=True)) + + doc.save(output_path) + + +# ── HTML → XLSX ──────────────────────────────────────────────────── +def _html_to_xlsx(html_content: str, output_path: str, pdf_name: str): + import openpyxl + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + from bs4 import BeautifulSoup + + soup = BeautifulSoup(html_content, "lxml") + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "텍스트" + + tables = soup.find_all("table") + if tables: + # 첫 번째 시트: 전체 텍스트 + row_idx = 1 + for el in (soup.find("body") or soup).find_all( + ["h1","h2","h3","h4","h5","h6","p"], recursive=True + ): + text = el.get_text(strip=True) + if text: + ws.cell(row=row_idx, column=1, value=text) + row_idx += 1 + + # 각 표마다 시트 추가 + for ti, tbl in enumerate(tables, 1): + ws2 = wb.create_sheet(title=f"표{ti}") + rows = tbl.find_all("tr") + for ri, row in enumerate(rows, 1): + cells = row.find_all(["td", "th"]) + for ci, cell in enumerate(cells, 1): + text = cell.get_text(strip=True) + c = ws2.cell(row=ri, column=ci, value=text) + if cell.name == "th": + c.font = Font(bold=True) + c.fill = PatternFill("solid", fgColor="D9E1F2") + c.alignment = Alignment(wrap_text=True) + ws2.column_dimensions[ + openpyxl.utils.get_column_letter(max(len(row.find_all(["td","th"])) for row in rows) or 1) + ] + else: + # 표 없음 — 전체 텍스트를 행으로 분리 + lines = [l.strip() for l in soup.get_text(separator="\n").split("\n") if l.strip()] + for i, line in enumerate(lines, 1): + ws.cell(row=i, column=1, value=line) + + wb.save(output_path) + + +# ── HTML → PPTX ──────────────────────────────────────────────────── +def _html_to_pptx(html_content: str, output_path: str, pdf_name: str): + from pptx import Presentation + from pptx.util import Inches, Pt + from pptx.dml.color import RGBColor + from pptx.enum.text import PP_ALIGN + from bs4 import BeautifulSoup, NavigableString + + soup = BeautifulSoup(html_content, "lxml") + prs = Presentation() + prs.slide_width = Inches(13.33) + prs.slide_height = Inches(7.5) + + blank_layout = prs.slide_layouts[6] # blank + + def _new_slide(): + return prs.slides.add_slide(blank_layout) + + def _add_textbox(slide, text, left, top, width, height, font_size=18, bold=False, color=None): + txBox = slide.shapes.add_textbox(left, top, width, height) + tf = txBox.text_frame + tf.word_wrap = True + p = tf.paragraphs[0] + run = p.add_run() + run.text = text + run.font.size = Pt(font_size) + run.font.bold = bold + if color: + run.font.color.rgb = RGBColor(*color) + + HEADING_SIZES = {1: 36, 2: 28, 3: 22, 4: 18, 5: 16, 6: 14} + HEADING_MAP = {f"h{i}": i for i in range(1, 7)} + + body = soup.find("body") or soup + slide = _new_slide() + y = Inches(0.3) + margin_x = Inches(0.5) + slide_w = prs.slide_width - Inches(1.0) + slide_h = prs.slide_height + + # 제목 슬라이드 + title_text = (soup.find("title") or soup.find("h1") or "") + title_str = title_text.get_text(strip=True) if hasattr(title_text, "get_text") else pdf_name + _add_textbox(slide, title_str, margin_x, Inches(3.0), slide_w, Inches(1.5), font_size=40, bold=True, color=(0x1F, 0x39, 0x7D)) + slide = _new_slide() + y = Inches(0.3) + + for el in body.find_all( + ["h1","h2","h3","h4","h5","h6","p","table"], recursive=True + ): + name = el.name + if y > slide_h - Inches(0.8): + slide = _new_slide() + y = Inches(0.3) + + if name in HEADING_MAP: + level = HEADING_MAP[name] + text = el.get_text(strip=True) + if not text: + continue + fs = HEADING_SIZES.get(level, 18) + h = Inches(0.6) if level <= 2 else Inches(0.45) + _add_textbox(slide, text, margin_x, y, slide_w, h, + font_size=fs, bold=True, + color=(0x1F, 0x39, 0x7D) if level == 1 else (0x2E, 0x74, 0xB5)) + y += h + Inches(0.1) + + elif name == "p": + text = el.get_text(strip=True) + if not text: + continue + lines = (len(text) // 80) + 1 + h = Inches(0.28 * min(lines, 8)) + _add_textbox(slide, text, margin_x, y, slide_w, h, font_size=14) + y += h + Inches(0.08) + + elif name == "table": + rows = el.find_all("tr") + if not rows: + continue + # 표 제목 박스 + _add_textbox(slide, "[ 표 ]", margin_x, y, slide_w, Inches(0.3), font_size=12, bold=True, color=(0x80, 0x80, 0x80)) + y += Inches(0.32) + row_h = Inches(0.28) + for row in rows[:20]: + cells = row.find_all(["td", "th"]) + line = " │ ".join(c.get_text(strip=True) for c in cells) + if not line.strip(): + continue + if y > slide_h - Inches(0.5): + slide = _new_slide() + y = Inches(0.3) + is_header = any(c.name == "th" for c in cells) + _add_textbox(slide, line, margin_x, y, slide_w, row_h, + font_size=11, bold=is_header) + y += row_h + y += Inches(0.1) + + if len(prs.slides) == 0 or (len(prs.slides) == 1 and not prs.slides[0].shapes): + slide = _new_slide() + _add_textbox(slide, soup.get_text(separator="\n", strip=True)[:2000], + margin_x, Inches(0.3), slide_w, Inches(6.5), font_size=14) + + prs.save(output_path) + + +# ══════════════════════════════════════════════════════════════════ +# Celery Task +# ══════════════════════════════════════════════════════════════════ +@celery_app.task(bind=True, name="tasks.pdf_convert_task", queue="stt") +def pdf_convert_task(self, file_id: str, pdf_path: str, target_fmt: str): + """ + PDF를 target_fmt(html/docx/xlsx/pptx)로 변환. + """ + os.makedirs(OUTPUT_DIR, exist_ok=True) + tmp_dir = tempfile.mkdtemp(prefix=f"odpdf_{file_id}_") + pdf_name = Path(pdf_path).stem + + try: + self.update_state(state="PROGRESS", meta={"progress": 10, "message": "PDF 파싱 중 (Java)..."}) + + # opendataloader-pdf 실행 — HTML + JSON 동시 추출 + odpdf_formats = ["html"] + if target_fmt in ("docx", "xlsx", "pptx"): + odpdf_formats.append("json") + + converted = _run_odpdf(pdf_path, tmp_dir, odpdf_formats) + + if "html" not in converted: + # fallback: text 포맷으로 재시도 + converted = _run_odpdf(pdf_path, tmp_dir, ["text"]) + if not converted: + raise Exception("PDF에서 내용을 추출할 수 없습니다.") + + self.update_state(state="PROGRESS", meta={"progress": 50, "message": f"{target_fmt.upper()} 변환 중..."}) + + output_filename = f"{file_id}.{target_fmt}" + output_path = os.path.join(OUTPUT_DIR, output_filename) + + if target_fmt == "html": + # HTML은 opendataloader-pdf 직접 출력 사용 + html_src = converted.get("html") + if html_src: + import shutil + shutil.copy2(html_src, output_path) + else: + # text fallback + text_src = converted.get("text", "") + with open(output_path, "w", encoding="utf-8") as f: + f.write(f"
{text_src}
") + + else: + html_src = converted.get("html") + if html_src: + html_content = _read_html(html_src) + else: + # HTML 없으면 텍스트로 최소 HTML 생성 + text_src = converted.get("text") + if text_src: + with open(text_src, "r", encoding="utf-8", errors="replace") as f: + raw = f.read() + html_content = f"
{raw}
" + else: + raise Exception("PDF 파싱 결과가 없습니다.") + + if target_fmt == "docx": + _html_to_docx(html_content, output_path) + elif target_fmt == "xlsx": + _html_to_xlsx(html_content, output_path, pdf_name) + elif target_fmt == "pptx": + _html_to_pptx(html_content, output_path, pdf_name) + + self.update_state(state="PROGRESS", meta={"progress": 95, "message": "파일 저장 중..."}) + + file_size = os.path.getsize(output_path) + return { + "output_file": output_filename, + "target_fmt": target_fmt, + "file_size": file_size, + "pdf_name": pdf_name, + } + + except Exception as e: + raise Exception(f"PDF 변환 실패: {e}") + + finally: + try: + import shutil as _sh + _sh.rmtree(tmp_dir, ignore_errors=True) + if os.path.exists(pdf_path): + os.remove(pdf_path) + except Exception: + pass diff --git a/app/requirements.txt b/app/requirements.txt index 7f5133a..22079cb 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -22,3 +22,10 @@ Pillow>=10.0.0 # 시스템 모니터링 psutil>=5.9.0 + +# PDF 변환 +opendataloader-pdf>=2.4.2 +python-docx>=1.1.0 +python-pptx>=0.6.23 +beautifulsoup4>=4.12.0 +lxml>=5.0.0 diff --git a/app/static/index.html b/app/static/index.html index e45e9f1..fa33466 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -305,8 +305,55 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border .btn-cancel:hover{background:rgba(255,107,53,.15)} /* 이력 자막 뱃지 */ .hist-type-badge.subtitle{background:rgba(77,166,255,.1);color:var(--blue);border:1px solid rgba(77,166,255,.2)} +.hist-type-badge.pdf{background:rgba(251,146,60,.1);color:var(--orange);border:1px solid rgba(251,146,60,.2)} +/* ── 자막 배치 ── */ +.sub-file-list{display:flex;flex-direction:column;gap:4px} +.sub-file-item{display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--surf2);border:1px solid var(--border);border-radius:3px} +.sub-file-name{font-family:var(--mono);font-size:.72rem;color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0} +.sub-file-size{font-family:var(--mono);font-size:.6rem;color:var(--muted);flex-shrink:0} +.sub-file-rm{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.75rem;padding:0 2px;flex-shrink:0;line-height:1} +.sub-file-rm:hover{color:var(--warn)} +.sub-file-count{font-family:var(--mono);font-size:.62rem;color:var(--muted);margin-top:4px;text-align:right} +.sub-batch-area{margin-bottom:14px} +.sub-batch-card{background:var(--surf2);border:1px solid var(--border2);border-radius:4px;padding:12px;margin-bottom:8px} +.sub-batch-head{display:flex;align-items:center;gap:8px;margin-bottom:6px} +.sub-batch-fname{font-family:var(--mono);font-size:.72rem;color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0} +.sub-batch-badge{font-family:var(--mono);font-size:.58rem;padding:2px 7px;border-radius:2px;flex-shrink:0} +.sub-batch-badge.waiting{background:rgba(255,255,255,.04);color:var(--muted);border:1px solid var(--border)} +.sub-batch-badge.running{background:rgba(77,166,255,.08);color:var(--blue);border:1px solid rgba(77,166,255,.25)} +.sub-batch-badge.done{background:rgba(0,229,160,.08);color:var(--accent);border:1px solid rgba(0,229,160,.25)} +.sub-batch-badge.failed{background:rgba(255,107,53,.08);color:var(--warn);border:1px solid rgba(255,107,53,.25)} +.sub-batch-prog-wrap{height:2px;background:var(--border);border-radius:1px;overflow:hidden;margin-bottom:6px} +.sub-batch-prog-bar{height:100%;background:var(--blue);width:0%;transition:width .4s ease} +.sub-batch-dl{display:flex;gap:6px;flex-wrap:wrap} +.sub-batch-dl-btn{padding:5px 10px;background:none;border:1px solid var(--border2);color:var(--muted);border-radius:2px;font-family:var(--mono);font-size:.6rem;cursor:pointer;transition:all .15s} +.sub-batch-dl-btn:hover{border-color:var(--accent);color:var(--accent)} +.sub-batch-dl-btn.trans{border-color:#3a7cc4;color:var(--blue)} +.sub-batch-dl-btn.trans:hover{background:rgba(77,166,255,.07)} +.sub-batch-err{font-family:var(--mono);font-size:.62rem;color:var(--warn);margin-top:4px;word-break:break-all} +.sub-batch-summary{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:rgba(0,229,160,.05);border:1px solid rgba(0,229,160,.15);border-radius:3px;margin-bottom:12px;font-family:var(--mono);font-size:.7rem;color:var(--accent)} @media(min-width:768px){.sub-info-grid{grid-template-columns:repeat(4,1fr)}.sub-dl-grid{grid-template-columns:repeat(4,1fr)}} +/* ── PDF 변환 ── */ +#page-pdf{display:none;flex-direction:column}#page-pdf.active{display:flex} +.pdf-wrap{max-width:860px;margin:0 auto;padding:28px 16px;width:100%} +.pdf-card{background:var(--surf);border:1px solid var(--border2);border-radius:6px;padding:20px;margin-bottom:14px} +.pdf-card h3{font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;margin-bottom:14px;padding-bottom:10px;border-bottom:1px solid var(--border)} +.pdf-fmt-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:6px} +.pdf-fmt-btn{padding:12px 4px;background:var(--surf);border:1px solid var(--border2);color:var(--muted);border-radius:4px;font-family:var(--mono);font-size:.68rem;cursor:pointer;text-align:center;transition:all .18s;display:flex;flex-direction:column;align-items:center;gap:4px} +.pdf-fmt-btn .pf-icon{font-size:1.4rem;opacity:.45;transition:opacity .18s} +.pdf-fmt-btn .pf-name{font-weight:600} +.pdf-fmt-btn .pf-desc{font-size:.55rem;color:var(--muted);line-height:1.3} +.pdf-fmt-btn.active{background:rgba(77,166,255,.08);border-color:#3a7cc4;color:var(--blue)} +.pdf-fmt-btn.active .pf-icon{opacity:1} +.pdf-queue{display:none;margin-top:10px} +.pdf-queue-list{display:flex;flex-direction:column;gap:4px;margin-bottom:6px} +.pdf-queue-summary{font-family:var(--mono);font-size:.64rem;color:var(--muted)}.pdf-queue-summary span{color:var(--text)} +.pdf-result-card{background:var(--surf2);border:1px solid rgba(77,166,255,.2);border-radius:6px;padding:16px;margin-top:10px;display:none} +.pdf-result-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-bottom:12px} +.pdf-result-item{background:var(--surf);border:1px solid var(--border);border-radius:4px;padding:10px 12px} +.pdf-result-label{font-family:var(--mono);font-size:.58rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;margin-bottom:4px} +.pdf-result-val{font-family:var(--mono);font-size:.88rem;color:var(--accent);font-weight:600} /* ── ADMIN ── */ #page-admin{display:none;flex-direction:column} #page-admin.active{display:flex} @@ -406,6 +453,7 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border @@ -710,12 +759,13 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border

📁 영상 / 오디오 파일

- + 🎬 -
탭하거나 드래그하여 선택
mp4 · mkv · h.264/h.265 · mp3 · wav 등
+
탭하거나 드래그하여 선택
mp4 · mkv · h.264/h.265 · mp3 · wav 등 · 최대 10개
- @@ -825,6 +875,7 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
+
✓ 자막 생성 완료
@@ -836,6 +887,64 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
+ + + +
+
+ + +
+
+ +
+

📁 PDF 파일 업로드

+
+ + 📄 +
탭하거나 드래그하여 선택
PDF 파일 · 최대 10개
+
opendataloader-pdf (Java) 기반 · 높은 인식률
+
+
+
+
+ + +
+
+
+ +
+

🎯 출력 포맷

+
+ + + + +
+
+ + + +
+ +
@@ -845,7 +954,7 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border

👤 사용자 관리

사용자 목록

-
사용자명역할STTOCR관리
+
사용자명역할STTOCR자막관리

신규 사용자 추가

@@ -924,111 +1033,6 @@ const HIST_PER=15; const api=(method,url,body)=>{const o={method,headers:{Authorization:'Bearer '+(token||'')}};if(body)o.body=body;return fetch(url,o)}; -// ══ AUTH ══ -async function checkAuth(){ - token=localStorage.getItem('vs_token'); - if(!token){showLogin();return} - try{const r=await api('GET','/api/me');if(r.ok){currentUser=await r.json();applyUserUI();await Promise.all([loadOllamaModels(),loadSettings()]);hideLogin();startSysMonitor()}else showLogin()} - catch{showLogin()} -} -function applyUserUI(){ - document.getElementById('user-name').textContent=currentUser.username; - const b=document.getElementById('user-badge');b.textContent=currentUser.role==='admin'?'ADMIN':'USER';b.className='user-badge '+currentUser.role; - document.getElementById('admin-tab').style.display=currentUser.role==='admin'?'flex':'none'; - document.getElementById('btn-hist-clear').style.display=currentUser.role==='admin'?'block':'none'; -} -const showLogin=()=>{document.getElementById('login-overlay').style.display='flex';stopSysMonitor()}; -const hideLogin=()=>document.getElementById('login-overlay').style.display='none'; -document.getElementById('btn-login').addEventListener('click',doLogin); -document.getElementById('inp-pass').addEventListener('keydown',e=>{if(e.key==='Enter')doLogin()}); -async function doLogin(){ - const u=document.getElementById('inp-user').value.trim(),p=document.getElementById('inp-pass').value; - const err=document.getElementById('login-err');err.style.display='none'; - if(!u||!p){err.style.display='block';err.textContent='아이디와 비밀번호를 입력하세요';return} - const fd=new FormData();fd.append('username',u);fd.append('password',p); - try{const r=await fetch('/api/login',{method:'POST',body:fd});const d=await r.json();if(!r.ok){err.style.display='block';err.textContent=d.detail||'로그인 실패';return}token=d.access_token;localStorage.setItem('vs_token',token);await checkAuth()} - catch{err.style.display='block';err.textContent='서버 연결 실패'} -} -document.getElementById('btn-logout').addEventListener('click',()=>{token=null;currentUser=null;localStorage.removeItem('vs_token');showLogin();document.getElementById('inp-pass').value=''}); - -// ══ SYS MONITOR ══ -function startSysMonitor(){fetchSysInfo();sysTimer=setInterval(fetchSysInfo,6000)} -function stopSysMonitor(){if(sysTimer){clearInterval(sysTimer);sysTimer=null}} -async function fetchSysInfo(){ - try{const r=await api('GET','/api/system');if(!r.ok)return;const d=await r.json(); - const p=d.ram_percent||0;const bar=document.getElementById('ram-bar'); - bar.style.width=p+'%';bar.style.background=p>85?'var(--warn)':p>65?'#f0b42a':'var(--accent)'; - document.getElementById('ram-text').textContent=`${d.ram_avail_gb}G여유`; - document.getElementById('cpu-text').textContent=`CPU ${d.cpu_percent}%`; - updateSC('ram',d.ram_percent,`${d.ram_used_gb}GB / ${d.ram_total_gb}GB`,`여유 ${d.ram_avail_gb}GB`,'var(--accent)'); - updateSC('cpu',d.cpu_percent,`${d.cpu_percent}%`,`물리 ${d.cpu_physical}코어 / 논리 ${d.cpu_logical}스레드`,'var(--blue)'); - const sp=d.swap_total_gb>0?Math.round(d.swap_used_gb/d.swap_total_gb*100):0; - updateSC('swap',sp,`${d.swap_used_gb}GB / ${d.swap_total_gb}GB`,`사용률 ${sp}%`,'var(--orange)'); - const th=d.cpu_threads_setting;document.getElementById('sys-threads-val').textContent=th===0?`자동 (${d.cpu_logical}스레드)`:`${th} 스레드`; - const sl=document.getElementById('cpu-slider');if(sl.max{const v=parseInt(cpuSlider.value);cpuDisplay.textContent=v===0?'0 (자동)':v+' 스레드'}); - -// ══ OLLAMA 모델 ══ -async function loadOllamaModels(){ - try{const r=await api('GET','/api/ollama/models');const d=await r.json();ollamaModels=d.models||[]; - const badge=document.getElementById('ollama-status-badge'); - if(badge){badge.className='ollama-status '+(d.connected?'ok':'fail');badge.textContent=d.connected?`✓ Ollama(${ollamaModels.length})`:'✗ Ollama 연결실패'} - populateModelSelects()}catch{} -} -function populateModelSelects(){ - const fill=(sel,def,ph)=>{const cur=sel.value||def||'';sel.innerHTML=``;ollamaModels.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;sel.appendChild(o)})}; - fill(document.getElementById('stt-ollama-model'),appSettings.stt_ollama_model,'설정 기본 모델 사용'); - fill(document.getElementById('ocr-ollama-model'),appSettings.ocr_ollama_model,'설정 기본 모델 사용'); - fill(document.getElementById('setting-stt-model'),appSettings.stt_ollama_model,'(없음)'); - fill(document.getElementById('setting-ocr-model'),appSettings.ocr_ollama_model,'(없음)'); -} - -// ══ 설정 ══ -async function loadSettings(){ - try{const r=await api('GET','/api/settings');appSettings=await r.json(); - const th=appSettings.cpu_threads||0;cpuSlider.value=th;cpuDisplay.textContent=th===0?'0 (자동)':th+' 스레드'; - document.getElementById('stt-timeout').value=appSettings.stt_timeout||0; - document.getElementById('ollama-timeout').value=appSettings.ollama_timeout||600; - populateModelSelects()}catch{} -} -document.getElementById('btn-save-settings').addEventListener('click',async()=>{ - const fd=new FormData(); - fd.append('stt_ollama_model',document.getElementById('setting-stt-model').value); - fd.append('ocr_ollama_model',document.getElementById('setting-ocr-model').value); - fd.append('cpu_threads',cpuSlider.value); - fd.append('stt_timeout',document.getElementById('stt-timeout').value||'0'); - fd.append('ollama_timeout',document.getElementById('ollama-timeout').value||'600'); - try{const r=await api('POST','/api/settings',fd);if(r.ok){appSettings=(await r.json()).settings;const msg=document.getElementById('settings-msg');msg.style.display='block';setTimeout(()=>msg.style.display='none',3500)}}catch{} -}); -document.getElementById('btn-refresh-models').addEventListener('click',loadOllamaModels); - -// ══ NAV ══ -document.querySelectorAll('.nav-tab').forEach(btn=>{ - btn.addEventListener('click',()=>{ - document.querySelectorAll('.nav-tab').forEach(b=>b.classList.remove('active')); - document.querySelectorAll('.page').forEach(p=>p.classList.remove('active')); - btn.classList.add('active'); - const p=document.getElementById('page-'+btn.dataset.page);if(p)p.classList.add('active'); - if(btn.dataset.page==='admin')loadUsers(); - if(btn.dataset.page==='settings'){loadSettings();fetchSysInfo()} - if(btn.dataset.page==='history'){histPage=1;loadHistory()} - }); -}); - // ══ STT ══ const sttDrop=document.getElementById('stt-drop'),sttInput=document.getElementById('stt-input'); let sttQueue=[], sttCurrentTaskId=null; @@ -1079,16 +1083,18 @@ document.getElementById('stt-btn').addEventListener('click',async()=>{ document.getElementById('stt-prog').style.display='block'; setProg('stt',0,`${pending.length}개 파일 업로드 중...`); const fd=new FormData(); - pending.forEach(item=>fd.append('files',item.file)); + const isSttSingle=pending.length===1; + if(isSttSingle) fd.append('file',pending[0].file); + else pending.forEach(item=>fd.append('files',item.file)); fd.append('use_ollama',sttEngine==='whisper+ollama'?'true':'false'); fd.append('ollama_model',document.getElementById('stt-ollama-model')?.value||''); fd.append('use_openrouter',sttEngine==='whisper+openrouter'?'true':'false'); fd.append('openrouter_model',document.getElementById('stt-or-model')?.value||''); fd.append('stt_engine',appSettings.default_stt_engine||'local'); try{ - const url=pending.length===1?'/api/transcribe':'/api/transcribe/batch'; + const url=isSttSingle?'/api/transcribe':'/api/transcribe/batch'; const r=await api('POST',url,fd); const d=await r.json(); - if(!r.ok)throw new Error(d.detail||'업로드 실패'); + if(!r.ok){const det=d.detail;throw new Error(Array.isArray(det)?det.map(e=>e.msg||JSON.stringify(e)).join(', '):det||'업로드 실패');} const items=pending.length===1?[d]:(d.items||[]); let pi=0; sttQueue.forEach((qItem,qi)=>{ @@ -1118,7 +1124,7 @@ document.getElementById('stt-btn').addEventListener('click',async()=>{ }catch{} },2000); }); - }catch(e){showErr('stt-err',e.message);document.getElementById('stt-btn').disabled=false;document.getElementById('stt-prog').style.display='none'} + }catch(e){showErr('stt-err',e instanceof Error?e.message:String(e));document.getElementById('stt-btn').disabled=false;document.getElementById('stt-prog').style.display='none'} }); function checkSttDone(){ @@ -1198,16 +1204,18 @@ document.getElementById('ocr-btn').addEventListener('click',async()=>{ document.getElementById('ocr-prog').style.display='block'; setProg('ocr',0,`${pending.length}개 업로드 중...`); const fd=new FormData(); - pending.forEach(item=>fd.append('files',item.file)); + const isOcrSingle=pending.length===1; + if(isOcrSingle) fd.append('file',pending[0].file); + else pending.forEach(item=>fd.append('files',item.file)); fd.append('mode',ocrMode); fd.append('backend',ocrEngine); fd.append('ollama_model',ocrEngine==='ollama'?(document.getElementById('ocr-ollama-model')?.value||''):''); fd.append('openrouter_model',ocrEngine==='openrouter'?(document.getElementById('ocr-or-model')?.value||''):''); const cp=ocrEngine==='openrouter'?(document.getElementById('custom-prompt-or')?.value||''):(document.getElementById('custom-prompt')?.value||''); fd.append('custom_prompt',cp); try{ - const url=pending.length===1?'/api/ocr':'/api/ocr/batch'; + const url=isOcrSingle?'/api/ocr':'/api/ocr/batch'; const r=await api('POST',url,fd); const d=await r.json(); - if(!r.ok)throw new Error(d.detail||'업로드 실패'); + if(!r.ok){const det=d.detail;throw new Error(Array.isArray(det)?det.map(e=>e.msg||JSON.stringify(e)).join(', '):det||'업로드 실패');} const items=pending.length===1?[d]:(d.items||[]); let pi=0; ocrQueue.forEach((qItem,qi)=>{ @@ -1228,7 +1236,7 @@ document.getElementById('ocr-btn').addEventListener('click',async()=>{ }catch{} },2000); }); - }catch(e){showErr('ocr-err',e.message);document.getElementById('ocr-btn').disabled=false;document.getElementById('ocr-prog').style.display='none'} + }catch(e){showErr('ocr-err',e instanceof Error?e.message:String(e));document.getElementById('ocr-btn').disabled=false;document.getElementById('ocr-prog').style.display='none'} }); function setOcrLoading(on){const isAI=ocrEngine!=='paddle',c=isAI?'var(--purple)':'var(--accent)';document.getElementById('ocr-btn').disabled=on;document.getElementById('ocr-prog').style.display=on?'block':'none';document.getElementById('ocr-wave').style.display=on?'flex':'none';document.getElementById('ocr-pfill').style.background=c;document.getElementById('ocr-ppct').style.color=c;document.querySelectorAll('#ocr-wave .wave-bar').forEach(b=>b.style.background=c);if(on)setProg('ocr',0,'준비 중...')} function showOcrResult(d){ @@ -1244,6 +1252,122 @@ document.getElementById('ocr-dl-txt').addEventListener('click',()=>dlFile(ocrOut document.getElementById('ocr-dl-xlsx').addEventListener('click',()=>dlFile(ocrOutputXlsx)); document.getElementById('ocr-new').addEventListener('click',()=>{ocrQueue=[];ocrInput.value='';ocrOutputTxt=null;ocrOutputXlsx=null;renderOcrQueue();['ocr-prog','ocr-err','ocr-meta','ocr-tabs','ocr-actions'].forEach(id=>document.getElementById(id).style.display='none');document.getElementById('ocr-empty').style.display='flex';document.getElementById('ocr-result').style.display='none';document.getElementById('ocr-result').value='';document.getElementById('ocr-linelist').innerHTML='';document.getElementById('ocr-tablelist').innerHTML='';document.getElementById('ocr-btn').disabled=true;resetTabs('ocr-tabs')}); +// ══ PDF 변환 ══ +const pdfDrop=document.getElementById('pdf-drop'),pdfInput=document.getElementById('pdf-input'); +let pdfQueue=[], pdfFmt='html'; + +function addPdfFiles(fl){ + const files=Array.from(fl).filter(f=>f.name.toLowerCase().endsWith('.pdf')); + if(!files.length)return; + files.forEach(f=>pdfQueue.push({file:f,taskId:null,outputFile:null,status:'waiting',fmt:pdfFmt})); + renderPdfQueue(); document.getElementById('pdf-btn').disabled=false; +} +pdfInput.addEventListener('change',()=>addPdfFiles(pdfInput.files)); +pdfDrop.addEventListener('dragover',e=>{e.preventDefault();pdfDrop.classList.add('dragover')}); +pdfDrop.addEventListener('dragleave',()=>pdfDrop.classList.remove('dragover')); +pdfDrop.addEventListener('drop',e=>{e.preventDefault();pdfDrop.classList.remove('dragover');addPdfFiles(e.dataTransfer.files)}); + +document.querySelectorAll('.pdf-fmt-btn').forEach(btn=>{ + btn.addEventListener('click',()=>{ + document.querySelectorAll('.pdf-fmt-btn').forEach(b=>b.classList.remove('active')); + btn.classList.add('active'); pdfFmt=btn.dataset.fmt; + }); +}); +document.getElementById('pdf-queue-clear')?.addEventListener('click',()=>{pdfQueue=[];renderPdfQueue();document.getElementById('pdf-btn').disabled=true}); + +function renderPdfQueue(){ + const qEl=document.getElementById('pdf-queue'),list=document.getElementById('pdf-queue-list'),sum=document.getElementById('pdf-queue-summary'); + if(!pdfQueue.length){if(qEl)qEl.style.display='none';return} + if(qEl)qEl.style.display='block'; list.innerHTML=''; + pdfQueue.forEach((item,i)=>{ + const div=document.createElement('div');div.className='batch-item '+item.status; + div.innerHTML=`
${esc(item.file.name)}
+ ${{waiting:'대기',running:'변환중',done:'완료',failed:'실패'}[item.status]||item.status} + ${item.status==='done'&&item.outputFile?``:''}`; + list.appendChild(div); + }); + const done=pdfQueue.filter(i=>i.status==='done').length,failed=pdfQueue.filter(i=>i.status==='failed').length,running=pdfQueue.filter(i=>i.status==='running').length; + if(sum)sum.innerHTML=`전체 ${pdfQueue.length} · 완료 ${done} · 실패 ${failed}${running?` · 진행중 ${running}`:''}`; +} + +document.getElementById('pdf-btn').addEventListener('click',async()=>{ + const pending=pdfQueue.filter(i=>i.status==='waiting'); + if(!pending.length){showErr('pdf-err','변환할 PDF가 없습니다');return} + document.getElementById('pdf-err').style.display='none'; + document.getElementById('pdf-btn').disabled=true; + const progEl=document.getElementById('pdf-prog');progEl.style.display='block'; + setPdfProg(0,`${pending.length}개 파일 업로드 중...`); + const fd=new FormData(); + const isPdfSingle=pending.length===1; + if(isPdfSingle) fd.append('file',pending[0].file); + else pending.forEach(item=>fd.append('files',item.file)); + fd.append('target_fmt',pdfFmt); + try{ + const url=isPdfSingle?'/api/pdf/convert':'/api/pdf/convert/batch'; + const r=await api('POST',url,fd); const d=await r.json(); + if(!r.ok){const det=d.detail;throw new Error(Array.isArray(det)?det.map(e=>e.msg||JSON.stringify(e)).join(', '):det||'업로드 실패');} + const items=isPdfSingle?[d]:(d.items||[]); + let pi=0; + pdfQueue.forEach((qItem,qi)=>{ + if(qItem.status!=='waiting')return; + const ti=items[pi++]; if(!ti)return; + if(ti.error){qItem.status='failed';renderPdfQueue();return} + qItem.status='running'; qItem.fmt=pdfFmt; qItem.taskId=ti.task_id; renderPdfQueue(); + addActiveTask(ti.task_id,{type:'PDF변환',filename:qItem.file.name,startedAt:Date.now()}); + renderActiveTasksBanner(); + const t=setInterval(async()=>{ + try{ + const r2=await api('GET','/api/status/'+ti.task_id); if(r2.status===401){clearInterval(t);showLogin();return} + const d2=await r2.json(); + if(d2.state==='success'){ + clearInterval(t); removeActiveTask(ti.task_id); renderActiveTasksBanner(); + qItem.outputFile=d2.output_file||null; qItem.status='done'; renderPdfQueue(); + showPdfItemResult(d2,qItem.file.name); + checkPdfDone(); + } else if(['failure','cancelled'].includes(d2.state)){ + clearInterval(t); removeActiveTask(ti.task_id); renderActiveTasksBanner(); + qItem.status='failed'; renderPdfQueue(); checkPdfDone(); + } else { + const done=pdfQueue.filter(i=>i.status==='done').length; + setPdfProg(10+Math.round((done/pdfQueue.length)*85),d2.message||'변환 중...'); + } + }catch{} + },2000); + }); + }catch(e){showErr('pdf-err',e instanceof Error?e.message:String(e));document.getElementById('pdf-btn').disabled=false;document.getElementById('pdf-prog').style.display='none'} +}); + +function checkPdfDone(){ + if(pdfQueue.every(i=>['done','failed','waiting','cancelled'].includes(i.status))){ + const done=pdfQueue.filter(i=>i.status==='done').length; + setPdfProg(100,`완료 ${done}/${pdfQueue.length}개`); + setTimeout(()=>document.getElementById('pdf-prog').style.display='none',2500); + document.getElementById('pdf-btn').disabled=false; + } +} + +function setPdfProg(pct,msg){ + const fill=document.getElementById('pdf-pfill'),ppct=document.getElementById('pdf-ppct'),pmsg=document.getElementById('pdf-pmsg'); + if(fill)fill.style.width=pct+'%';if(ppct)ppct.textContent=pct+'%';if(pmsg)pmsg.textContent=msg||''; +} + +function showPdfItemResult(d, filename){ + const container=document.getElementById('pdf-results'); + const fmtIcon={html:'🌐',docx:'📝',xlsx:'📊',pptx:'📑'}[d.target_fmt]||'📥'; + const sizeKb=d.file_size?Math.round(d.file_size/1024)+'KB':'—'; + const card=document.createElement('div');card.className='pdf-result-card';card.style.display='block'; + card.innerHTML=` +
✓ 변환 완료 — ${esc(filename)}
+
+
포맷
${(d.target_fmt||'').toUpperCase()}
+
파일 크기
${sizeKb}
+
+ `; + container.appendChild(card); +} + // ══ HISTORY ══ document.querySelectorAll('.hist-filter-btn').forEach(btn=>{btn.addEventListener('click',()=>{document.querySelectorAll('.hist-filter-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');histType=btn.dataset.type;histPage=1;loadHistory()})}); document.getElementById('btn-hist-refresh').addEventListener('click',()=>loadHistory()); @@ -1261,8 +1385,8 @@ function renderHistoryList(items){ items.forEach(h=>{ const card=document.createElement('div');card.className='hist-card'; const inp=h.input||{},set=h.settings||{},out=h.output||{}; - const isStt=h.type==='stt',isSub=h.type==='subtitle',isOcr=h.type==='ocr'; - const typeBadge={stt:'🎙 STT',ocr:'🔍 OCR',subtitle:'🎬 자막'}[h.type]||h.type; + const isStt=h.type==='stt',isSub=h.type==='subtitle',isOcr=h.type==='ocr',isPdf=h.type==='pdf'; + const typeBadge={stt:'🎙 STT',ocr:'🔍 OCR',subtitle:'🎬 자막',pdf:'📄 PDF'}[h.type]||h.type; const statusLabel={success:'완료',processing:'처리중',failed:'실패',cancelled:'취소'}[h.status]||h.status; const ENG={local:'faster-whisper',groq:'Groq',openai:'OpenAI'}; let settingsHtml=''; @@ -1278,6 +1402,8 @@ function renderHistoryList(items){
포맷${(set.subtitle_fmt||'srt').toUpperCase()}
${set.refine_model?`
교정${esc(set.refine_model)}
`:''} ${set.translate_to?`
번역${esc(set.translate_to)} / ${esc(set.trans_model||'기본')}
`:''}`; + } else if(isPdf){ + settingsHtml=`
변환 포맷${(set.target_fmt||'html').toUpperCase()}
`; } else { settingsHtml=`
엔진${esc(set.backend||'—')}
모드${esc(set.mode||'—')}
@@ -1292,6 +1418,7 @@ function renderHistoryList(items){ } else if(h.status==='success'){ if(isStt) resultHtml=`
언어${esc(out.language||'—')}
재생시간${fmtDur(out.duration_s)}
세그먼트${out.segments||0}개
`; else if(isSub) resultHtml=`
감지 언어${esc(out.detected_language||'—')}
재생시간${fmtDur(out.duration_s)}
자막 수${out.segment_count||0}개
${out.translated?`
번역${esc(out.translate_to||'—')}
`:''}`; + else if(isPdf) resultHtml=`
출력${(out.target_fmt||'').toUpperCase()}
크기${out.file_size?Math.round(out.file_size/1024)+'KB':'—'}
`; else resultHtml=`
줄 수${out.line_count||0}줄
${out.table_count||0}개
`; } const previewHtml=h.status==='success'&&out.text_preview?`
📄 미리보기
${esc(out.text_preview)}
`:''; @@ -1309,6 +1436,10 @@ function renderHistoryList(items){ if(out.txt_file) btns.push(``); if(out.xlsx_file) btns.push(``); } + if(isPdf&&out.output_file){ + const fmtIcon={html:'🌐',docx:'📝',xlsx:'📊',pptx:'📑'}[out.target_fmt]||'📥'; + btns.push(``); + } if(btns.length) dlHtml=`
${btns.join('')}
`; } card.innerHTML=` @@ -1348,20 +1479,48 @@ function renderPagination(){ // ══ 자막 ══ const subDrop=document.getElementById('sub-drop'),subInput=document.getElementById('sub-input'); -let subFile=null, subTaskId=null, subFmt='srt', subTransVia='ollama', subRefineVia='ollama', subSttEng='local'; +let subFiles=[], subTaskId=null, subBatchPending=0, subFmt='srt', subTransVia='ollama', subRefineVia='ollama', subSttEng='local'; -subInput.addEventListener('change',()=>setSubFile(subInput.files[0])); +subInput.addEventListener('change',()=>{addSubFiles(subInput.files);subInput.value='';}); subDrop.addEventListener('dragover',e=>{e.preventDefault();subDrop.classList.add('dragover')}); subDrop.addEventListener('dragleave',()=>subDrop.classList.remove('dragover')); -subDrop.addEventListener('drop',e=>{e.preventDefault();subDrop.classList.remove('dragover');setSubFile(e.dataTransfer.files[0])}); +subDrop.addEventListener('drop',e=>{e.preventDefault();subDrop.classList.remove('dragover');addSubFiles(e.dataTransfer.files)}); -function setSubFile(f){ - if(!f)return; subFile=f; - document.getElementById('sub-info').style.display='block'; - document.getElementById('sub-fname').textContent=f.name; - document.getElementById('sub-fsize').textContent=fmtBytes(f.size); - document.getElementById('sub-btn').disabled=false; - document.getElementById('sub-err').style.display='none'; +function addSubFiles(fileList){ + if(!fileList||!fileList.length)return; + const available=10-subFiles.length; + if(available<=0){alert('최대 10개까지 선택할 수 있습니다.');return;} + const toAdd=Array.from(fileList).slice(0,available); + subFiles=[...subFiles,...toAdd]; + renderSubFileList(); +} + +function removeSubFile(idx){ + subFiles.splice(idx,1); + renderSubFileList(); +} + +function renderSubFileList(){ + const info=document.getElementById('sub-info'); + const list=document.getElementById('sub-file-list'); + const count=document.getElementById('sub-file-count'); + const btn=document.getElementById('sub-btn'); + if(!subFiles.length){ + info.style.display='none'; + btn.disabled=true; + btn.textContent='자막 생성 시작'; + return; + } + info.style.display='block'; + list.innerHTML=subFiles.map((f,i)=>` +
+ ${esc(f.name)} + ${fmtBytes(f.size)} + +
`).join(''); + count.textContent=subFiles.length>1?`${subFiles.length}개 선택됨 (최대 10개)`:''; + btn.disabled=false; + btn.textContent=subFiles.length>1?`일괄 자막 생성 (${subFiles.length}개)`:'자막 생성 시작'; } // 포맷 버튼 @@ -1414,12 +1573,11 @@ function setSubStep(step,status){ if(status!=='waiting'){const ln=document.getElementById('sline-'+step);if(ln)ln.className='step-line '+(status==='done'?'done':'');} } -document.getElementById('sub-btn').addEventListener('click',async()=>{ - if(!subFile)return; +function _buildSubFormData(file){ const transLang=document.getElementById('sub-trans-lang').value; const useRefine=document.getElementById('sub-refine-enable').checked; const fd=new FormData(); - fd.append('file',subFile); + fd.append('file',file); fd.append('src_language',document.getElementById('sub-src-lang').value||''); fd.append('subtitle_fmt',subFmt); fd.append('stt_engine',subSttEng); @@ -1428,29 +1586,171 @@ document.getElementById('sub-btn').addEventListener('click',async()=>{ fd.append('translate_to',transLang); fd.append('trans_model',transLang?(document.getElementById('sub-trans-model')?.value||''):''); fd.append('trans_via',subTransVia); + return fd; +} +function _buildBatchFormData(){ + const transLang=document.getElementById('sub-trans-lang').value; + const useRefine=document.getElementById('sub-refine-enable').checked; + const fd=new FormData(); + subFiles.forEach(f=>fd.append('files',f)); + fd.append('src_language',document.getElementById('sub-src-lang').value||''); + fd.append('subtitle_fmt',subFmt); + fd.append('stt_engine',subSttEng); + fd.append('refine_model',useRefine?(document.getElementById('sub-refine-model')?.value||''):''); + fd.append('refine_via',subRefineVia); + fd.append('translate_to',transLang); + fd.append('trans_model',transLang?(document.getElementById('sub-trans-model')?.value||''):''); + fd.append('trans_via',subTransVia); + return fd; +} + +document.getElementById('sub-btn').addEventListener('click',async()=>{ + if(!subFiles.length)return; + if(subFiles.length===1){ + await _startSingleSubtitle(); + } else { + await _startBatchSubtitle(); + } +}); + +async function _startSingleSubtitle(){ + const file=subFiles[0]; + const transLang=document.getElementById('sub-trans-lang').value; + const fd=_buildSubFormData(file); document.getElementById('sub-btn').disabled=true; document.getElementById('sub-cancel-btn').style.display='block'; document.getElementById('sub-err').style.display='none'; document.getElementById('sub-prog-box').style.display='block'; document.getElementById('sub-result-card').style.display='none'; + document.getElementById('sub-batch-area').style.display='none'; document.getElementById('sub-prog-bar').style.width='0%'; [1,2,3].forEach(s=>setSubStep(s,'waiting')); setSubStep(1,'running'); - try{ const r=await api('POST','/api/subtitle',fd); const d=await r.json(); - if(!r.ok)throw new Error(d.detail||'업로드 실패'); + if(!r.ok){const det=d.detail;throw new Error(Array.isArray(det)?det.map(e=>e.msg||JSON.stringify(e)).join(', '):det||'업로드 실패');} subTaskId=d.task_id; - addActiveTask(d.task_id,{type:'자막',filename:subFile.name,startedAt:Date.now()}); + addActiveTask(d.task_id,{type:'자막',filename:file.name,startedAt:Date.now()}); renderActiveTasksBanner(); pollSubtitle(d.task_id,!!transLang); }catch(e){ - showErr('sub-err',e.message); + showErr('sub-err',e instanceof Error?e.message:String(e)); document.getElementById('sub-btn').disabled=false; document.getElementById('sub-cancel-btn').style.display='none'; document.getElementById('sub-prog-box').style.display='none'; } -}); +} + +async function _startBatchSubtitle(){ + const transLang=document.getElementById('sub-trans-lang').value; + const total=subFiles.length; + const fd=_buildBatchFormData(); + document.getElementById('sub-btn').disabled=true; + document.getElementById('sub-cancel-btn').style.display='none'; + document.getElementById('sub-err').style.display='none'; + document.getElementById('sub-prog-box').style.display='none'; + document.getElementById('sub-result-card').style.display='none'; + const batchArea=document.getElementById('sub-batch-area'); + batchArea.style.display='block'; + document.getElementById('sub-batch-summary').innerHTML= + `
🎬 ${total}개 파일 업로드 중...
`; + document.getElementById('sub-batch-cards').innerHTML=subFiles.map((_,i)=> + `
+
+ ${esc(subFiles[i].name)} + 대기 +
+
+ + +
`).join(''); + document.getElementById('sub-batch-new').style.display='none'; + try{ + const r=await api('POST','/api/subtitle/batch',fd); const d=await r.json(); + if(!r.ok){const det=d.detail;throw new Error(Array.isArray(det)?det.map(e=>e.msg||JSON.stringify(e)).join(', '):det||'업로드 실패');} + document.getElementById('sub-batch-summary').innerHTML= + `
🎬 ${total}개 파일 자막 생성 중...
`; + subBatchPending=0; + d.items.forEach((item,i)=>{ + if(item.error){_setBatchBadge(i,'failed');_setBatchErr(i,item.error);_onBatchItemDone();} + else{ + subBatchPending++; + addActiveTask(item.task_id,{type:'자막',filename:item.filename,startedAt:Date.now()}); + renderActiveTasksBanner(); + _setBatchBadge(i,'running'); + _pollBatchItem(item.task_id,i,!!transLang,total); + } + }); + }catch(e){ + showErr('sub-err',e instanceof Error?e.message:String(e)); + document.getElementById('sub-btn').disabled=false; + batchArea.style.display='none'; + } +} + +function _setBatchBadge(idx,status){ + const el=document.getElementById('sub-bs-'+idx); + if(!el)return; + el.className='sub-batch-badge '+status; + el.textContent={waiting:'대기',running:'처리중',done:'완료',failed:'실패'}[status]||status; +} + +function _setBatchErr(idx,msg){ + const el=document.getElementById('sub-berr-'+idx); + if(el){el.style.display='block';el.textContent=msg;} +} + +function _onBatchItemDone(){ + const badges=document.querySelectorAll('.sub-batch-badge'); + const allDone=[...badges].every(b=>b.classList.contains('done')||b.classList.contains('failed')); + if(allDone){ + const doneCount=[...badges].filter(b=>b.classList.contains('done')).length; + const failCount=[...badges].filter(b=>b.classList.contains('failed')).length; + document.getElementById('sub-batch-summary').innerHTML= + `
✓ 배치 완료 — ${doneCount}개 성공${failCount?` / ${failCount}개 실패`:''}
`; + document.getElementById('sub-batch-new').style.display='block'; + document.getElementById('sub-btn').disabled=false; + } +} + +function _pollBatchItem(taskId,idx,hasTranslation,total){ + const t=setInterval(async()=>{ + try{ + const r=await api('GET','/api/status/'+taskId); + if(r.status===401){clearInterval(t);showLogin();return;} + const d=await r.json(); + if(d.progress){const bar=document.getElementById('sub-bp-'+idx);if(bar)bar.style.width=d.progress+'%';} + if(d.state==='success'){ + clearInterval(t);removeActiveTask(taskId);renderActiveTasksBanner(); + _setBatchBadge(idx,'done'); + const bar=document.getElementById('sub-bp-'+idx);if(bar)bar.style.width='100%'; + const dlArea=document.getElementById('sub-bdl-'+idx); + if(dlArea){ + dlArea.style.display='flex'; + const _addDl=(label,lang,file,cls='')=>{ + if(!file)return; + const ext=file.split('.').pop().toUpperCase(); + const btn=document.createElement('button'); + btn.className='sub-batch-dl-btn '+cls; + btn.textContent=`${ext} ${label}`; + btn.onclick=()=>dlFile(file); + dlArea.appendChild(btn); + }; + _addDl('원어',d.detected_language,d.srt_orig); + _addDl('원어',d.detected_language,d.vtt_orig); + _addDl('번역',d.translate_to,d.srt_trans,'trans'); + _addDl('번역',d.translate_to,d.vtt_trans,'trans'); + } + _onBatchItemDone(); + }else if(['failure','cancelled'].includes(d.state)){ + clearInterval(t);removeActiveTask(taskId);renderActiveTasksBanner(); + _setBatchBadge(idx,'failed'); + _setBatchErr(idx,d.message||(d.state==='cancelled'?'취소됨':'실패')); + _onBatchItemDone(); + } + }catch{} + },2000); +} document.getElementById('sub-cancel-btn')?.addEventListener('click',async()=>{ if(!subTaskId||!confirm('자막 생성을 취소하시겠습니까?'))return; @@ -1525,19 +1825,24 @@ function showSubResult(d){ document.getElementById('sub-btn').disabled=false; } -document.getElementById('sub-new')?.addEventListener('click',()=>{ - subFile=null;subInput.value='';subTaskId=null; +function _resetSubtitle(){ + subFiles=[];subInput.value='';subTaskId=null; document.getElementById('sub-info').style.display='none'; document.getElementById('sub-prog-box').style.display='none'; document.getElementById('sub-result-card').style.display='none'; + document.getElementById('sub-batch-area').style.display='none'; document.getElementById('sub-err').style.display='none'; document.getElementById('sub-cancel-btn').style.display='none'; document.getElementById('sub-btn').disabled=true; + document.getElementById('sub-btn').textContent='자막 생성 시작'; document.getElementById('sub-prog-bar').style.width='0%'; document.getElementById('sub-refine-enable').checked=false; document.getElementById('sub-refine-opts').style.display='none'; [1,2,3].forEach(s=>setSubStep(s,'waiting')); -}); +} + +document.getElementById('sub-new')?.addEventListener('click',_resetSubtitle); +document.getElementById('sub-batch-new')?.addEventListener('click',_resetSubtitle); // ══ ADMIN ══ @@ -1596,6 +1901,7 @@ async function loadUsers() { ${p.ocr?'허용':'차단'} ${p.ocr?`${ocrModels}`:''} + ${p.subtitle?'허용':'차단'} ${isAdmin ? '기본' : ` @@ -1778,9 +2084,6 @@ function pollResumed(taskId){ },3000); } -// ══ API ══ - -// ══ AUTH ══ async function checkAuth(){ token=localStorage.getItem('vs_token'); if(!token){showLogin();return} diff --git a/app/tasks.py b/app/tasks.py index 44eb07c..3711689 100644 --- a/app/tasks.py +++ b/app/tasks.py @@ -5,6 +5,7 @@ import os, json, subprocess, tempfile import httpx from celery import Celery from ocr_tasks import ocr_task # noqa: F401 +from pdf_tasks import pdf_convert_task # noqa: F401 REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") MODEL_SIZE = os.getenv("WHISPER_MODEL", "medium") @@ -112,6 +113,22 @@ def _llm_call(prompt, model, use_openrouter, openrouter_url, openrouter_key, tim f"해결: 설정에서 Ollama 타임아웃을 늘리거나, 더 작은 모델을 사용하세요." ) +def _extract_json_array(raw: str) -> str: + """LLM 응답에서 JSON 배열 문자열 추출""" + s = raw.strip() + if "```" in s: + parts = s.split("```") + # 홀수 인덱스 = 코드블록 내부 + for part in parts[1::2]: + cleaned = part.lstrip("json").strip() + if cleaned.startswith("["): + return cleaned + # 코드블록 없을 때: [ ... ] 범위 직접 탐색 + start, end = s.find("["), s.rfind("]") + if start != -1 and end > start: + return s[start:end+1] + return s + def _translate_batch(texts, target_lang, use_or, model, or_url, or_key, timeout): if not texts or not model: return texts prompt = ( @@ -120,16 +137,17 @@ def _translate_batch(texts, target_lang, use_or, model, or_url, or_key, timeout) f"입력과 동일한 개수와 순서를 유지해.\n\n" f"{json.dumps(texts, ensure_ascii=False)}" ) - try: - raw = _llm_call(prompt, model, use_or, or_url, or_key, timeout) - if "```" in raw: raw=raw.split("```")[1].lstrip("json\n").rstrip() - result = json.loads(raw) - if isinstance(result,list) and len(result)==len(texts): - return [str(r) for r in result] - return texts - except Exception as e: - print(f"[번역 실패] {e}") - return texts + raw = _llm_call(prompt, model, use_or, or_url, or_key, timeout) + json_str = _extract_json_array(raw) + result = json.loads(json_str) + if not isinstance(result, list): + raise Exception(f"번역 결과가 배열 형식이 아닙니다 (모델: {model})") + if len(result) != len(texts): + raise Exception( + f"번역 결과 개수 불일치: 입력 {len(texts)}개, 출력 {len(result)}개 " + f"(모델: {model}). 더 작은 청크 크기나 다른 모델을 시도하세요." + ) + return [str(r) for r in result] def _refine_batch(texts, model, use_or, or_url, or_key, timeout): if not texts or not model: return texts @@ -175,7 +193,7 @@ def _api_transcribe(audio_path, api_key, base_url, language, model="whisper-larg """Groq / OpenAI Whisper API 호출""" with open(audio_path,"rb") as f: data = f.read() - params = {"model":model} + params = {"model":model, "response_format":"verbose_json"} if language: params["language"] = language try: resp = httpx.post( @@ -416,7 +434,10 @@ def subtitle_pipeline_task( _prog(min(pct,95),3,f"{min(start+CHUNK,total)}/{total} 번역", f"Step 3/3 — {_lang_name(translate_to)}로 번역 중... ({min(start+CHUNK,total)}/{total})") batch=[s["text"] for s in chunk] - trans_texts.extend(_translate_batch(batch,translate_to,use_or,trans_model,openrouter_url,openrouter_key,timeout)) + try: + trans_texts.extend(_translate_batch(batch,translate_to,use_or,trans_model,openrouter_url,openrouter_key,timeout)) + except Exception as e: + raise Exception(f"LLM 번역 실패 ({_lang_name(translate_to)}, 청크 {ci+1}): {e}") translated_segments=[{**seg,"text":trans_texts[i] if i