PDF변환 추가

This commit is contained in:
root
2026-05-07 17:45:54 +09:00
parent c3cb7a6e8f
commit 148d8b3483
9 changed files with 960 additions and 189 deletions

0
1eee6fcc4d86 Normal file
View File

0
Running Normal file
View File

View File

@@ -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

View File

@@ -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"

357
app/pdf_tasks.py Normal file
View File

@@ -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"<html><body><pre>{text_src}</pre></body></html>")
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"<html><body><pre>{raw}</pre></body></html>"
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

View File

@@ -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

View File

@@ -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
<div class="nav-tabs">
<button class="nav-tab active" data-page="stt">🎙 STT</button>
<button class="nav-tab" data-page="ocr">🔍 OCR</button>
<button class="nav-tab" data-page="pdf">📄 PDF변환</button>
<button class="nav-tab history-tab" data-page="history">📋 이력</button>
<button class="nav-tab" data-page="subtitle">🎬 자막</button>
<button class="nav-tab settings-tab" data-page="settings">⚙️ 설정</button>
@@ -569,6 +617,7 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<button class="hist-filter-btn" data-type="stt">STT</button>
<button class="hist-filter-btn" data-type="ocr">OCR</button>
<button class="hist-filter-btn" data-type="subtitle">🎬 자막</button>
<button class="hist-filter-btn" data-type="pdf">📄 PDF</button>
</div>
<button class="btn-hist-clear" id="btn-hist-refresh">🔄</button>
<button class="btn-hist-clear" id="btn-hist-clear" style="display:none">🗑 전체삭제</button>
@@ -710,12 +759,13 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<div class="sub-card" id="sub-config-card">
<h3>📁 영상 / 오디오 파일</h3>
<div class="dropzone" id="sub-drop" style="padding:22px 16px">
<input type="file" id="sub-input" accept=".mp4,.mkv,.avi,.mov,.webm,.ts,.mts,.wmv,.flv,.h264,.h265,.mp3,.wav,.m4a,.ogg,.flac">
<input type="file" id="sub-input" accept=".mp4,.mkv,.avi,.mov,.webm,.ts,.mts,.wmv,.flv,.h264,.h265,.mp3,.wav,.m4a,.ogg,.flac" multiple>
<span class="drop-icon" style="font-size:1.6rem">🎬</span>
<div class="drop-label"><strong>탭하거나 드래그하여 선택</strong><br>mp4 · mkv · h.264/h.265 · mp3 · wav 등</div>
<div class="drop-label"><strong>탭하거나 드래그하여 선택</strong><br>mp4 · mkv · h.264/h.265 · mp3 · wav 등 · <span style="color:var(--muted)">최대 10개</span></div>
</div>
<div class="file-info" id="sub-info" style="display:none;margin-top:10px">
<div class="fname" id="sub-fname"></div><div class="fsize" id="sub-fsize"></div>
<div id="sub-info" style="display:none;margin-top:10px">
<div class="sub-file-list" id="sub-file-list"></div>
<div class="sub-file-count" id="sub-file-count"></div>
</div>
</div>
@@ -825,6 +875,7 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<div class="err-box" id="sub-err"></div>
<!-- 단일 파일 결과 -->
<div class="sub-result-card" id="sub-result-card">
<div style="font-family:var(--mono);font-size:.72rem;letter-spacing:.1em;color:var(--accent);text-transform:uppercase;margin-bottom:14px">✓ 자막 생성 완료</div>
<div class="sub-info-grid">
@@ -836,6 +887,64 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<div class="sub-dl-grid" id="sub-dl-grid"></div>
<button class="btn-act" id="sub-new" style="margin-top:12px;width:100%">새 파일</button>
</div>
<!-- 배치 결과 -->
<div class="sub-batch-area" id="sub-batch-area" style="display:none">
<div id="sub-batch-summary"></div>
<div id="sub-batch-cards"></div>
<button class="btn-act" id="sub-batch-new" style="width:100%;display:none">새 파일</button>
</div>
</div>
</div>
<!-- ══ PDF 변환 ══ -->
<div class="page" id="page-pdf">
<div class="pdf-wrap">
<!-- 파일 업로드 -->
<div class="pdf-card">
<h3>📁 PDF 파일 업로드</h3>
<div class="dropzone" id="pdf-drop">
<input type="file" id="pdf-input" accept=".pdf" multiple>
<span class="drop-icon">📄</span>
<div class="drop-label"><strong>탭하거나 드래그하여 선택</strong><br>PDF 파일 · 최대 10개</div>
<div class="drop-formats">opendataloader-pdf (Java) 기반 · 높은 인식률</div>
</div>
<div class="pdf-queue" id="pdf-queue">
<div class="pdf-queue-list" id="pdf-queue-list"></div>
<div style="display:flex;align-items:center;justify-content:space-between">
<span class="pdf-queue-summary" id="pdf-queue-summary"></span>
<button class="batch-clear-btn" id="pdf-queue-clear" style="font-size:.6rem;padding:4px 8px">초기화</button>
</div>
</div>
</div>
<!-- 출력 포맷 선택 -->
<div class="pdf-card">
<h3>🎯 출력 포맷</h3>
<div class="pdf-fmt-grid">
<button class="pdf-fmt-btn active" data-fmt="html">
<span class="pf-icon">🌐</span><span class="pf-name">HTML</span><span class="pf-desc">웹 페이지 형식</span>
</button>
<button class="pdf-fmt-btn" data-fmt="docx">
<span class="pf-icon">📝</span><span class="pf-name">Word</span><span class="pf-desc">.docx 문서</span>
</button>
<button class="pdf-fmt-btn" data-fmt="xlsx">
<span class="pf-icon">📊</span><span class="pf-name">Excel</span><span class="pf-desc">.xlsx 표 데이터</span>
</button>
<button class="pdf-fmt-btn" data-fmt="pptx">
<span class="pf-icon">📑</span><span class="pf-name">PowerPoint</span><span class="pf-desc">.pptx 슬라이드</span>
</button>
</div>
</div>
<!-- 변환 버튼 -->
<button class="btn-start blue" id="pdf-btn" disabled style="width:100%;margin-bottom:10px">📄 PDF 변환 시작</button>
<div class="prog-bar-wrap" id="pdf-prog" style="display:none">
<div class="prog-fill" id="pdf-pfill"></div>
<span class="prog-pct" id="pdf-ppct">0%</span>
<span class="prog-msg" id="pdf-pmsg">준비 중...</span>
</div>
<div class="err-box" id="pdf-err"></div>
<!-- 결과 (배치) -->
<div id="pdf-results"></div>
</div>
</div>
@@ -845,7 +954,7 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<h2 style="font-family:var(--mono);font-size:.88rem;font-weight:600;letter-spacing:.06em;margin-bottom:18px">👤 사용자 관리</h2>
<div class="admin-section">
<div class="admin-section-head"><h3>사용자 목록</h3><button class="btn-sm" id="btn-reload-users">새로고침</button></div>
<table class="user-table"><thead><tr><th>사용자명</th><th>역할</th><th>STT</th><th>OCR</th><th>관리</th></tr></thead><tbody id="user-tbody"></tbody></table>
<table class="user-table"><thead><tr><th>사용자명</th><th>역할</th><th>STT</th><th>OCR</th><th>자막</th><th>관리</th></tr></thead><tbody id="user-tbody"></tbody></table>
</div>
<div class="admin-section">
<div class="admin-section-head"><h3>신규 사용자 추가</h3></div>
@@ -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<d.cpu_logical)sl.max=d.cpu_logical;
// 타임아웃 현재값 반영
if(d.stt_timeout!==undefined&&!document.getElementById('stt-timeout').value) document.getElementById('stt-timeout').value=d.stt_timeout;
if(d.ollama_timeout!==undefined&&!document.getElementById('ollama-timeout').value) document.getElementById('ollama-timeout').value=d.ollama_timeout;
}catch{}
}
function updateSC(id,pct,val,sub,color){
const b=document.getElementById(`sys-${id}-bar`);if(!b)return;
b.style.width=Math.min(pct||0,100)+'%';b.style.background=color;
document.getElementById(`sys-${id}-val`).textContent=val;
document.getElementById(`sys-${id}-sub`).textContent=sub;
}
// ══ CPU 슬라이더 ══
const cpuSlider=document.getElementById('cpu-slider'),cpuDisplay=document.getElementById('cpu-val-display');
cpuSlider.addEventListener('input',()=>{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=`<option value="">${ph}</option>`;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=`<div><div class="bi-name">${esc(item.file.name)}</div></div>
<span class="bi-status ${item.status}">${{waiting:'대기',running:'변환중',done:'완료',failed:'실패'}[item.status]||item.status}</span>
<span>${item.status==='done'&&item.outputFile?`<button class="bi-dl" onclick="dlFile('${esc(item.outputFile)}')">📥 ${(item.fmt||'').toUpperCase()}</button>`:''}</span>`;
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=`전체 <span>${pdfQueue.length}</span> · 완료 <span>${done}</span> · 실패 <span>${failed}</span>${running?` · 진행중 <span>${running}</span>`:''}`;
}
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=`
<div style="font-family:var(--mono);font-size:.68rem;letter-spacing:.08em;color:var(--blue);text-transform:uppercase;margin-bottom:10px">✓ 변환 완료 — ${esc(filename)}</div>
<div class="pdf-result-grid">
<div class="pdf-result-item"><div class="pdf-result-label">포맷</div><div class="pdf-result-val">${(d.target_fmt||'').toUpperCase()}</div></div>
<div class="pdf-result-item"><div class="pdf-result-label">파일 크기</div><div class="pdf-result-val">${sizeKb}</div></div>
</div>
<button class="btn-start blue" style="width:100%;padding:10px" onclick="dlFile('${esc(d.output_file||'')}')">
${fmtIcon} ${(d.target_fmt||'').toUpperCase()} 다운로드
</button>`;
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){
<div class="hist-kv-row"><span class="hist-k">포맷</span><span class="hist-v">${(set.subtitle_fmt||'srt').toUpperCase()}</span></div>
${set.refine_model?`<div class="hist-kv-row"><span class="hist-k">교정</span><span class="hist-v">${esc(set.refine_model)}</span></div>`:''}
${set.translate_to?`<div class="hist-kv-row"><span class="hist-k">번역</span><span class="hist-v">${esc(set.translate_to)} / ${esc(set.trans_model||'기본')}</span></div>`:''}`;
} else if(isPdf){
settingsHtml=`<div class="hist-kv-row"><span class="hist-k">변환 포맷</span><span class="hist-v">${(set.target_fmt||'html').toUpperCase()}</span></div>`;
} else {
settingsHtml=`<div class="hist-kv-row"><span class="hist-k">엔진</span><span class="hist-v">${esc(set.backend||'—')}</span></div>
<div class="hist-kv-row"><span class="hist-k">모드</span><span class="hist-v">${esc(set.mode||'—')}</span></div>
@@ -1292,6 +1418,7 @@ function renderHistoryList(items){
} else if(h.status==='success'){
if(isStt) resultHtml=`<div class="hist-kv-row"><span class="hist-k">언어</span><span class="hist-v">${esc(out.language||'—')}</span></div><div class="hist-kv-row"><span class="hist-k">재생시간</span><span class="hist-v">${fmtDur(out.duration_s)}</span></div><div class="hist-kv-row"><span class="hist-k">세그먼트</span><span class="hist-v">${out.segments||0}개</span></div>`;
else if(isSub) resultHtml=`<div class="hist-kv-row"><span class="hist-k">감지 언어</span><span class="hist-v">${esc(out.detected_language||'—')}</span></div><div class="hist-kv-row"><span class="hist-k">재생시간</span><span class="hist-v">${fmtDur(out.duration_s)}</span></div><div class="hist-kv-row"><span class="hist-k">자막 수</span><span class="hist-v">${out.segment_count||0}개</span></div>${out.translated?`<div class="hist-kv-row"><span class="hist-k">번역</span><span class="hist-v">${esc(out.translate_to||'—')}</span></div>`:''}`;
else if(isPdf) resultHtml=`<div class="hist-kv-row"><span class="hist-k">출력</span><span class="hist-v">${(out.target_fmt||'').toUpperCase()}</span></div><div class="hist-kv-row"><span class="hist-k">크기</span><span class="hist-v">${out.file_size?Math.round(out.file_size/1024)+'KB':'—'}</span></div>`;
else resultHtml=`<div class="hist-kv-row"><span class="hist-k">줄 수</span><span class="hist-v">${out.line_count||0}줄</span></div><div class="hist-kv-row"><span class="hist-k">표</span><span class="hist-v">${out.table_count||0}개</span></div>`;
}
const previewHtml=h.status==='success'&&out.text_preview?`<div><div class="hist-section-title">📄 미리보기</div><div class="hist-preview-text">${esc(out.text_preview)}</div></div>`:'';
@@ -1309,6 +1436,10 @@ function renderHistoryList(items){
if(out.txt_file) btns.push(`<button class="hist-btn" onclick="dlFile('${esc(out.txt_file)}')">📥 TXT</button>`);
if(out.xlsx_file) btns.push(`<button class="hist-btn blue" onclick="dlFile('${esc(out.xlsx_file)}')">📊 Excel</button>`);
}
if(isPdf&&out.output_file){
const fmtIcon={html:'🌐',docx:'📝',xlsx:'📊',pptx:'📑'}[out.target_fmt]||'📥';
btns.push(`<button class="hist-btn blue" onclick="dlFile('${esc(out.output_file)}')">${fmtIcon} ${(out.target_fmt||'').toUpperCase()}</button>`);
}
if(btns.length) dlHtml=`<div class="hist-actions">${btns.join('')}</div>`;
}
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)=>`
<div class="sub-file-item">
<span class="sub-file-name">${esc(f.name)}</span>
<span class="sub-file-size">${fmtBytes(f.size)}</span>
<button class="sub-file-rm" onclick="removeSubFile(${i})" title="제거">✕</button>
</div>`).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=
`<div style="font-family:var(--mono);font-size:.72rem;color:var(--muted);margin-bottom:10px">🎬 ${total}개 파일 업로드 중...</div>`;
document.getElementById('sub-batch-cards').innerHTML=subFiles.map((_,i)=>
`<div class="sub-batch-card" id="sub-bc-${i}">
<div class="sub-batch-head">
<span class="sub-batch-fname">${esc(subFiles[i].name)}</span>
<span class="sub-batch-badge waiting" id="sub-bs-${i}">대기</span>
</div>
<div class="sub-batch-prog-wrap"><div class="sub-batch-prog-bar" id="sub-bp-${i}"></div></div>
<div class="sub-batch-dl" id="sub-bdl-${i}" style="display:none"></div>
<div class="sub-batch-err" id="sub-berr-${i}" style="display:none"></div>
</div>`).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=
`<div style="font-family:var(--mono);font-size:.72rem;color:var(--muted);margin-bottom:10px">🎬 ${total}개 파일 자막 생성 중...</div>`;
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=
`<div class="sub-batch-summary">✓ 배치 완료 — ${doneCount}개 성공${failCount?` / ${failCount}개 실패`:''}</div>`;
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() {
<span class="perm-badge ${p.ocr?'on':'off'}">${p.ocr?'허용':'차단'}</span>
${p.ocr?`<span style="font-family:var(--mono);font-size:.58rem;color:var(--muted)">${ocrModels}</span>`:''}
</td>
<td><span class="perm-badge ${p.subtitle?'on':'off'}">${p.subtitle?'허용':'차단'}</span></td>
<td>${isAdmin
? '<span style="font-family:var(--mono);font-size:.6rem;color:var(--muted)">기본</span>'
: `<button class="btn-sm" onclick="openEditModal('${esc(name)}',${JSON.stringify(p)})">편집</button>
@@ -1778,9 +2084,6 @@ function pollResumed(taskId){
},3000);
}
// ══ API ══
// ══ AUTH ══
async function checkAuth(){
token=localStorage.getItem('vs_token');
if(!token){showLogin();return}

View File

@@ -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):
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]
return texts
except Exception as e:
print(f"[번역 실패] {e}")
return texts
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]
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<len(trans_texts) else seg["text"]}
for i,seg in enumerate(segments)]
trans_suffix=translate_to

View File

@@ -33,6 +33,14 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
# 캐시 비활성화 — HTML/JS 항상 최신 버전 제공
proxy_no_cache 1;
proxy_cache_bypass 1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
# 대용량 업로드를 위한 타임아웃
proxy_connect_timeout 60s;