feat: 복수 파일 배치 변환 (STT/OCR)

This commit is contained in:
root
2026-05-02 02:14:44 +09:00
parent 4af20f72e0
commit 4fc3da1a2d
6 changed files with 1252 additions and 1339 deletions

View File

@@ -251,17 +251,29 @@ 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)}
.openrouter-status.ok{background:rgba(77,166,255,.1);color:var(--blue);border:1px solid rgba(77,166,255,.2)}
.openrouter-status.fail{background:rgba(255,107,53,.1);color:var(--warn);border:1px solid rgba(255,107,53,.2)}
.or-section{margin-top:10px;padding:12px;background:var(--surf2);border:1px solid #1c2840;border-radius:4px}
.key-input-wrap{display:flex;gap:6px;margin-top:6px}
.key-input-wrap input{flex:1;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;-webkit-appearance:none}
.key-input-wrap input:focus{border-color:var(--blue)}
.btn-test{padding:9px 14px;background:none;border:1px solid #3a7cc4;color:var(--blue);border-radius:3px;font-family:var(--mono);font-size:.68rem;cursor:pointer;white-space:nowrap;transition:all .15s}
.btn-test:hover{background:rgba(77,166,255,.08)}
.or-model-tabs{display:flex;gap:5px;margin-top:8px;flex-wrap:wrap}
.or-model-tab{font-family:var(--mono);font-size:.6rem;padding:4px 10px;border:1px solid var(--border2);background:none;color:var(--muted);border-radius:2px;cursor:pointer;transition:all .12s;text-transform:uppercase}
.or-model-tab.active{border-color:var(--blue);color:var(--blue);background:rgba(77,166,255,.07)}
/* ── 자막 모드 ── */
.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}
.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}
.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}
.bi-status{font-family:var(--mono);font-size:.6rem;padding:3px 7px;border-radius:2px;white-space:nowrap}
.bi-status.waiting{background:rgba(255,255,255,.04);color:var(--muted);border:1px solid var(--border)}.bi-status.running{background:rgba(0,229,160,.07);color:var(--accent);border:1px solid rgba(0,229,160,.2)}.bi-status.done{background:rgba(0,229,160,.07);color:var(--accent2);border:1px solid rgba(0,229,160,.2)}.bi-status.failed{background:rgba(255,107,53,.07);color:var(--warn);border:1px solid rgba(255,107,53,.2)}
.bi-dl{font-family:var(--mono);font-size:.6rem;padding:3px 8px;border:1px solid var(--border2);background:none;color:var(--text);border-radius:2px;cursor:pointer;white-space:nowrap}.bi-dl:hover{border-color:var(--accent);color:var(--accent)}
.bi-prog{height:2px;background:var(--accent);border-radius:1px;transition:width .4s;margin-top:3px}
.batch-summary{font-family:var(--mono);font-size:.68rem;color:var(--muted);margin-top:8px;display:flex;gap:12px;flex-wrap:wrap}.batch-summary span{color:var(--text)}
.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}
/* ── ADMIN ── */
#page-admin{display:none;flex-direction:column}
@@ -373,26 +385,65 @@ 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">
<input type="file" id="stt-input" accept=".mp3,.mp4,.wav,.m4a,.ogg,.flac,.aac,.wma,.webm,.mkv,.avi,.mov,.ts,.mts,.h264,.h265" multiple>
<span class="drop-icon">🎵</span>
<div class="drop-label"><strong>탭하거나 드래그하여 선택</strong><br>음성 또는 영상 파일</div>
<div class="drop-label"><strong>탭하거나 드래그하여 선택</strong><br>영상(mp4/mkv/h265 등) · 음성 복수 선택 가능</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>
<div class="batch-actions">
<button class="batch-add-btn" onclick="document.getElementById('stt-input').click()">+ 파일 더 추가</button>
<button class="batch-clear-btn" id="stt-queue-clear">큐 초기화</button>
</div>
</div>
<div class="sec-label">STT 엔진</div>
<div class="engine-btns">
<button class="engine-btn active" data-engine="whisper"><span class="e-icon"></span><span class="e-name">faster-whisper</span><span class="e-desc">로컬 CPU 변환<br>빠르고 안정적</span></button>
<button class="engine-btn" data-engine="whisper+ollama"><span class="e-icon">🦙</span><span class="e-name">+ Ollama 교정</span><span class="e-desc">Whisper 후<br>Ollama 교정</span></button>
<button class="engine-btn" data-engine="whisper+openrouter" style="grid-column:1/-1"><span class="e-icon">🌐</span><span class="e-name">+ OpenRouter 교정</span><span class="e-desc">외부 AI 모델로 문장 부호·맞춤법 교정 (텍스트 전용 모델도 사용 가능)</span></button>
</div>
<div class="ollama-opts" id="stt-ollama-opts">
<div class="sec-label" style="margin-top:0">후처리 모델</div>
<select class="model-select" id="stt-ollama-model"><option value="">설정 기본 모델 사용</option></select>
</div>
<div class="ollama-opts" id="stt-or-opts">
<div class="sec-label" style="margin-top:0">OpenRouter 후처리 모델</div>
<select class="model-select" id="stt-or-model"><option value="">설정 기본 모델 사용</option></select>
<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);margin-top:5px">⚙️ 설정 → OpenRouter에서 API 키 및 기본 모델을 설정하세요</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">
@@ -421,7 +472,10 @@ 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="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" id="stt-new">새 파일</button>
</div>
</section>
@@ -434,18 +488,26 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<section class="panel">
<div class="panel-title">이미지 업로드</div>
<div class="dropzone" id="ocr-drop">
<input type="file" id="ocr-input" accept=".jpg,.jpeg,.png,.bmp,.tiff,.tif,.webp,.gif">
<input type="file" id="ocr-input" accept=".jpg,.jpeg,.png,.bmp,.tiff,.tif,.webp,.gif" multiple>
<span class="drop-icon">🖼</span>
<div class="drop-label"><strong>탭하거나 드래그하여 선택</strong><br>이미지 파일</div>
<div class="drop-label"><strong>탭하거나 드래그하여 선택</strong><br>복수 이미지 동시 선택 가능</div>
<div class="drop-formats">jpg · png · bmp · tiff · webp · gif</div>
</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>
<div class="batch-actions">
<button class="batch-add-btn" onclick="document.getElementById('ocr-input').click()">+ 파일 더 추가</button>
<button class="batch-clear-btn" id="ocr-queue-clear">큐 초기화</button>
</div>
</div>
<div class="sec-label">OCR 엔진</div>
<div class="engine-btns">
<button class="engine-btn active" data-engine="paddle"><span class="e-icon">🐾</span><span class="e-name">PaddleOCR</span><span class="e-desc">로컬 실행<br>표 구조 분석</span></button>
<button class="engine-btn" data-engine="ollama"><span class="e-icon">🦙</span><span class="e-name">Ollama Vision</span><span class="e-desc">자연어 지시<br>커스텀 프롬프트</span></button>
<button class="engine-btn" data-engine="openrouter" style="grid-column:1/-1"><span class="e-icon">🌐</span><span class="e-name">OpenRouter Vision</span><span class="e-desc">Claude / GPT-4o / Gemini 등 외부 Vision 모델 사용</span></button>
</div>
<div class="ollama-opts" id="ocr-ollama-opts">
<div class="sec-label" style="margin-top:0">Vision 모델</div>
@@ -453,13 +515,6 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<span class="cprompt-toggle" id="cprompt-toggle">▶ 커스텀 프롬프트</span>
<textarea class="cprompt" id="custom-prompt" placeholder="예: 이 영수증의 품목과 금액을 JSON으로 추출해줘"></textarea>
</div>
<div class="ollama-opts" id="ocr-or-opts">
<div class="sec-label" style="margin-top:0">OpenRouter Vision 모델</div>
<select class="model-select" id="ocr-or-model"><option value="">설정 기본 모델 사용</option></select>
<span class="cprompt-toggle" id="cprompt-toggle-or">▶ 커스텀 프롬프트</span>
<textarea class="cprompt" id="custom-prompt-or" placeholder="예: 이 영수증의 품목과 금액을 JSON으로 추출해줘"></textarea>
<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);margin-top:5px">⚠️ Vision 기능을 지원하는 모델만 이미지 처리 가능 (Claude-3, GPT-4o, Gemini 등)</div>
</div>
<div class="sec-label">인식 모드</div>
<div class="mode-btns">
<button class="mode-btn active" data-mode="text">📄 텍스트 추출</button>
@@ -585,40 +640,6 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<select class="settings-select" id="setting-ocr-model" style="margin-top:8px"><option value="">(없음)</option></select>
</div>
<!-- OpenRouter -->
<div class="settings-section">
<h3>🌐 OpenRouter 외부 AI 연동</h3>
<label class="settings-label">API 키<small>openrouter.ai에서 발급 — 저장 후 "연결 테스트"로 확인</small></label>
<div class="key-input-wrap">
<input type="password" id="or-api-key" placeholder="sk-or-v1-..." autocomplete="off">
<button class="btn-test" id="btn-or-test">연결 테스트</button>
</div>
<div id="or-test-result" style="font-family:var(--mono);font-size:.68rem;margin-top:6px;display:none"></div>
<label class="settings-label" style="margin-top:12px">API URL<small>기본값 사용 권장</small></label>
<input type="text" id="or-url" value="https://openrouter.ai/api/v1"
style="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:.75rem;outline:none;margin-top:6px">
<div id="or-models-wrap" style="display:none;margin-top:14px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">
<span id="or-connected-badge" class="openrouter-status ok"></span>
<div class="or-model-tabs">
<button class="or-model-tab active" data-filter="vision">Vision 모델</button>
<button class="or-model-tab" data-filter="text">텍스트 모델</button>
<button class="or-model-tab" data-filter="all">전체</button>
</div>
</div>
<label class="settings-label">STT 교정 기본 모델<small>텍스트 전용 모델도 사용 가능</small></label>
<select class="settings-select" id="setting-or-stt-model" style="margin-top:6px">
<option value="">(없음)</option>
</select>
<label class="settings-label" style="margin-top:10px">OCR 기본 Vision 모델<small>반드시 Vision 지원 모델 선택</small></label>
<select class="settings-select" id="setting-or-ocr-model" style="margin-top:6px">
<option value="">(없음)</option>
</select>
</div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;align-items:center">
<div id="settings-msg" style="font-family:var(--mono);font-size:.68rem;color:var(--accent);display:none">✓ 저장됨 (CPU·타임아웃: worker 재시작 후 반영)</div>
<button class="btn-settings blue" id="btn-save-settings">저장</button>
@@ -702,7 +723,6 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
// ══ STATE ══
let token=null,currentUser=null,ollamaModels=[],appSettings={};
let sttFile=null,sttOutputFile=null,sttEngine='whisper';
let orModels=[],orVisionModels=[],orTextModels=[];
let ocrFile=null,ocrOutputTxt=null,ocrOutputXlsx=null,ocrEngine='paddle',ocrMode='text';
let editTarget=null,sysTimer=null;
let histPage=1,histType='',histTotal=0;
@@ -781,7 +801,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,'(없음)');
// OpenRouter 드롭다운
populateOrSelects();
}
@@ -791,11 +810,7 @@ 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').placeholder='저장된 키: '+appSettings.openrouter_api_key_masked;
populateModelSelects();
// 기존 OR 모델 로드
if(appSettings.openrouter_api_key_masked)loadOrModels();}catch{}
populateModelSelects()}catch{}
}
document.getElementById('btn-save-settings').addEventListener('click',async()=>{
const fd=new FormData();
@@ -804,10 +819,10 @@ document.getElementById('btn-save-settings').addEventListener('click',async()=>{
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');
fd.append('openrouter_url',document.getElementById('or-url').value||'https://openrouter.ai/api/v1');
const orKey=document.getElementById('or-api-key').value.trim();if(orKey)fd.append('openrouter_api_key',orKey);
fd.append('openrouter_stt_model',document.getElementById('setting-or-stt-model').value);
fd.append('openrouter_ocr_model',document.getElementById('setting-or-ocr-model').value);
fd.append('openrouter_url',document.getElementById('or-url')?.value||'https://openrouter.ai/api/v1');
const orKey=document.getElementById('or-api-key')?.value?.trim();if(orKey)fd.append('openrouter_api_key',orKey);
fd.append('openrouter_stt_model',document.getElementById('setting-or-stt-model')?.value||'');
fd.append('openrouter_ocr_model',document.getElementById('setting-or-ocr-model')?.value||'');
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);
@@ -825,71 +840,286 @@ document.querySelectorAll('.nav-tab').forEach(btn=>{
});
});
// ══ STT ══
// ══ STT — 배치 + 자막 ══
const sttDrop=document.getElementById('stt-drop'),sttInput=document.getElementById('stt-input');
sttInput.addEventListener('change',()=>setSttFile(sttInput.files[0]));
let sttQueue=[],sttSubFmt='srt',sttTransVia='ollama';
let languages={};
// 언어 목록 로드
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()));
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}));
renderSttQueue();document.getElementById('stt-btn').disabled=false;
}
sttInput.addEventListener('change',()=>addSttFiles(sttInput.files));
sttDrop.addEventListener('dragover',e=>{e.preventDefault();sttDrop.classList.add('dragover')});
sttDrop.addEventListener('dragleave',()=>sttDrop.classList.remove('dragover'));
sttDrop.addEventListener('drop',e=>{e.preventDefault();sttDrop.classList.remove('dragover');setSttFile(e.dataTransfer.files[0])});
function setSttFile(f){if(!f)return;sttFile=f;showFileInfo('stt',f);document.getElementById('stt-btn').disabled=false;document.getElementById('stt-err').style.display='none'}
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');const isOr=sttEngine==='whisper+openrouter',isOllama=sttEngine==='whisper+ollama';document.getElementById('stt-btn').className='btn-start '+(isOr||isOllama?'purple':'green')})});
document.getElementById('stt-btn').addEventListener('click',async()=>{
if(!sttFile)return;document.getElementById('stt-err').style.display='none';setSttLoading(true);
const fd=new FormData();fd.append('file',sttFile);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||'');
try{const r=await api('POST','/api/transcribe',fd);const d=await r.json();if(!r.ok)throw new Error(d.detail||'업로드 실패');pollTask(d.task_id,dt=>setProg('stt',dt.progress||0,dt.message||'처리 중...'),showSttResult,e=>{showErr('stt-err',e);setSttLoading(false)})}
catch(e){showErr('stt-err',e.message);setSttLoading(false)}
sttDrop.addEventListener('drop',e=>{e.preventDefault();sttDrop.classList.remove('dragover');addSttFiles(e.dataTransfer.files)});
document.getElementById('stt-queue-clear').addEventListener('click',()=>{sttQueue=[];renderSttQueue();document.getElementById('stt-btn').disabled=true});
function renderSttQueue(){
const qEl=document.getElementById('stt-queue'),list=document.getElementById('stt-queue-list'),sum=document.getElementById('stt-queue-summary');
if(!sttQueue.length){qEl.style.display='none';return}
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);
});
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-btn').className='btn-start '+(sttEngine!=='whisper'?'purple':'green');
});
});
document.getElementById('stt-btn').addEventListener('click',async()=>{
const pending=sttQueue.filter(i=>i.status==='waiting');
if(!pending.length){showErr('stt-err','변환할 파일이 없습니다');return}
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;
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();
if(!r.ok)throw new Error(d.detail||'업로드 실패');
const items=pending.length===1?[d]:(d.items||[]);
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);
});
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;
setProg('stt',100,`완료 ${done}/${sttQueue.length}`);
setTimeout(()=>document.getElementById('stt-prog').style.display='none',2000);
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||'').toUpperCase();
document.getElementById('stt-mlang').textContent=((d.language||'')+( d.translated?' → '+d.translate_to:'')).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';setSttLoading(false);
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-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',()=>{sttFile=null;sttInput.value='';sttOutputFile=null;['stt-info','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')});
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');
});
// ══ OCR ══
// ══ OCR — 배치 ══
const ocrDrop=document.getElementById('ocr-drop'),ocrInput=document.getElementById('ocr-input');
ocrInput.addEventListener('change',()=>setOcrFile(ocrInput.files[0]));
let ocrQueue=[];
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()));
if(!files.length)return;
files.forEach(f=>ocrQueue.push({file:f,taskId:null,txtFile:null,xlsxFile:null,status:'waiting',el:null}));
renderOcrQueue();document.getElementById('ocr-btn').disabled=false;
}
ocrInput.addEventListener('change',()=>addOcrFiles(ocrInput.files));
ocrDrop.addEventListener('dragover',e=>{e.preventDefault();ocrDrop.classList.add('dragover')});
ocrDrop.addEventListener('dragleave',()=>ocrDrop.classList.remove('dragover'));
ocrDrop.addEventListener('drop',e=>{e.preventDefault();ocrDrop.classList.remove('dragover');setOcrFile(e.dataTransfer.files[0])});
function setOcrFile(f){if(!f)return;ocrFile=f;showFileInfo('ocr',f);document.getElementById('ocr-btn').disabled=false;document.getElementById('ocr-err').style.display='none';const p=document.getElementById('ocr-preview'),w=document.getElementById('ocr-preview-wrap');p.src=URL.createObjectURL(f);w.style.display='block'}
document.querySelectorAll('#page-ocr .engine-btn').forEach(btn=>{btn.addEventListener('click',()=>{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');const isOr=ocrEngine==='openrouter',isOllama=ocrEngine==='ollama';document.getElementById('ocr-btn').className='btn-start '+(isOr||isOllama?'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-or').addEventListener('click',()=>{const ta=document.getElementById('custom-prompt-or');const open=ta.style.display!=='block';ta.style.display=open?'block':'none';document.getElementById('cprompt-toggle-or').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()=>{
if(!ocrFile)return;document.getElementById('ocr-err').style.display='none';setOcrLoading(true);
const fd=new FormData();
fd.append('file',ocrFile);
fd.append('mode',ocrMode);
fd.append('backend',ocrEngine);
// Ollama 모델
fd.append('ollama_model', ocrEngine==='ollama' ? (document.getElementById('ocr-ollama-model').value||'') : '');
// OpenRouter 모델
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 r=await api('POST','/api/ocr',fd);const d=await r.json();if(!r.ok)throw new Error(d.detail||'업로드 실패');pollTask(d.task_id,dt=>setProg('ocr',dt.progress||0,dt.message||'처리 중...'),showOcrResult,e=>{showErr('ocr-err',e);setOcrLoading(false)})}
catch(e){showErr('ocr-err',e.message);setOcrLoading(false)}
ocrDrop.addEventListener('drop',e=>{e.preventDefault();ocrDrop.classList.remove('dragover');addOcrFiles(e.dataTransfer.files)});
document.getElementById('ocr-queue-clear').addEventListener('click',()=>{ocrQueue=[];renderOcrQueue();document.getElementById('ocr-btn').disabled=true});
function renderOcrQueue(){
const qEl=document.getElementById('ocr-queue'),list=document.getElementById('ocr-queue-list'),sum=document.getElementById('ocr-queue-summary');
if(!ocrQueue.length){qEl.style.display='none';return}
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);
});
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>`:''}`;
}
document.querySelectorAll('#page-ocr .engine-btn').forEach(btn=>{
btn.addEventListener('click',()=>{
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-btn').className='btn-start '+(ocrEngine!=='paddle'?'purple':'green');
});
});
function setOcrLoading(on){const io=(ocrEngine==='ollama'||ocrEngine==='openrouter'),c=io?'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,'준비 중...')}
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()=>{
const pending=ocrQueue.filter(i=>i.status==='waiting');
if(!pending.length){showErr('ocr-err','인식할 파일이 없습니다');return}
document.getElementById('ocr-err').style.display='none';
document.getElementById('ocr-btn').disabled=true;
document.getElementById('ocr-prog').style.display='block';
setProg('ocr',0,`${pending.length}개 업로드 중...`);
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('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 r=await api('POST',url,fd);const d=await r.json();if(!r.ok)throw new Error(d.detail||'업로드 실패');
const items=pending.length===1?[d]:(d.items||[]);
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 t=setInterval(async()=>{
try{
const r2=await api('GET','/api/status/'+taskItem.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||'처리중...')}
}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==='ollama';
document.getElementById('ocr-mlines').textContent=(d.line_count||0)+'줄';document.getElementById('ocr-mmode').textContent=d.mode==='structure'?'구조분석':'텍스트';document.getElementById('ocr-mbackend').textContent=io?`Ollama`:'Paddle';document.getElementById('ocr-mtables').textContent=(d.tables||[]).length+'개';
ocrOutputTxt=d.txt_file||null;ocrOutputXlsx=d.xlsx_file||null;const io=d.backend!=='paddle';
document.getElementById('ocr-mlines').textContent=(d.line_count||0)+'줄';document.getElementById('ocr-mmode').textContent=d.mode==='structure'?'구조분석':'텍스트';document.getElementById('ocr-mbackend').textContent=d.backend==='openrouter'?'OpenRouter':d.backend==='ollama'?'Ollama':'Paddle';document.getElementById('ocr-mtables').textContent=(d.tables||[]).length+'개';
document.getElementById('ocr-meta').style.display='flex';document.getElementById('ocr-tabs').style.display='flex';document.getElementById('ocr-empty').style.display='none';document.getElementById('ocr-result').style.display='block';document.getElementById('ocr-result').value=d.full_text||'';
const ll=document.getElementById('ocr-linelist');ll.innerHTML='';(d.lines||[]).forEach(line=>{const c=line.confidence||0,cls=c>=.9?'high':c>=.7?'mid':'low';const row=document.createElement('div');row.className='line-item';row.innerHTML=`<div class="line-conf ${cls}">${io?'AI':Math.round(c*100)+'%'}</div><div class="line-text">${esc(line.text)}</div>`;ll.appendChild(row)});
const tl=document.getElementById('ocr-tablelist'),te=document.getElementById('ocr-tableempty');tl.innerHTML='';const tables=d.tables||[];te.style.display=tables.length?'none':'flex';tables.forEach((t,i)=>{const w=document.createElement('div');w.innerHTML=`<div class="table-title">표 ${i+1}${t.rows||0}× ${t.cols||0}열</div><div class="table-wrapper">${(t.html||'').replace(/<table/g,'<table class="ocr-table"')}</div>`;tl.appendChild(w)});
@@ -898,7 +1128,12 @@ 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',()=>{ocrFile=null;ocrInput.value='';ocrOutputTxt=null;ocrOutputXlsx=null;['ocr-info','ocr-preview-wrap','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');
});
// ══ 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()})});
@@ -1153,70 +1388,36 @@ async function copyText(text,btn){try{await navigator.clipboard.writeText(text);
// ══ OPENROUTER ══
async function loadOrModels(){
try{
const r=await api('GET','/api/openrouter/models');const d=await r.json();
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||[];orTextModels=d.text_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||[];
wrap.style.display='block';document.getElementById('or-connected-badge').textContent=`✓ 연결됨 — Vision ${orVisionModels.length}개 / 전체 ${orModels.length}`;populateOrSelects('vision');}
else wrap.style.display='none';
}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);
});
});
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)=>{
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)});
};
const sttSel=document.getElementById('setting-or-stt-model');
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');
const sttPage=document.getElementById('stt-or-model');
const ocrPage=document.getElementById('ocr-or-model');
if(sttSel)fillOr(sttSel,appSettings.openrouter_stt_model);
if(ocrSel){
// OCR은 Vision만
const vlist=filter==='text'?[]:orVisionModels;
const cur=ocrSel.value||appSettings.openrouter_ocr_model||'';
ocrSel.innerHTML='<option value="">(없음)</option>';
vlist.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;ocrSel.appendChild(o)});
}
if(sttPage)fillOr(sttPage,appSettings.openrouter_stt_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)});
}
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)})}
}
document.getElementById('btn-or-test').addEventListener('click',async()=>{
const key=document.getElementById('or-api-key').value.trim();
const url=document.getElementById('or-url').value.trim()||'https://openrouter.ai/api/v1';
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';
const result=document.getElementById('or-test-result');
if(!key){result.style.display='block';result.style.color='var(--warn)';result.textContent='API 키를 입력하세요';return}
result.style.display='block';result.style.color='var(--muted)';result.textContent='연결 중...';
try{
const fd=new FormData();fd.append('api_key',key);fd.append('base_url',url);
try{const fd=new FormData();fd.append('api_key',key);fd.append('base_url',url);
const r=await api('POST','/api/openrouter/test',fd);const d=await r.json();
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='요청 실패'}
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='요청 실패'}
});
loadLanguages();
checkAuth();
</script>
</body>