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

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}