feat: 자막 생성 탭 (ffmpeg+Whisper+LLM 3단계 파이프라인)

This commit is contained in:
root
2026-05-02 07:28:34 +09:00
parent 4fc3da1a2d
commit b3805c2b0b
3 changed files with 884 additions and 608 deletions

View File

@@ -60,6 +60,7 @@ header h1 span{color:var(--accent)}
.nav-tab.admin-tab.active{color:var(--orange);border-bottom-color:var(--orange)}
.nav-tab.settings-tab.active{color:var(--blue);border-bottom-color:var(--blue)}
.nav-tab.history-tab.active{color:var(--purple);border-bottom-color:var(--purple)}
.nav-tab.subtitle-tab.active{color:var(--blue);border-bottom-color:var(--blue)}
/* ── PAGE ── */
.page{display:none;flex:1;flex-direction:column}
@@ -251,18 +252,56 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
.ollama-status{font-family:var(--mono);font-size:.63rem;padding:4px 9px;border-radius:2px}
.ollama-status.ok{background:rgba(0,229,160,.1);color:var(--accent);border:1px solid rgba(0,229,160,.2)}
.ollama-status.fail{background:rgba(255,107,53,.1);color:var(--warn);border:1px solid rgba(255,107,53,.2)}
/* ── 자막 모드 ── */
.sub-section{margin-top:12px;padding:12px;background:var(--surf2);border:1px solid #1c2840;border-radius:4px}
.sub-section-title{font-family:var(--mono);font-size:.6rem;letter-spacing:.1em;color:var(--blue);text-transform:uppercase;margin-bottom:10px;display:flex;align-items:center;gap:6px}
.lang-select{width:100%;background:var(--surf);border:1px solid var(--border2);color:var(--text);padding:9px 10px;border-radius:3px;font-family:var(--mono);font-size:.78rem;outline:none;cursor:pointer;appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%2352526a'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;margin-top:4px}
.lang-select:focus{border-color:var(--blue)}
.fmt-btns{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;margin-top:6px}
.fmt-btn{padding:7px;background:var(--surf);border:1px solid var(--border2);color:var(--muted);border-radius:3px;font-family:var(--mono);font-size:.68rem;cursor:pointer;transition:all .15s;text-align:center;text-transform:uppercase}
/* ── 자막 ── */
#page-subtitle{display:none;flex-direction:column}
#page-subtitle.active{display:flex}
.subtitle-wrap{max-width:860px;margin:0 auto;padding:28px 16px;width:100%}
.step-indicator{display:flex;align-items:center;gap:0;margin-bottom:28px}
.step-dot{width:32px;height:32px;border-radius:50%;border:2px solid var(--border2);background:var(--surf);display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:.72rem;font-weight:600;color:var(--muted);transition:all .3s;flex-shrink:0}
.step-dot.active{border-color:var(--blue);background:rgba(77,166,255,.1);color:var(--blue)}
.step-dot.done{border-color:var(--accent);background:rgba(0,229,160,.1);color:var(--accent)}
.step-line{flex:1;height:2px;background:var(--border);transition:background .3s}
.step-line.done{background:var(--accent)}
.step-labels{display:flex;justify-content:space-between;margin-top:6px;margin-bottom:20px}
.step-label{font-family:var(--mono);font-size:.6rem;color:var(--muted);text-align:center;flex:1;letter-spacing:.06em;text-transform:uppercase}
.step-label.active{color:var(--blue)}.step-label.done{color:var(--accent)}
.sub-card{background:var(--surf);border:1px solid var(--border2);border-radius:6px;padding:20px;margin-bottom:14px}
.sub-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)}
.lang-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.sub-select{width:100%;background:var(--surf2);border:1px solid var(--border2);color:var(--text);padding:9px 10px;border-radius:3px;font-family:var(--mono);font-size:.78rem;outline:none;cursor:pointer;appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%2352526a'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center}
.sub-select:focus{border-color:var(--blue)}
.fmt-row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:6px}
.fmt-btn{padding:9px;background:var(--surf);border:1px solid var(--border2);color:var(--muted);border-radius:3px;font-family:var(--mono);font-size:.7rem;cursor:pointer;transition:all .15s;text-align:center;text-transform:uppercase}
.fmt-btn.active{background:rgba(77,166,255,.08);border-color:#3a7cc4;color:var(--blue)}
.sub-dl-btn{flex:1;padding:8px;background:rgba(77,166,255,.07);border:1px solid #3a7cc4;color:var(--blue);border-radius:3px;font-family:var(--mono);font-size:.66rem;cursor:pointer;transition:all .15s;text-transform:uppercase}
.sub-dl-btn:hover{background:rgba(77,166,255,.15)}
/* ── 배치 큐 ── */
.batch-queue{margin-top:14px;display:flex;flex-direction:column;gap:6px;max-height:280px;overflow-y:auto}
.engine-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:6px}
.sub-prog-box{background:var(--surf2);border:1px solid var(--border2);border-radius:6px;padding:18px;margin-bottom:14px;display:none}
.sub-prog-steps{display:flex;flex-direction:column;gap:10px}
.sub-step-row{display:flex;align-items:center;gap:12px}
.sub-step-icon{width:26px;height:26px;border-radius:50%;border:2px solid var(--border2);display:flex;align-items:center;justify-content:center;font-size:.75rem;flex-shrink:0;transition:all .3s}
.sub-step-icon.waiting{border-color:var(--border2);color:var(--muted)}
.sub-step-icon.running{border-color:var(--blue);background:rgba(77,166,255,.1);color:var(--blue)}
.sub-step-icon.done{border-color:var(--accent);background:rgba(0,229,160,.1);color:var(--accent)}
.sub-step-icon.failed{border-color:var(--warn);background:rgba(255,107,53,.1);color:var(--warn)}
.sub-step-text{flex:1}
.sub-step-name{font-family:var(--mono);font-size:.72rem;color:var(--text)}
.sub-step-msg{font-family:var(--mono);font-size:.62rem;color:var(--muted);margin-top:2px}
.sub-prog-bar-wrap{height:3px;background:var(--border);border-radius:2px;overflow:hidden;margin-top:14px}
.sub-prog-bar{height:100%;background:var(--blue);border-radius:2px;transition:width .5s ease;width:0%}
.sub-result-card{background:var(--surf);border:1px solid rgba(0,229,160,.2);border-radius:6px;padding:20px;display:none}
.sub-result-title{font-family:var(--mono);font-size:.72rem;letter-spacing:.1em;color:var(--accent);text-transform:uppercase;margin-bottom:14px}
.sub-info-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:14px}
.sub-info-item{background:var(--surf2);border:1px solid var(--border);border-radius:3px;padding:10px 12px}
.sub-info-label{font-family:var(--mono);font-size:.58rem;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin-bottom:3px}
.sub-info-val{font-family:var(--mono);font-size:.8rem;color:var(--text);font-weight:600}
.sub-dl-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.sub-dl-btn{padding:11px;background:none;border:1px solid var(--border2);color:var(--text);border-radius:4px;font-family:var(--mono);font-size:.7rem;cursor:pointer;transition:all .15s;text-align:center;display:flex;flex-direction:column;align-items:center;gap:4px}
.sub-dl-btn:hover{border-color:var(--accent);color:var(--accent);background:rgba(0,229,160,.05)}
.sub-dl-btn .dl-icon{font-size:1.2rem;opacity:.6}
.sub-dl-btn .dl-label{font-weight:600}.sub-dl-btn .dl-lang{font-size:.58rem;color:var(--muted)}
.sub-dl-btn.trans{border-color:#3a7cc4;color:var(--blue)}
.sub-dl-btn.trans:hover{background:rgba(77,166,255,.07)}
/* 배치 큐 공통 */
.batch-queue{margin-top:14px;display:flex;flex-direction:column;gap:6px;max-height:260px;overflow-y:auto}
.batch-item{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:8px;padding:9px 12px;background:var(--surf);border:1px solid var(--border2);border-radius:4px;transition:border-color .2s}
.batch-item.running{border-color:var(--accent2)}.batch-item.done{border-color:rgba(0,229,160,.3)}.batch-item.failed{border-color:rgba(255,107,53,.3)}.batch-item.waiting{opacity:.6}
.bi-name{font-family:var(--mono);font-size:.72rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -274,6 +313,7 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
.batch-add-btn{margin-top:8px;padding:7px 14px;background:none;border:1px dashed var(--border2);color:var(--muted);border-radius:3px;font-family:var(--mono);font-size:.68rem;cursor:pointer;width:100%;transition:all .15s}.batch-add-btn:hover{border-color:var(--accent);color:var(--accent)}
.batch-clear-btn{padding:7px 14px;background:none;border:1px solid var(--border2);color:var(--muted);border-radius:3px;font-family:var(--mono);font-size:.68rem;cursor:pointer;transition:all .15s}.batch-clear-btn:hover{border-color:var(--warn);color:var(--warn)}
.batch-actions{display:flex;gap:8px;margin-top:10px}
@media(min-width:768px){.subtitle-wrap{padding:32px}.lang-grid{grid-template-columns:1fr 1fr}.sub-info-grid{grid-template-columns:1fr 1fr 1fr 1fr}.sub-dl-grid{grid-template-columns:repeat(4,1fr)}}
/* ── ADMIN ── */
#page-admin{display:none;flex-direction:column}
@@ -375,6 +415,7 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<button class="nav-tab active" data-page="stt">🎙 STT</button>
<button class="nav-tab" data-page="ocr">🔍 OCR</button>
<button class="nav-tab history-tab" data-page="history">📋 이력</button>
<button class="nav-tab subtitle-tab" data-page="subtitle">🎬 자막</button>
<button class="nav-tab settings-tab" data-page="settings">⚙️ 설정</button>
<button class="nav-tab admin-tab" data-page="admin" id="admin-tab" style="display:none">👤 관리자</button>
</div>
@@ -385,13 +426,12 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<section class="panel">
<div class="panel-title">파일 업로드</div>
<div class="dropzone" id="stt-drop">
<input type="file" id="stt-input" accept=".mp3,.mp4,.wav,.m4a,.ogg,.flac,.aac,.wma,.webm,.mkv,.avi,.mov,.ts,.mts,.h264,.h265" multiple>
<input type="file" id="stt-input" accept=".mp3,.mp4,.wav,.m4a,.ogg,.flac,.aac,.wma,.webm,.mkv,.avi,.mov" multiple>
<span class="drop-icon">🎵</span>
<div class="drop-label"><strong>탭하거나 드래그하여 선택</strong><br>영상(mp4/mkv/h265 등) · 음성 복수 선택 가능</div>
<div class="drop-label"><strong>탭하거나 드래그하여 선택</strong><br>음성·영상 파일 복수 선택 가능</div>
<div class="drop-formats">mp3 · wav · m4a · ogg · flac · mp4 · webm</div>
</div>
<div class="file-info" id="stt-info"><div class="fname" id="stt-fname"></div><div class="fsize" id="stt-fsize"></div></div>
<!-- 배치 큐 -->
<div id="stt-queue" style="display:none">
<div class="batch-queue" id="stt-queue-list"></div>
<div class="batch-summary" id="stt-queue-summary"></div>
@@ -409,42 +449,6 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<div class="sec-label" style="margin-top:0">후처리 모델</div>
<select class="model-select" id="stt-ollama-model"><option value="">설정 기본 모델 사용</option></select>
</div>
<!-- 자막 모드 옵션 -->
<div class="sub-section" id="sub-section">
<div class="sub-section-title">🎬 자막 모드 (영상/음성 → 자막 파일)</div>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-family:var(--mono);font-size:.75rem;color:var(--text)">
<input type="checkbox" id="subtitle-mode" style="accent-color:var(--blue);width:15px;height:15px">
자막 파일 생성 (SRT / VTT)
</label>
<div id="sub-opts" style="display:none;margin-top:10px">
<div class="sec-label">음성 언어 (원어)</div>
<select class="lang-select" id="force-language">
<option value="">자동 감지</option>
</select>
<div class="sec-label">자막 포맷</div>
<div class="fmt-btns">
<button class="fmt-btn active" data-fmt="srt">SRT</button>
<button class="fmt-btn" data-fmt="vtt">VTT</button>
<button class="fmt-btn" data-fmt="both">둘 다</button>
</div>
<div class="sec-label">번역 (선택 — 빈칸이면 원어 자막)</div>
<select class="lang-select" id="translate-to">
<option value="">번역 안 함 (원어 자막)</option>
</select>
<div id="trans-model-wrap" style="display:none;margin-top:8px">
<div class="sec-label">번역 엔진</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:6px">
<button class="fmt-btn active" data-via="ollama" id="trans-via-ollama">🦙 Ollama</button>
<button class="fmt-btn" data-via="openrouter" id="trans-via-or">🌐 OpenRouter</button>
</div>
<div class="sec-label">번역 모델</div>
<select class="lang-select" id="translate-model">
<option value="">STT 엔진과 같은 모델 사용</option>
</select>
</div>
</div>
</div>
<button class="btn-start green" id="stt-btn" disabled>변환 시작</button>
<div class="prog-box" id="stt-prog">
<div class="prog-header"><span class="prog-msg" id="stt-pmsg">처리 중...</span><span class="prog-pct" id="stt-ppct">0%</span></div>
@@ -472,10 +476,7 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<div class="tab-content" id="stt-segs"><div class="segments-list" id="stt-seglist"></div></div>
<div class="result-actions" id="stt-actions">
<button class="btn-act" id="stt-copy">복사</button>
<button class="btn-act primary" id="stt-dl">TXT</button>
<button class="sub-dl-btn" id="stt-dl-srt" style="display:none">SRT 저장</button>
<button class="sub-dl-btn" id="stt-dl-vtt" style="display:none">VTT 저장</button>
<button class="sub-dl-btn" id="stt-dl-srt-orig" style="display:none">원어 SRT</button>
<button class="btn-act primary" id="stt-dl">TXT 저장</button>
<button class="btn-act" id="stt-new">새 파일</button>
</div>
</section>
@@ -495,7 +496,6 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
</div>
<div class="file-info" id="ocr-info"><div class="fname" id="ocr-fname"></div><div class="fsize" id="ocr-fsize"></div></div>
<div id="ocr-preview-wrap"><img id="ocr-preview"></div>
<!-- 배치 큐 -->
<div id="ocr-queue" style="display:none">
<div class="batch-queue" id="ocr-queue-list"></div>
<div class="batch-summary" id="ocr-queue-summary"></div>
@@ -647,6 +647,174 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
</div>
</div>
<!-- ══ 자막 PAGE ══ -->
<div class="page" id="page-subtitle">
<div class="subtitle-wrap">
<h2 style="font-family:var(--mono);font-size:.9rem;font-weight:600;letter-spacing:.06em;margin-bottom:20px">🎬 자막 생성</h2>
<!-- 스텝 인디케이터 -->
<div class="step-indicator">
<div class="step-dot" id="sdot-1">1</div>
<div class="step-line" id="sline-1"></div>
<div class="step-dot" id="sdot-2">2</div>
<div class="step-line" id="sline-2"></div>
<div class="step-dot" id="sdot-3">3</div>
</div>
<div class="step-labels">
<span class="step-label" id="slabel-1">오디오 추출</span>
<span class="step-label" id="slabel-2">음성 인식</span>
<span class="step-label" id="slabel-3">번역 (선택)</span>
</div>
<!-- 설정 카드 -->
<div class="sub-card" id="sub-config-card">
<h3>📁 영상 / 오디오 파일</h3>
<div class="dropzone" id="sub-drop" style="padding:24px 16px">
<input type="file" id="sub-input" accept=".mp4,.mkv,.avi,.mov,.webm,.ts,.mts,.m2ts,.wmv,.flv,.h264,.h265,.mp3,.wav,.m4a,.ogg,.flac">
<span class="drop-icon" style="font-size:1.6rem">🎬</span>
<div class="drop-label"><strong>탭하거나 드래그하여 선택</strong><br>mp4 · mkv · avi · mov · h.264/h.265 등</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>
</div>
<div class="sub-card">
<h3>⚙️ 자막 설정</h3>
<div class="lang-grid">
<div>
<div class="sec-label">음성 언어 (원어)</div>
<select class="sub-select" id="sub-src-lang">
<option value="">🔍 자동 감지 (권장)</option>
<option value="ko">🇰🇷 한국어</option>
<option value="en">🇺🇸 English</option>
<option value="ja">🇯🇵 日本語</option>
<option value="zh">🇨🇳 中文(简体)</option>
<option value="zh-tw">🇹🇼 中文(繁體)</option>
<option value="fr">🇫🇷 Français</option>
<option value="de">🇩🇪 Deutsch</option>
<option value="es">🇪🇸 Español</option>
<option value="it">🇮🇹 Italiano</option>
<option value="pt">🇵🇹 Português</option>
<option value="ru">🇷🇺 Русский</option>
<option value="ar">🇸🇦 العربية</option>
<option value="vi">🇻🇳 Tiếng Việt</option>
<option value="th">🇹🇭 ไทย</option>
<option value="id">🇮🇩 Bahasa Indonesia</option>
<option value="nl">🇳🇱 Nederlands</option>
<option value="pl">🇵🇱 Polski</option>
<option value="tr">🇹🇷 Türkçe</option>
<option value="sv">🇸🇪 Svenska</option>
<option value="uk">🇺🇦 Українська</option>
<option value="hi">🇮🇳 हिन्दी</option>
</select>
</div>
<div>
<div class="sec-label">자막 포맷</div>
<div class="fmt-row">
<button class="fmt-btn active" data-fmt="srt">SRT</button>
<button class="fmt-btn" data-fmt="vtt">VTT</button>
<button class="fmt-btn" data-fmt="both">둘 다</button>
</div>
</div>
</div>
</div>
<div class="sub-card">
<h3>🌐 번역 설정 (선택사항)</h3>
<div style="font-family:var(--mono);font-size:.65rem;color:var(--muted);margin-bottom:10px">
빈칸으로 두면 원어 자막만 생성합니다
</div>
<div class="lang-grid">
<div>
<div class="sec-label">번역 대상 언어</div>
<select class="sub-select" id="sub-trans-lang">
<option value="">번역 안 함</option>
<option value="ko">🇰🇷 한국어</option>
<option value="en">🇺🇸 English</option>
<option value="ja">🇯🇵 日本語</option>
<option value="zh">🇨🇳 中文(简体)</option>
<option value="zh-tw">🇹🇼 中文(繁體)</option>
<option value="fr">🇫🇷 Français</option>
<option value="de">🇩🇪 Deutsch</option>
<option value="es">🇪🇸 Español</option>
<option value="it">🇮🇹 Italiano</option>
<option value="pt">🇵🇹 Português</option>
<option value="ru">🇷🇺 Русский</option>
<option value="ar">🇸🇦 العربية</option>
<option value="vi">🇻🇳 Tiếng Việt</option>
<option value="th">🇹🇭 ไทย</option>
<option value="id">🇮🇩 Bahasa Indonesia</option>
<option value="nl">🇳🇱 Nederlands</option>
<option value="pl">🇵🇱 Polski</option>
<option value="tr">🇹🇷 Türkçe</option>
<option value="sv">🇸🇪 Svenska</option>
<option value="hi">🇮🇳 हिन्दी</option>
</select>
</div>
<div id="sub-trans-engine-wrap" style="display:none">
<div class="sec-label">번역 엔진</div>
<div class="engine-row">
<button class="fmt-btn active" data-via="ollama" id="sub-via-ollama">🦙 Ollama</button>
<button class="fmt-btn" data-via="openrouter" id="sub-via-or">🌐 OpenRouter</button>
</div>
<div class="sec-label">번역 모델</div>
<select class="sub-select" id="sub-trans-model" style="margin-top:4px">
<option value="">설정 기본 모델 사용</option>
</select>
</div>
</div>
</div>
<button class="btn-start blue" id="sub-btn" disabled style="background:var(--blue);color:#fff;margin-bottom:14px">자막 생성 시작</button>
<!-- 진행 상태 -->
<div class="sub-prog-box" id="sub-prog-box">
<div class="sub-prog-steps">
<div class="sub-step-row">
<div class="sub-step-icon waiting" id="sub-sicon-1"></div>
<div class="sub-step-text">
<div class="sub-step-name">Step 1 — 오디오 추출</div>
<div class="sub-step-msg" id="sub-smsg-1">ffmpeg으로 오디오 트랙 추출</div>
</div>
</div>
<div class="sub-step-row">
<div class="sub-step-icon waiting" id="sub-sicon-2"></div>
<div class="sub-step-text">
<div class="sub-step-name">Step 2 — 음성 인식</div>
<div class="sub-step-msg" id="sub-smsg-2">Whisper로 자막 생성</div>
</div>
</div>
<div class="sub-step-row">
<div class="sub-step-icon waiting" id="sub-sicon-3"></div>
<div class="sub-step-text">
<div class="sub-step-name">Step 3 — 번역 (선택)</div>
<div class="sub-step-msg" id="sub-smsg-3">LLM 번역 (미선택 시 건너뜀)</div>
</div>
</div>
</div>
<div class="sub-prog-bar-wrap"><div class="sub-prog-bar" id="sub-prog-bar"></div></div>
</div>
<div class="err-box" id="sub-err"></div>
<!-- 결과 카드 -->
<div class="sub-result-card" id="sub-result-card">
<div class="sub-result-title">✓ 자막 생성 완료</div>
<div class="sub-info-grid">
<div class="sub-info-item"><div class="sub-info-label">감지 언어</div><div class="sub-info-val" id="sub-res-lang"></div></div>
<div class="sub-info-item"><div class="sub-info-label">재생 시간</div><div class="sub-info-val" id="sub-res-dur"></div></div>
<div class="sub-info-item"><div class="sub-info-label">자막 수</div><div class="sub-info-val" id="sub-res-segs"></div></div>
<div class="sub-info-item"><div class="sub-info-label">번역</div><div class="sub-info-val" id="sub-res-trans"></div></div>
</div>
<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>
</div>
<!-- ══ ADMIN ══ -->
<div class="page" id="page-admin">
<div class="admin-wrap">
@@ -742,6 +910,7 @@ function applyUserUI(){
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';
if(appSettings.openrouter_api_key_masked)loadOrModels();
}
const showLogin=()=>{document.getElementById('login-overlay').style.display='flex';stopSysMonitor()};
const hideLogin=()=>document.getElementById('login-overlay').style.display='none';
@@ -801,7 +970,6 @@ function populateModelSelects(){
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,'(없음)');
populateOrSelects();
}
// ══ 설정 ══
@@ -810,6 +978,9 @@ async function loadSettings(){
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;
if(appSettings.openrouter_url)document.getElementById('or-url').value=appSettings.openrouter_url;
if(appSettings.openrouter_api_key_masked&&document.getElementById('or-api-key'))
document.getElementById('or-api-key').placeholder='저장된 키: '+appSettings.openrouter_api_key_masked;
populateModelSelects()}catch{}
}
document.getElementById('btn-save-settings').addEventListener('click',async()=>{
@@ -837,64 +1008,19 @@ document.querySelectorAll('.nav-tab').forEach(btn=>{
if(btn.dataset.page==='admin')loadUsers();
if(btn.dataset.page==='settings'){loadSettings();fetchSysInfo()}
if(btn.dataset.page==='history'){histPage=1;loadHistory()}
if(btn.dataset.page==='subtitle')fillSubTransModels();
});
});
// ══ STT — 배치 + 자막 ══
// ══ STT — 배치 ══
const sttDrop=document.getElementById('stt-drop'),sttInput=document.getElementById('stt-input');
let sttQueue=[],sttSubFmt='srt',sttTransVia='ollama';
let languages={};
let sttQueue=[];
const AUDIO_EXTS=['mp3','mp4','wav','m4a','ogg','flac','aac','wma','webm','mkv','avi','mov','ts','mts','h264','h265'];
// 언어 목록 로드
async function loadLanguages(){
try{const r=await api('GET','/api/languages');const d=await r.json();languages=d.languages||{};
const sel1=document.getElementById('force-language');
const sel2=document.getElementById('translate-to');
Object.entries(languages).forEach(([code,name])=>{
sel1.appendChild(Object.assign(document.createElement('option'),{value:code,textContent:`${name} (${code})`}));
sel2.appendChild(Object.assign(document.createElement('option'),{value:code,textContent:`${name} (${code})`}));
});
}catch{}
}
// 번역 모델 드롭다운 채우기
function fillTranslateModels(){
const sel=document.getElementById('translate-model');
const cur=sel.value;sel.innerHTML='<option value="">STT 엔진과 같은 모델 사용</option>';
const models=sttTransVia==='openrouter'?orModels:ollamaModels;
models.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;sel.appendChild(o)});
}
// 자막 모드 토글
document.getElementById('subtitle-mode').addEventListener('change',function(){
document.getElementById('sub-opts').style.display=this.checked?'block':'none';
});
// 포맷 버튼
document.querySelectorAll('.fmt-btn[data-fmt]').forEach(btn=>{
btn.addEventListener('click',()=>{document.querySelectorAll('.fmt-btn[data-fmt]').forEach(b=>b.classList.remove('active'));btn.classList.add('active');sttSubFmt=btn.dataset.fmt});
});
// 번역 언어 선택 → 모델 옵션 표시
document.getElementById('translate-to').addEventListener('change',function(){
document.getElementById('trans-model-wrap').style.display=this.value?'block':'none';
if(this.value)fillTranslateModels();
});
// 번역 엔진 선택
document.querySelectorAll('button[data-via]').forEach(btn=>{
btn.addEventListener('click',()=>{
document.querySelectorAll('button[data-via]').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');sttTransVia=btn.dataset.via;fillTranslateModels();
});
});
// 파일 추가
function addSttFiles(fileList){
const AUDIO=['mp3','mp4','wav','m4a','ogg','flac','aac','wma','webm','mkv','avi','mov','ts','mts','h264','h265'];
const files=Array.from(fileList).filter(f=>AUDIO.includes(f.name.split('.').pop().toLowerCase()));
const files=Array.from(fileList).filter(f=>AUDIO_EXTS.includes(f.name.split('.').pop().toLowerCase()));
if(!files.length)return;
files.forEach(f=>sttQueue.push({file:f,taskId:null,outputFile:null,srtFile:null,vttFile:null,srtOrigFile:null,status:'waiting',el:null}));
files.forEach(f=>sttQueue.push({file:f,taskId:null,outputFile:null,status:'waiting'}));
renderSttQueue();document.getElementById('stt-btn').disabled=false;
}
sttInput.addEventListener('change',()=>addSttFiles(sttInput.files));
@@ -909,26 +1035,19 @@ function renderSttQueue(){
qEl.style.display='block';list.innerHTML='';
sttQueue.forEach((item,i)=>{
const div=document.createElement('div');div.className='batch-item '+item.status;
const dlBtns=item.status==='done'?[
item.outputFile?`<button class="bi-dl" onclick="dlFile('${esc(item.outputFile)}')">TXT</button>`:'',
item.srtFile?`<button class="bi-dl" onclick="dlFile('${esc(item.srtFile)}')">SRT</button>`:'',
item.vttFile?`<button class="bi-dl" onclick="dlFile('${esc(item.vttFile)}')">VTT</button>`:'',
item.srtOrigFile?`<button class="bi-dl" onclick="dlFile('${esc(item.srtOrigFile)}')">원어SRT</button>`:'',
].filter(Boolean).join(''):''
div.innerHTML=`<div><div class="bi-name">${esc(item.file.name)}</div><div class="bi-prog" id="stt-bp-${i}" style="width:0%;display:${item.status==='running'?'block':'none'}"></div></div><span class="bi-status ${item.status}">${{waiting:'대기',running:'변환중',done:'완료',failed:'실패'}[item.status]}</span><span style="display:flex;gap:3px">${dlBtns}</span>`;
item.el=div;list.appendChild(div);
div.innerHTML=`<div><div class="bi-name">${esc(item.file.name)}</div><div class="bi-prog" id="sbi-${i}" style="width:0%;display:${item.status==='running'?'block':'none'}"></div></div><span class="bi-status ${item.status}">${{waiting:'대기',running:'변환중',done:'완료',failed:'실패'}[item.status]}</span><span>${item.status==='done'&&item.outputFile?`<button class="bi-dl" onclick="dlFile('${esc(item.outputFile)}')">📥 TXT</button>`:''}</span>`;
list.appendChild(div);
});
const done=sttQueue.filter(i=>i.status==='done').length,failed=sttQueue.filter(i=>i.status==='failed').length,running=sttQueue.filter(i=>i.status==='running').length;
sum.innerHTML=`전체 <span>${sttQueue.length}</span>개 · 완료 <span>${done}</span> · 실패 <span>${failed}</span>${running?` · 진행중 <span>${running}</span>`:''}`;
}
// 엔진 버튼
document.querySelectorAll('#page-stt .engine-btn').forEach(btn=>{
btn.addEventListener('click',()=>{
document.querySelectorAll('#page-stt .engine-btn').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');sttEngine=btn.dataset.engine;
document.getElementById('stt-ollama-opts').classList.toggle('visible',sttEngine==='whisper+ollama');
document.getElementById('stt-or-opts').classList.toggle('visible',sttEngine==='whisper+openrouter');
document.getElementById('stt-or-opts')?.classList.toggle('visible',sttEngine==='whisper+openrouter');
document.getElementById('stt-btn').className='btn-start '+(sttEngine!=='whisper'?'purple':'green');
});
});
@@ -939,22 +1058,13 @@ document.getElementById('stt-btn').addEventListener('click',async()=>{
document.getElementById('stt-err').style.display='none';
document.getElementById('stt-btn').disabled=true;
document.getElementById('stt-prog').style.display='block';
setProg('stt',0,`${pending.length} 파일 업로드 중...`);
const subMode=document.getElementById('subtitle-mode').checked;
setProg('stt',0,`${pending.length}개 업로드 중...`);
const fd=new FormData();
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('subtitle_mode',subMode?'true':'false');
fd.append('subtitle_format',sttSubFmt);
fd.append('force_language',document.getElementById('force-language').value||'');
fd.append('translate_to',document.getElementById('translate-to').value||'');
fd.append('translate_model',document.getElementById('translate-model').value||'');
fd.append('translate_via',sttTransVia);
try{
const url=pending.length===1?'/api/transcribe':'/api/transcribe/batch';
const r=await api('POST',url,fd);const d=await r.json();
@@ -963,38 +1073,24 @@ document.getElementById('stt-btn').addEventListener('click',async()=>{
let pi=0;
sttQueue.forEach((qItem,qi)=>{
if(qItem.status!=='waiting')return;
const taskItem=items[pi++];if(!taskItem)return;
if(taskItem.error){qItem.status='failed';return}
qItem.status='running';qItem.taskId=taskItem.task_id;renderSttQueue();
pollSttItem(qi,taskItem.task_id);
const ti=items[pi++];if(!ti)return;
if(ti.error){qItem.status='failed';return}
qItem.status='running';qItem.taskId=ti.task_id;renderSttQueue();
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);qItem.outputFile=d2.output_file||null;qItem.status='done';renderSttQueue();
if(sttQueue.filter(i=>i.status==='done').length===1&&!sttQueue.some(i=>i.status==='running'))showSttResult(d2);
checkSttBatchDone();}
else if(d2.state==='failure'){clearInterval(t);qItem.status='failed';renderSttQueue();checkSttBatchDone();}
else{const done=sttQueue.filter(i=>i.status==='done').length;setProg('stt',20+Math.round((done/sttQueue.length)*75),d2.message||'처리 중...')}
}catch{}
},1800);
});
setProg('stt',20,`${items.length}개 변환 중...`);
}catch(e){showErr('stt-err',e.message);document.getElementById('stt-btn').disabled=false;document.getElementById('stt-prog').style.display='none'}
});
function pollSttItem(qi,taskId){
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.state==='success'){
clearInterval(t);
const item=sttQueue[qi];
item.outputFile=d.output_file||null;item.srtFile=d.srt_file||null;
item.vttFile=d.vtt_file||null;item.srtOrigFile=d.srt_original_file||null;
item.status='done';renderSttQueue();checkSttBatchDone();
if(sttQueue.filter(i=>i.status!=='waiting'&&i.status!=='done'&&i.status!=='failed').length===0&&
sttQueue.filter(i=>i.status==='done').length===1) showSttResult(d);
} else if(d.state==='failure'){
clearInterval(t);sttQueue[qi].status='failed';renderSttQueue();checkSttBatchDone();
} else {
const done=sttQueue.filter(i=>i.status==='done').length;
setProg('stt',20+Math.round((done/sttQueue.length)*75),d.message||'처리 중...');
}
}catch{}
},1800);
}
function checkSttBatchDone(){
if(sttQueue.every(i=>['done','failed','waiting'].includes(i.status))){
const done=sttQueue.filter(i=>i.status==='done').length;
@@ -1003,48 +1099,32 @@ function checkSttBatchDone(){
document.getElementById('stt-btn').disabled=false;
}
}
function setSttLoading(on){document.getElementById('stt-btn').disabled=on;document.getElementById('stt-prog').style.display=on?'block':'none';if(on)setProg('stt',0,'준비 중...')}
function showSttResult(d){
sttOutputFile=d.output_file;
document.getElementById('stt-mlang').textContent=((d.language||'')+( d.translated?' → '+d.translate_to:'')).toUpperCase();
document.getElementById('stt-mlang').textContent=(d.language||'').toUpperCase();
document.getElementById('stt-mdur').textContent=fmtDur(d.duration);
document.getElementById('stt-msegs').textContent=(d.segments||[]).length+'개';
const chip=document.getElementById('stt-mollama-chip');
if(d.ollama_used){chip.style.display='block';document.getElementById('stt-mollama').textContent=d.ollama_model}else chip.style.display='none';
const chip=document.getElementById('stt-mollama-chip');if(d.ollama_used){chip.style.display='block';document.getElementById('stt-mollama').textContent=d.ollama_model}else chip.style.display='none';
document.getElementById('stt-meta').style.display='flex';document.getElementById('stt-tabs').style.display='flex';
document.getElementById('stt-empty').style.display='none';document.getElementById('stt-result').style.display='block';document.getElementById('stt-result').value=d.text||'';
const sl=document.getElementById('stt-seglist');sl.innerHTML='';
(d.segments||[]).forEach(s=>{const row=document.createElement('div');row.className='seg-item';row.innerHTML=`<div class="seg-time">${fmtTime(s.start)}<br>→${fmtTime(s.end)}</div><div class="seg-text">${esc(s.text)}</div>`;sl.appendChild(row)});
document.getElementById('stt-actions').style.display='flex';
// 자막 다운로드 버튼
const srtBtn=document.getElementById('stt-dl-srt'),vttBtn=document.getElementById('stt-dl-vtt'),origBtn=document.getElementById('stt-dl-srt-orig');
srtBtn.style.display=d.srt_file?'flex':'none';if(d.srt_file)srtBtn.onclick=()=>dlFile(d.srt_file);
vttBtn.style.display=d.vtt_file?'flex':'none';if(d.vtt_file)vttBtn.onclick=()=>dlFile(d.vtt_file);
origBtn.style.display=d.srt_original_file?'flex':'none';if(d.srt_original_file)origBtn.onclick=()=>dlFile(d.srt_original_file);
setSttLoading(false);
document.getElementById('stt-actions').style.display='flex';setSttLoading(false);
}
document.getElementById('stt-copy').addEventListener('click',()=>copyText(document.getElementById('stt-result').value,document.getElementById('stt-copy')));
document.getElementById('stt-dl').addEventListener('click',()=>dlFile(sttOutputFile));
document.getElementById('stt-new').addEventListener('click',()=>{
sttQueue=[];sttInput.value='';sttOutputFile=null;renderSttQueue();
['stt-prog','stt-err','stt-meta','stt-tabs','stt-actions'].forEach(id=>document.getElementById(id).style.display='none');
document.getElementById('stt-empty').style.display='flex';
document.getElementById('stt-result').style.display='none';document.getElementById('stt-result').value='';
document.getElementById('stt-seglist').innerHTML='';document.getElementById('stt-btn').disabled=true;resetTabs('stt-tabs');
['stt-dl-srt','stt-dl-vtt','stt-dl-srt-orig'].forEach(id=>document.getElementById(id).style.display='none');
});
document.getElementById('stt-new').addEventListener('click',()=>{sttQueue=[];sttInput.value='';sttOutputFile=null;renderSttQueue();['stt-prog','stt-err','stt-meta','stt-tabs','stt-actions'].forEach(id=>document.getElementById(id).style.display='none');document.getElementById('stt-empty').style.display='flex';document.getElementById('stt-result').style.display='none';document.getElementById('stt-result').value='';document.getElementById('stt-seglist').innerHTML='';document.getElementById('stt-btn').disabled=true;resetTabs('stt-tabs')});
// ══ OCR — 배치 ══
const ocrDrop=document.getElementById('ocr-drop'),ocrInput=document.getElementById('ocr-input');
let ocrQueue=[];
const IMG_EXTS=['jpg','jpeg','png','bmp','tiff','tif','webp','gif'];
function addOcrFiles(fileList){
const IMG=['jpg','jpeg','png','bmp','tiff','tif','webp','gif'];
const files=Array.from(fileList).filter(f=>IMG.includes(f.name.split('.').pop().toLowerCase()));
const files=Array.from(fileList).filter(f=>IMG_EXTS.includes(f.name.split('.').pop().toLowerCase()));
if(!files.length)return;
files.forEach(f=>ocrQueue.push({file:f,taskId:null,txtFile:null,xlsxFile:null,status:'waiting',el:null}));
files.forEach(f=>ocrQueue.push({file:f,taskId:null,txtFile:null,xlsxFile:null,status:'waiting'}));
renderOcrQueue();document.getElementById('ocr-btn').disabled=false;
}
ocrInput.addEventListener('change',()=>addOcrFiles(ocrInput.files));
@@ -1059,8 +1139,8 @@ function renderOcrQueue(){
qEl.style.display='block';list.innerHTML='';
ocrQueue.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 class="bi-prog" id="ocr-bp-${i}" style="width:0%;display:${item.status==='running'?'block':'none'}"></div></div><span class="bi-status ${item.status}">${{waiting:'대기',running:'인식중',done:'완료',failed:'실패'}[item.status]}</span><span style="display:flex;gap:3px">${item.status==='done'?[item.txtFile?`<button class="bi-dl" onclick="dlFile('${esc(item.txtFile)}')">TXT</button>`:'',item.xlsxFile?`<button class="bi-dl" onclick="dlFile('${esc(item.xlsxFile)}')">XLS</button>`:''].filter(Boolean).join(''):''}</span>`;
item.el=div;list.appendChild(div);
div.innerHTML=`<div><div class="bi-name">${esc(item.file.name)}</div><div class="bi-prog" id="obi-${i}" style="width:0%;display:${item.status==='running'?'block':'none'}"></div></div><span class="bi-status ${item.status}">${{waiting:'대기',running:'인식중',done:'완료',failed:'실패'}[item.status]}</span><span style="display:flex;gap:3px">${item.status==='done'?[item.txtFile?`<button class="bi-dl" onclick="dlFile('${esc(item.txtFile)}')">TXT</button>`:'',item.xlsxFile?`<button class="bi-dl" onclick="dlFile('${esc(item.xlsxFile)}')">XLS</button>`:''].filter(Boolean).join(''):''}</span>`;
list.appendChild(div);
});
const done=ocrQueue.filter(i=>i.status==='done').length,failed=ocrQueue.filter(i=>i.status==='failed').length,running=ocrQueue.filter(i=>i.status==='running').length;
sum.innerHTML=`전체 <span>${ocrQueue.length}</span>개 · 완료 <span>${done}</span> · 실패 <span>${failed}</span>${running?` · 진행중 <span>${running}</span>`:''}`;
@@ -1071,11 +1151,11 @@ document.querySelectorAll('#page-ocr .engine-btn').forEach(btn=>{
document.querySelectorAll('#page-ocr .engine-btn').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');ocrEngine=btn.dataset.engine;
document.getElementById('ocr-ollama-opts').classList.toggle('visible',ocrEngine==='ollama');
document.getElementById('ocr-or-opts').classList.toggle('visible',ocrEngine==='openrouter');
document.getElementById('ocr-or-opts')?.classList.toggle('visible',ocrEngine==='openrouter');
document.getElementById('ocr-btn').className='btn-start '+(ocrEngine!=='paddle'?'purple':'green');
});
});
document.getElementById('cprompt-toggle').addEventListener('click',()=>{const ta=document.getElementById('custom-prompt');const open=ta.style.display!=='block';ta.style.display=open?'block':'none';document.getElementById('cprompt-toggle').textContent=(open?'▼':'▶')+' 커스텀 프롬프트'});
document.getElementById('cprompt-toggle')?.addEventListener('click',()=>{const ta=document.getElementById('custom-prompt');const open=ta.style.display!=='block';ta.style.display=open?'block':'none';document.getElementById('cprompt-toggle').textContent=(open?'▼':'▶')+' 커스텀 프롬프트'});
document.querySelectorAll('.mode-btn').forEach(btn=>{btn.addEventListener('click',()=>{document.querySelectorAll('.mode-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');ocrMode=btn.dataset.mode;document.getElementById('mode-desc').textContent=ocrMode==='structure'?'표 구조를 감지하고 Excel로 저장합니다':'일반 텍스트와 글자를 인식합니다'})});
document.getElementById('ocr-btn').addEventListener('click',async()=>{
@@ -1088,9 +1168,9 @@ document.getElementById('ocr-btn').addEventListener('click',async()=>{
const fd=new FormData();
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('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||'');
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';
@@ -1099,23 +1179,24 @@ document.getElementById('ocr-btn').addEventListener('click',async()=>{
let pi=0;
ocrQueue.forEach((qItem,qi)=>{
if(qItem.status!=='waiting')return;
const taskItem=items[pi++];if(!taskItem)return;
if(taskItem.error){qItem.status='failed';return}
qItem.status='running';qItem.taskId=taskItem.task_id;renderOcrQueue();
const ti=items[pi++];if(!ti)return;
if(ti.error){qItem.status='failed';return}
qItem.status='running';qItem.taskId=ti.task_id;renderOcrQueue();
const t=setInterval(async()=>{
try{
const r2=await api('GET','/api/status/'+taskItem.task_id);if(r2.status===401){clearInterval(t);showLogin();return}
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);qItem.txtFile=d2.txt_file||null;qItem.xlsxFile=d2.xlsx_file||null;qItem.status='done';renderOcrQueue();
if(ocrQueue.filter(i=>i.status==='done').length===1&&ocrQueue.filter(i=>i.status==='running').length===0)showOcrResult(d2);
if(ocrQueue.every(i=>['done','failed','waiting'].includes(i.status))){const done=ocrQueue.filter(i=>i.status==='done').length;setProg('ocr',100,`완료 ${done}/${ocrQueue.length}`);setTimeout(()=>document.getElementById('ocr-prog').style.display='none',2000);document.getElementById('ocr-btn').disabled=false;}
} else if(d2.state==='failure'){clearInterval(t);qItem.status='failed';renderOcrQueue();}
else{const done=ocrQueue.filter(i=>i.status==='done').length;setProg('ocr',20+Math.round((done/ocrQueue.length)*75),d2.message||'처리중...')}
if(ocrQueue.filter(i=>i.status==='done').length===1&&!ocrQueue.some(i=>i.status==='running'))showOcrResult(d2);
if(ocrQueue.every(i=>['done','failed','waiting'].includes(i.status))){const done=ocrQueue.filter(i=>i.status==='done').length;setProg('ocr',100,`완료 ${done}/${ocrQueue.length}`);setTimeout(()=>document.getElementById('ocr-prog').style.display='none',2000);document.getElementById('ocr-btn').disabled=false;}}
else if(d2.state==='failure'){clearInterval(t);qItem.status='failed';renderOcrQueue();}
else{const done=ocrQueue.filter(i=>i.status==='done').length;setProg('ocr',20+Math.round((done/ocrQueue.length)*75),d2.message||'처리 중...')}
}catch{}
},1800);
});
}catch(e){showErr('ocr-err',e.message);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){
ocrOutputTxt=d.txt_file||null;ocrOutputXlsx=d.xlsx_file||null;const io=d.backend!=='paddle';
@@ -1128,12 +1209,184 @@ function showOcrResult(d){
document.getElementById('ocr-copy').addEventListener('click',()=>copyText(document.getElementById('ocr-result').value,document.getElementById('ocr-copy')));
document.getElementById('ocr-dl-txt').addEventListener('click',()=>dlFile(ocrOutputTxt));
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');
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')});
// ══ 자막 ══
const subDrop=document.getElementById('sub-drop'),subInput=document.getElementById('sub-input');
let subFile=null, subTransVia='ollama', subFmt='srt';
subInput.addEventListener('change',()=>setSubFile(subInput.files[0]));
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])});
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';
}
// 자막 포맷 버튼
document.querySelectorAll('#page-subtitle .fmt-btn[data-fmt]').forEach(btn=>{
btn.addEventListener('click',()=>{
document.querySelectorAll('#page-subtitle .fmt-btn[data-fmt]').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');subFmt=btn.dataset.fmt;
});
});
// 번역 언어 선택 → 번역 엔진/모델 표시
document.getElementById('sub-trans-lang').addEventListener('change',function(){
document.getElementById('sub-trans-engine-wrap').style.display=this.value?'flex':'none';
if(this.value)fillSubTransModels();
});
// 번역 엔진 버튼
document.querySelectorAll('button[data-via]').forEach(btn=>{
btn.addEventListener('click',()=>{
document.querySelectorAll('button[data-via]').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');subTransVia=btn.dataset.via;fillSubTransModels();
});
});
function fillSubTransModels(){
const sel=document.getElementById('sub-trans-model');if(!sel)return;
const cur=sel.value;
sel.innerHTML='<option value="">설정 기본 모델 사용</option>';
const models=subTransVia==='openrouter'?orModels:ollamaModels;
models.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;sel.appendChild(o)});
}
// 스텝 표시기 업데이트
function setSubStep(step, status){
// step: 1|2|3, status: waiting|running|done|failed
const icon=document.getElementById(`sub-sicon-${step}`);
if(!icon)return;
icon.className='sub-step-icon '+status;
icon.textContent=status==='done'?'✓':status==='failed'?'✗':status==='running'?'⚡':'⏳';
const dot=document.getElementById(`sdot-${step}`);
if(dot){dot.className='step-dot '+(status==='done'?'done':status==='running'?'active':'');}
if(step>1){const line=document.getElementById(`sline-${step-1}`);if(line)line.className='step-line '+(status!=='waiting'?'done':'');}
const lbl=document.getElementById(`slabel-${step}`);
if(lbl)lbl.className='step-label '+(status==='done'?'done':status==='running'?'active':'');
}
document.getElementById('sub-btn').addEventListener('click',async()=>{
if(!subFile)return;
const transLang=document.getElementById('sub-trans-lang').value;
const fd=new FormData();
fd.append('file',subFile);
fd.append('src_language',document.getElementById('sub-src-lang').value||'');
fd.append('subtitle_fmt',subFmt);
fd.append('translate_to',transLang);
fd.append('trans_model',transLang?(document.getElementById('sub-trans-model')?.value||''):'');
fd.append('trans_via',subTransVia);
document.getElementById('sub-btn').disabled=true;
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-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||'업로드 실패');
pollSubtitle(d.task_id, transLang);
}catch(e){
showErr('sub-err',e.message);
document.getElementById('sub-btn').disabled=false;
document.getElementById('sub-prog-box').style.display='none';
}
});
function pollSubtitle(taskId, transLang){
let prevStep=0;
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.state==='progress'||d.state==='success'){
const step=d.step||1;const prog=d.progress||0;
document.getElementById('sub-prog-bar').style.width=prog+'%';
// 스텝 전환
if(step!==prevStep){
if(prevStep>0&&prevStep<step) setSubStep(prevStep,'done');
if(step<=3) setSubStep(step,'running');
// step 3가 없으면 (번역 안 함) skip
if(!transLang&&step===2){
setSubStep(3,'done');
}
prevStep=step;
}
if(d.step_msg) document.getElementById(`sub-smsg-${step}`).textContent=d.step_msg;
}
if(d.state==='success'){
clearInterval(t);
[1,2,3].forEach(s=>setSubStep(s,'done'));
document.getElementById('sub-prog-bar').style.width='100%';
setTimeout(()=>showSubResult(d),400);
} else if(d.state==='failure'){
clearInterval(t);
if(prevStep>0) setSubStep(prevStep,'failed');
showErr('sub-err',d.message||'자막 생성 실패');
document.getElementById('sub-btn').disabled=false;
}
}catch{}
},1800);
}
const LANG_NAMES={ko:'한국어',en:'English',ja:'日本語',zh:'中文(简体)',
'zh-tw':'中文(繁體)',fr:'Français',de:'Deutsch',es:'Español',
it:'Italiano',pt:'Português',ru:'Русский',ar:'العربية',
vi:'Tiếng Việt',th:'ไทย',id:'Bahasa Indonesia',nl:'Nederlands',
pl:'Polski',tr:'Türkçe',sv:'Svenska',uk:'Українська',hi:'हिन्दी'};
function langName(code){return LANG_NAMES[code]||code||'알 수 없음'}
function showSubResult(d){
document.getElementById('sub-prog-box').style.display='none';
const rc=document.getElementById('sub-result-card');rc.style.display='block';
document.getElementById('sub-res-lang').textContent=langName(d.detected_language);
document.getElementById('sub-res-dur').textContent=fmtDur(d.duration);
document.getElementById('sub-res-segs').textContent=(d.segment_count||0)+'개';
document.getElementById('sub-res-trans').textContent=d.translated?langName(d.translate_to):'없음';
const grid=document.getElementById('sub-dl-grid');grid.innerHTML='';
const addBtn=(label,lang,file,cls='')=>{
if(!file)return;
const ext=file.split('.').pop().toUpperCase();
const btn=document.createElement('button');
btn.className='sub-dl-btn '+(cls);
btn.innerHTML=`<span class="dl-icon">📄</span><span class="dl-label">${ext} ${label}</span><span class="dl-lang">${langName(lang)}</span>`;
btn.onclick=()=>dlFile(file);
grid.appendChild(btn);
};
addBtn('원어',d.detected_language,d.srt_orig);
addBtn('원어',d.detected_language,d.vtt_orig);
addBtn('번역',d.translate_to,d.srt_trans,'trans');
addBtn('번역',d.translate_to,d.vtt_trans,'trans');
document.getElementById('sub-btn').disabled=false;
}
document.getElementById('sub-new').addEventListener('click',()=>{
subFile=null;subInput.value='';
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-err').style.display='none';
document.getElementById('sub-btn').disabled=true;
document.getElementById('sub-prog-bar').style.width='0%';
[1,2,3].forEach(s=>setSubStep(s,'waiting'));
});
// ══ 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()})});
@@ -1387,24 +1640,19 @@ function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').
async function copyText(text,btn){try{await navigator.clipboard.writeText(text);const o=btn.textContent;btn.textContent='복사됨 ✓';setTimeout(()=>btn.textContent=o,1500)}catch{}}
// ══ OPENROUTER ══
let orModels=[],orVisionModels=[];
async function loadOrModels(){
try{const r=await api('GET','/api/openrouter/models');const d=await r.json();
const wrap=document.getElementById('or-models-wrap');
if(d.connected){orModels=d.models||[];orVisionModels=d.vision_models||[];
wrap.style.display='block';document.getElementById('or-connected-badge').textContent=`✓ 연결됨 — Vision ${orVisionModels.length}개 / 전체 ${orModels.length}`;populateOrSelects('vision');}
else wrap.style.display='none';
if(d.connected){orModels=d.models||[];orVisionModels=d.vision_models||[];populateOrSelects();}
}catch{}
}
let orFilter='vision';
document.querySelectorAll('.or-model-tab').forEach(btn=>{btn.addEventListener('click',()=>{document.querySelectorAll('.or-model-tab').forEach(b=>b.classList.remove('active'));btn.classList.add('active');orFilter=btn.dataset.filter;populateOrSelects(orFilter)})});
function populateOrSelects(filter){
filter=filter||orFilter;const list=filter==='vision'?orVisionModels:filter==='text'?orTextModels:orModels;
const fillOr=(sel,def)=>{if(!sel)return;const cur=sel.value||def||'';sel.innerHTML='<option value="">(없음)</option>';list.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;sel.appendChild(o)})};
fillOr(document.getElementById('setting-or-stt-model'),appSettings.openrouter_stt_model);
const ocrSel=document.getElementById('setting-or-ocr-model');
if(ocrSel){const cur=ocrSel.value||appSettings.openrouter_ocr_model||'';ocrSel.innerHTML='<option value="">(없음)</option>';orVisionModels.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;ocrSel.appendChild(o)})}
fillOr(document.getElementById('stt-or-model'),appSettings.openrouter_stt_model);
const ocrPage=document.getElementById('ocr-or-model');if(ocrPage){const cur=ocrPage.value||appSettings.openrouter_ocr_model||'';ocrPage.innerHTML='<option value="">설정 기본 모델 사용</option>';orVisionModels.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;ocrPage.appendChild(o)})}
function populateOrSelects(){
const fill=(sel,def,list)=>{if(!sel)return;const cur=sel.value||def||'';sel.innerHTML='<option value="">(없음)</option>';list.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('setting-or-stt-model'),appSettings.openrouter_stt_model,orModels);
fill(document.getElementById('setting-or-ocr-model'),appSettings.openrouter_ocr_model,orVisionModels);
fill(document.getElementById('stt-or-model'),appSettings.openrouter_stt_model,orModels);
fill(document.getElementById('ocr-or-model'),appSettings.openrouter_ocr_model,orVisionModels);
fillSubTransModels();
}
document.getElementById('btn-or-test')?.addEventListener('click',async()=>{
const key=document.getElementById('or-api-key').value.trim(),url=document.getElementById('or-url').value.trim()||'https://openrouter.ai/api/v1';
@@ -1416,8 +1664,8 @@ document.getElementById('btn-or-test')?.addEventListener('click',async()=>{
result.style.color=d.ok?'var(--accent)':'var(--warn)';result.textContent=d.message;if(d.ok)loadOrModels();}
catch{result.style.color='var(--warn)';result.textContent='요청 실패'}
});
document.getElementById('btn-refresh-models')?.addEventListener('click',()=>{loadOllamaModels();loadOrModels()});
loadLanguages();
checkAuth();
</script>
</body>