feat: VoiceScript STT+OCR 초기 버전
This commit is contained in:
771
app/static/index.html
Normal file
771
app/static/index.html
Normal file
@@ -0,0 +1,771 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VoiceScript — STT & OCR</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+KR:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg:#08080a;--surf:#0f0f14;--surf2:#141419;--border:#1c1c24;--border2:#272730;
|
||||
--accent:#00e5a0;--accent2:#00b37a;--blue:#4da6ff;--purple:#a78bfa;--warn:#ff6b35;
|
||||
--text:#e4e4f0;--muted:#52526a;
|
||||
--mono:'IBM Plex Mono',monospace;--sans:'IBM Plex Sans KR',sans-serif;
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--text);font-family:var(--sans);min-height:100vh;display:flex;flex-direction:column}
|
||||
|
||||
/* ── LOGIN ── */
|
||||
#login-overlay{position:fixed;inset:0;background:var(--bg);display:flex;align-items:center;justify-content:center;z-index:999}
|
||||
.login-box{width:380px;padding:48px 40px;background:var(--surf);border:1px solid var(--border2);border-radius:6px}
|
||||
.login-logo{display:flex;align-items:center;gap:12px;margin-bottom:36px}
|
||||
.login-mark{width:28px;height:28px;background:var(--accent);clip-path:polygon(0 20%,100% 0,100% 80%,0 100%)}
|
||||
.login-title{font-family:var(--mono);font-size:1rem;font-weight:600;letter-spacing:.08em}
|
||||
.login-title span{color:var(--accent)}
|
||||
.field{margin-bottom:16px}
|
||||
.field label{display:block;font-family:var(--mono);font-size:.65rem;letter-spacing:.12em;color:var(--muted);text-transform:uppercase;margin-bottom:6px}
|
||||
.field input{width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--border2);border-radius:3px;color:var(--text);font-family:var(--mono);font-size:.85rem;outline:none;transition:border-color .15s}
|
||||
.field input:focus{border-color:var(--accent)}
|
||||
#btn-login{width:100%;margin-top:8px;padding:12px;background:var(--accent);color:#000;border:none;border-radius:3px;font-family:var(--mono);font-size:.82rem;font-weight:600;letter-spacing:.1em;cursor:pointer;transition:all .15s;text-transform:uppercase}
|
||||
#btn-login:hover{background:#00ffb3}
|
||||
#login-err{display:none;margin-top:12px;padding:10px 12px;background:rgba(255,107,53,.08);border:1px solid rgba(255,107,53,.3);border-radius:3px;font-family:var(--mono);font-size:.72rem;color:var(--warn)}
|
||||
|
||||
/* ── HEADER ── */
|
||||
header{border-bottom:1px solid var(--border);padding:14px 28px;display:flex;align-items:center;gap:12px;position:sticky;top:0;background:rgba(8,8,10,.92);backdrop-filter:blur(12px);z-index:100}
|
||||
.logo-mark{width:28px;height:28px;background:var(--accent);clip-path:polygon(0 20%,100% 0,100% 80%,0 100%)}
|
||||
header h1{font-family:var(--mono);font-size:1rem;font-weight:600;letter-spacing:.08em}
|
||||
header h1 span{color:var(--accent)}
|
||||
#user-info{margin-left:auto;display:flex;align-items:center;gap:12px;font-family:var(--mono);font-size:.68rem;color:var(--muted)}
|
||||
#user-name{color:var(--accent)}
|
||||
#btn-logout{background:none;border:1px solid var(--border2);color:var(--muted);padding:4px 10px;border-radius:2px;font-family:var(--mono);font-size:.65rem;cursor:pointer;letter-spacing:.08em;transition:all .15s;text-transform:uppercase}
|
||||
#btn-logout:hover{border-color:var(--warn);color:var(--warn)}
|
||||
|
||||
/* ── NAV ── */
|
||||
.nav-tabs{display:flex;border-bottom:1px solid var(--border);padding:0 28px;background:var(--surf)}
|
||||
.nav-tab{font-family:var(--mono);font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;padding:14px 20px;background:none;border:none;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;display:flex;align-items:center;gap:8px}
|
||||
.nav-tab.active{color:var(--accent);border-bottom-color:var(--accent)}
|
||||
.nav-tab:hover:not(.active){color:var(--text)}
|
||||
|
||||
/* ── PAGE / PANEL ── */
|
||||
.page{display:none;flex:1}
|
||||
.page.active{display:flex}
|
||||
.two-panel{display:grid;grid-template-columns:1fr 1fr;width:100%;max-width:1360px;margin:0 auto}
|
||||
.panel{padding:32px 36px;border-right:1px solid var(--border);min-height:calc(100vh - 120px)}
|
||||
.panel:last-child{border-right:none}
|
||||
.panel-title{font-family:var(--mono);font-size:.65rem;letter-spacing:.15em;color:var(--muted);text-transform:uppercase;margin-bottom:24px;display:flex;align-items:center;gap:10px}
|
||||
.panel-title::after{content:'';flex:1;height:1px;background:var(--border)}
|
||||
|
||||
/* ── DROPZONE ── */
|
||||
.dropzone{border:1px dashed var(--border2);border-radius:4px;padding:44px 24px;text-align:center;cursor:pointer;transition:all .2s;position:relative;background:var(--surf)}
|
||||
.dropzone:hover,.dropzone.dragover{border-color:var(--accent);background:rgba(0,229,160,.04)}
|
||||
.dropzone input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer}
|
||||
.drop-icon{font-size:2.2rem;margin-bottom:12px;display:block;opacity:.35}
|
||||
.drop-label{font-size:.9rem;color:var(--muted);line-height:1.7}
|
||||
.drop-label strong{color:var(--text);font-weight:500}
|
||||
.drop-formats{margin-top:10px;font-family:var(--mono);font-size:.62rem;color:var(--muted);letter-spacing:.05em}
|
||||
|
||||
/* ── FILE INFO ── */
|
||||
.file-info{display:none;margin-top:14px;padding:12px 14px;background:var(--surf);border:1px solid var(--border2);border-radius:3px;font-family:var(--mono);font-size:.75rem}
|
||||
.file-info .fname{color:var(--accent);margin-bottom:3px;word-break:break-all}
|
||||
.file-info .fsize{color:var(--muted)}
|
||||
|
||||
/* ── SECTION LABEL ── */
|
||||
.sec-label{font-family:var(--mono);font-size:.63rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;margin-bottom:7px}
|
||||
|
||||
/* ── ENGINE SELECTOR ── */
|
||||
.engine-selector{margin-top:18px}
|
||||
.engine-btns{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:7px}
|
||||
.engine-btn{
|
||||
padding:12px 8px;background:var(--surf);border:1px solid var(--border2);
|
||||
color:var(--muted);border-radius:4px;font-family:var(--mono);font-size:.72rem;
|
||||
letter-spacing:.06em;cursor:pointer;transition:all .18s;text-align:center;
|
||||
display:flex;flex-direction:column;align-items:center;gap:5px;
|
||||
}
|
||||
.engine-btn .e-icon{font-size:1.4rem;opacity:.5;transition:opacity .18s}
|
||||
.engine-btn .e-name{font-weight:600;font-size:.72rem}
|
||||
.engine-btn .e-desc{font-size:.6rem;color:var(--muted);letter-spacing:.04em;line-height:1.4}
|
||||
.engine-btn.active[data-engine="paddle"]{background:rgba(0,229,160,.07);border-color:var(--accent2);color:var(--accent)}
|
||||
.engine-btn.active[data-engine="paddle"] .e-icon{opacity:1}
|
||||
.engine-btn.active[data-engine="paddle"] .e-desc{color:var(--accent2)}
|
||||
.engine-btn.active[data-engine="ollama"]{background:rgba(167,139,250,.07);border-color:#7c6cd4;color:var(--purple)}
|
||||
.engine-btn.active[data-engine="ollama"] .e-icon{opacity:1}
|
||||
.engine-btn.active[data-engine="ollama"] .e-desc{color:#9b8de6}
|
||||
|
||||
/* ── OLLAMA OPTIONS (조건부 표시) ── */
|
||||
#ollama-options{
|
||||
margin-top:14px;padding:14px;background:var(--surf2);
|
||||
border:1px solid #272040;border-radius:4px;
|
||||
display:none;
|
||||
}
|
||||
#ollama-options.visible{display:block}
|
||||
.ollama-model-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:7px}
|
||||
.model-card{
|
||||
padding:10px 12px;background:var(--surf);border:1px solid var(--border2);
|
||||
border-radius:3px;cursor:pointer;transition:all .15s;
|
||||
}
|
||||
.model-card:hover{border-color:#7c6cd4}
|
||||
.model-card.active{background:rgba(167,139,250,.08);border-color:#7c6cd4}
|
||||
.mc-name{font-family:var(--mono);font-size:.7rem;color:var(--text);margin-bottom:4px}
|
||||
.mc-size{font-family:var(--mono);font-size:.6rem;color:var(--muted)}
|
||||
.mc-tag{font-family:var(--mono);font-size:.55rem;padding:2px 5px;border-radius:2px;margin-top:4px;display:inline-block}
|
||||
.mc-tag.ocr{background:rgba(0,229,160,.1);color:var(--accent)}
|
||||
.mc-tag.doc{background:rgba(77,166,255,.1);color:var(--blue)}
|
||||
.mc-tag.gen{background:rgba(167,139,250,.1);color:var(--purple)}
|
||||
.mc-tag.best{background:rgba(255,107,53,.1);color:#ff9d6b}
|
||||
|
||||
/* ── 커스텀 프롬프트 ── */
|
||||
#custom-prompt-wrap{margin-top:12px;display:none}
|
||||
#custom-prompt-wrap.visible{display:block}
|
||||
textarea.cprompt{
|
||||
width:100%;background:var(--surf);border:1px solid var(--border2);color:var(--text);
|
||||
padding:10px 12px;border-radius:3px;font-family:var(--mono);font-size:.72rem;
|
||||
line-height:1.6;resize:vertical;min-height:72px;outline:none;
|
||||
}
|
||||
textarea.cprompt:focus{border-color:#7c6cd4}
|
||||
.cprompt-toggle{
|
||||
display:inline-flex;align-items:center;gap:6px;
|
||||
font-family:var(--mono);font-size:.65rem;color:var(--muted);cursor:pointer;
|
||||
margin-top:8px;
|
||||
}
|
||||
.cprompt-toggle:hover{color:var(--text)}
|
||||
|
||||
/* ── OPTIONS ── */
|
||||
.options{margin-top:14px;display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.opt-item label{display:block;font-family:var(--mono);font-size:.63rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;margin-bottom:5px}
|
||||
.opt-item select{width:100%;background:var(--surf);border:1px solid var(--border2);color:var(--text);padding:8px 10px;border-radius:3px;font-family:var(--mono);font-size:.75rem;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}
|
||||
.opt-item select:focus{border-color:var(--accent)}
|
||||
|
||||
/* ── MODE TOGGLE ── */
|
||||
.mode-toggle{margin-top:14px}
|
||||
.mode-btns{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:7px}
|
||||
.mode-btn{padding:9px;background:var(--surf);border:1px solid var(--border2);color:var(--muted);border-radius:3px;font-family:var(--mono);font-size:.7rem;letter-spacing:.07em;cursor:pointer;transition:all .15s;text-align:center;text-transform:uppercase}
|
||||
.mode-btn.active{background:rgba(0,229,160,.07);border-color:var(--accent2);color:var(--accent)}
|
||||
#mode-desc{margin-top:6px;font-family:var(--mono);font-size:.62rem;color:var(--muted);line-height:1.6}
|
||||
|
||||
/* ── BUTTON ── */
|
||||
.btn-start{margin-top:16px;width:100%;padding:13px;border:none;border-radius:3px;font-family:var(--mono);font-size:.82rem;font-weight:600;letter-spacing:.1em;cursor:pointer;transition:all .15s;text-transform:uppercase}
|
||||
.btn-start.green{background:var(--accent);color:#000}
|
||||
.btn-start.green:hover:not(:disabled){background:#00ffb3;transform:translateY(-1px)}
|
||||
.btn-start.purple{background:var(--purple);color:#fff}
|
||||
.btn-start.purple:hover:not(:disabled){background:#c4b5fd;transform:translateY(-1px)}
|
||||
.btn-start:disabled{background:var(--border2);color:var(--muted);cursor:not-allowed;transform:none}
|
||||
|
||||
/* ── PROGRESS ── */
|
||||
.prog-box{display:none;margin-top:16px}
|
||||
.prog-header{display:flex;justify-content:space-between;margin-bottom:6px}
|
||||
.prog-msg{font-family:var(--mono);font-size:.72rem;color:var(--muted)}
|
||||
.prog-pct{font-family:var(--mono);font-size:.72rem}
|
||||
.prog-track{height:2px;background:var(--border);border-radius:1px;overflow:hidden}
|
||||
.prog-fill{height:100%;transition:width .4s ease;width:0%;border-radius:1px}
|
||||
.waveform{display:flex;align-items:center;justify-content:center;gap:3px;margin-top:16px;height:28px}
|
||||
.wave-bar{width:3px;border-radius:2px;opacity:.6;animation:wave 1s ease-in-out infinite}
|
||||
.wave-bar:nth-child(1){animation-delay:0s;height:8px}
|
||||
.wave-bar:nth-child(2){animation-delay:.1s;height:14px}
|
||||
.wave-bar:nth-child(3){animation-delay:.2s;height:22px}
|
||||
.wave-bar:nth-child(4){animation-delay:.3s;height:18px}
|
||||
.wave-bar:nth-child(5){animation-delay:.4s;height:26px}
|
||||
.wave-bar:nth-child(6){animation-delay:.3s;height:18px}
|
||||
.wave-bar:nth-child(7){animation-delay:.2s;height:22px}
|
||||
.wave-bar:nth-child(8){animation-delay:.1s;height:14px}
|
||||
.wave-bar:nth-child(9){animation-delay:0s;height:8px}
|
||||
@keyframes wave{0%,100%{transform:scaleY(.4);opacity:.3}50%{transform:scaleY(1.2);opacity:.9}}
|
||||
|
||||
/* ── ERROR ── */
|
||||
.err-box{display:none;margin-top:12px;padding:10px 12px;background:rgba(255,107,53,.08);border:1px solid rgba(255,107,53,.3);border-radius:3px;font-family:var(--mono);font-size:.72rem;color:var(--warn);white-space:pre-wrap;line-height:1.6}
|
||||
|
||||
/* ── RESULT ── */
|
||||
.result-meta{display:none;flex-wrap:wrap;gap:8px;margin-bottom:14px}
|
||||
.meta-chip{font-family:var(--mono);font-size:.63rem;padding:4px 9px;border:1px solid var(--border2);border-radius:2px;color:var(--muted);letter-spacing:.04em}
|
||||
.meta-chip span{color:var(--accent)}
|
||||
.meta-chip.ollama span{color:var(--purple)}
|
||||
|
||||
.result-tabs{display:none;border-bottom:1px solid var(--border);margin-bottom:14px}
|
||||
.tab-btn{font-family:var(--mono);font-size:.67rem;letter-spacing:.1em;padding:9px 14px;background:none;border:none;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;text-transform:uppercase}
|
||||
.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}
|
||||
.tab-btn:hover:not(.active){color:var(--text)}
|
||||
.tab-content{display:none;flex-direction:column;flex:1}
|
||||
.tab-content.active{display:flex}
|
||||
|
||||
.result-textarea{flex:1;min-height:360px;background:var(--surf);border:1px solid var(--border);color:var(--text);padding:16px;border-radius:3px;font-family:var(--mono);font-size:.78rem;line-height:1.8;resize:vertical;outline:none;white-space:pre-wrap}
|
||||
|
||||
.segments-list,.lines-list,.segments-list-ocr{flex:1;min-height:360px;overflow-y:auto;background:var(--surf);border:1px solid var(--border);border-radius:3px}
|
||||
.seg-item{display:grid;grid-template-columns:110px 1fr;border-bottom:1px solid var(--border)}
|
||||
.seg-item:last-child{border-bottom:none}
|
||||
.seg-item:hover{background:rgba(255,255,255,.015)}
|
||||
.seg-time{padding:11px 12px;font-family:var(--mono);font-size:.65rem;color:var(--muted);border-right:1px solid var(--border);white-space:nowrap;line-height:1.6}
|
||||
.seg-text{padding:11px 14px;font-size:.8rem;line-height:1.6}
|
||||
|
||||
.line-item{display:grid;grid-template-columns:60px 1fr;border-bottom:1px solid var(--border)}
|
||||
.line-item:last-child{border-bottom:none}
|
||||
.line-conf{padding:9px 10px;font-family:var(--mono);font-size:.62rem;border-right:1px solid var(--border);text-align:center;display:flex;align-items:center;justify-content:center}
|
||||
.line-conf.high{color:var(--accent)}.line-conf.mid{color:#f0b42a}.line-conf.low{color:var(--warn)}
|
||||
.line-text{padding:9px 12px;font-size:.8rem;line-height:1.5}
|
||||
|
||||
.table-wrapper{overflow-x:auto;margin-bottom:14px;border:1px solid var(--border);border-radius:3px}
|
||||
.ocr-table{width:100%;border-collapse:collapse;font-size:.78rem;font-family:var(--mono)}
|
||||
.ocr-table th{background:#1a1a2e;color:var(--accent);padding:8px 12px;text-align:left;border:1px solid var(--border2);font-weight:500}
|
||||
.ocr-table td{padding:8px 12px;border:1px solid var(--border);line-height:1.5}
|
||||
.ocr-table tr:nth-child(even) td{background:rgba(255,255,255,.015)}
|
||||
.table-title{font-family:var(--mono);font-size:.68rem;color:var(--muted);letter-spacing:.08em;padding:10px 12px;background:var(--surf2);border-bottom:1px solid var(--border);text-transform:uppercase}
|
||||
|
||||
.result-actions{display:none;gap:8px;margin-top:12px}
|
||||
.btn-act{flex:1;padding:9px;background:none;border:1px solid var(--border2);color:var(--text);border-radius:3px;font-family:var(--mono);font-size:.68rem;letter-spacing:.08em;cursor:pointer;transition:all .15s;text-transform:uppercase}
|
||||
.btn-act:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.btn-act.primary{background:rgba(0,229,160,.07);border-color:var(--accent2);color:var(--accent)}
|
||||
.btn-act.excel{background:rgba(77,166,255,.07);border-color:#3a7cc4;color:var(--blue)}
|
||||
.btn-act.excel:hover{background:rgba(77,166,255,.14)}
|
||||
|
||||
.empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--muted);padding:60px 0}
|
||||
.empty-icon{font-size:2.2rem;opacity:.18}
|
||||
.empty-text{font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;text-align:center;line-height:1.9;text-transform:uppercase}
|
||||
|
||||
@media(max-width:900px){.two-panel{grid-template-columns:1fr}.panel{border-right:none;border-bottom:1px solid var(--border);min-height:auto;padding:24px 18px}.panel:last-child{border-bottom:none}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ════════ LOGIN ════════ -->
|
||||
<div id="login-overlay">
|
||||
<div class="login-box">
|
||||
<div class="login-logo">
|
||||
<div class="login-mark"></div>
|
||||
<div class="login-title">Voice<span>Script</span></div>
|
||||
</div>
|
||||
<div class="field"><label>아이디</label><input type="text" id="inp-user" placeholder="username" autocomplete="username"></div>
|
||||
<div class="field"><label>비밀번호</label><input type="password" id="inp-pass" placeholder="password" autocomplete="current-password"></div>
|
||||
<button id="btn-login">로그인</button>
|
||||
<div id="login-err"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════ HEADER ════════ -->
|
||||
<header>
|
||||
<div class="logo-mark"></div>
|
||||
<h1>Voice<span>Script</span></h1>
|
||||
<div id="user-info"><span id="user-name"></span><button id="btn-logout">로그아웃</button></div>
|
||||
</header>
|
||||
|
||||
<!-- ════════ NAV ════════ -->
|
||||
<div class="nav-tabs">
|
||||
<button class="nav-tab active" data-page="stt"><span>🎙</span> STT 음성변환</button>
|
||||
<button class="nav-tab" data-page="ocr"><span>🔍</span> OCR 이미지인식</button>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════
|
||||
STT PAGE
|
||||
════════════════════════════════════════════════ -->
|
||||
<div class="page active" id="page-stt">
|
||||
<div class="two-panel">
|
||||
<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">
|
||||
<span class="drop-icon">🎵</span>
|
||||
<div class="drop-label"><strong>드래그하거나 클릭하여 선택</strong><br>음성 또는 영상 파일</div>
|
||||
<div class="drop-formats">mp3 · wav · m4a · ogg · flac · aac · mp4 · webm · mkv</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 class="options">
|
||||
<div class="opt-item"><label>언어</label>
|
||||
<select id="stt-lang"><option value="ko">한국어</option><option value="en">English</option><option value="ja">日本語</option><option value="zh">中文</option><option value="">자동 감지</option></select>
|
||||
</div>
|
||||
<div class="opt-item"><label>출력</label>
|
||||
<select id="stt-fmt"><option value="full">전체 텍스트</option><option value="timestamp">타임스탬프</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-start green" id="stt-btn" disabled>변환 시작</button>
|
||||
<div class="prog-box" id="stt-prog">
|
||||
<div class="prog-header"><span class="prog-msg" id="stt-pmsg">처리 중...</span><span class="prog-pct" id="stt-ppct" style="color:var(--accent)">0%</span></div>
|
||||
<div class="prog-track"><div class="prog-fill" id="stt-pfill" style="background:var(--accent)"></div></div>
|
||||
<div class="waveform"><div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div></div>
|
||||
</div>
|
||||
<div class="err-box" id="stt-err"></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-title">변환 결과</div>
|
||||
<div class="result-meta" id="stt-meta">
|
||||
<div class="meta-chip">언어 <span id="stt-mlang">—</span></div>
|
||||
<div class="meta-chip">길이 <span id="stt-mdur">—</span></div>
|
||||
<div class="meta-chip">세그먼트 <span id="stt-msegs">—</span></div>
|
||||
</div>
|
||||
<div class="result-tabs" id="stt-tabs">
|
||||
<button class="tab-btn active" data-tab="stt-text">전체 텍스트</button>
|
||||
<button class="tab-btn" data-tab="stt-segs">타임스탬프</button>
|
||||
</div>
|
||||
<div class="tab-content active" id="stt-text">
|
||||
<div class="empty-state" id="stt-empty"><div class="empty-icon">📝</div><div class="empty-text">파일 업로드 후<br>변환을 시작하면<br>결과가 표시됩니다</div></div>
|
||||
<textarea class="result-textarea" id="stt-result" style="display:none" readonly></textarea>
|
||||
</div>
|
||||
<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" id="stt-new">새 파일</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════
|
||||
OCR PAGE
|
||||
════════════════════════════════════════════════ -->
|
||||
<div class="page" id="page-ocr">
|
||||
<div class="two-panel">
|
||||
<!-- 업로드 & 설정 -->
|
||||
<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">
|
||||
<span class="drop-icon">🖼</span>
|
||||
<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" style="display:none;margin-top:12px">
|
||||
<img id="ocr-preview" style="max-width:100%;max-height:180px;border:1px solid var(--border);border-radius:3px;object-fit:contain">
|
||||
</div>
|
||||
|
||||
<!-- ─── OCR 엔진 선택 ─── -->
|
||||
<div class="engine-selector">
|
||||
<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>PP-Structure 지원</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">기존 Ollama 서버 사용<br>자연어 지시 가능</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Ollama 전용 옵션 ─── -->
|
||||
<div id="ollama-options">
|
||||
<div class="sec-label" style="margin-bottom:7px">모델 선택</div>
|
||||
<div class="ollama-model-grid">
|
||||
<div class="model-card active" data-model="granite3.2-vision">
|
||||
<div class="mc-name">granite3.2-vision</div>
|
||||
<div class="mc-size">IBM · ~2GB</div>
|
||||
<span class="mc-tag doc">문서/표 특화</span>
|
||||
</div>
|
||||
<div class="model-card" data-model="deepseek-ocr:3b">
|
||||
<div class="mc-name">deepseek-ocr:3b</div>
|
||||
<div class="mc-size">DeepSeek · ~2GB</div>
|
||||
<span class="mc-tag ocr">OCR 전용</span>
|
||||
</div>
|
||||
<div class="model-card" data-model="llama3.2-vision:11b">
|
||||
<div class="mc-name">llama3.2-vision:11b</div>
|
||||
<div class="mc-size">Meta · ~8GB</div>
|
||||
<span class="mc-tag gen">범용 고정확도</span>
|
||||
</div>
|
||||
<div class="model-card" data-model="richardyoung/olmocr2:7b-q8">
|
||||
<div class="mc-name">olmocr2:7b-q8</div>
|
||||
<div class="mc-size">AllenAI · ~9GB</div>
|
||||
<span class="mc-tag best">최고 정확도</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 커스텀 프롬프트 -->
|
||||
<div class="cprompt-toggle" id="cprompt-toggle">
|
||||
<span id="cprompt-arrow">▶</span> 커스텀 프롬프트 직접 입력
|
||||
</div>
|
||||
<div id="custom-prompt-wrap">
|
||||
<textarea class="cprompt" id="custom-prompt" placeholder="예: 이 영수증에서 품목명과 금액만 표 형식으로 추출해줘"></textarea>
|
||||
<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);margin-top:4px">비워두면 인식 모드에 맞는 기본 프롬프트가 사용됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 인식 모드 ─── -->
|
||||
<div class="mode-toggle">
|
||||
<div class="sec-label">인식 모드</div>
|
||||
<div class="mode-btns">
|
||||
<button class="mode-btn active" data-mode="text">📄 텍스트 추출</button>
|
||||
<button class="mode-btn" data-mode="structure">📊 표 구조 분석</button>
|
||||
</div>
|
||||
<div id="mode-desc" style="margin-top:6px;font-family:var(--mono);font-size:.62rem;color:var(--muted);line-height:1.6">
|
||||
일반 텍스트와 글자를 인식합니다
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── PaddleOCR 언어 (Paddle 전용 표시) ─── -->
|
||||
<div id="paddle-lang-wrap" class="options" style="grid-template-columns:1fr">
|
||||
<div class="opt-item"><label>OCR 언어</label>
|
||||
<select id="ocr-lang"><option value="korean">한국어</option><option value="en">English</option><option value="japan">日本語</option><option value="chinese_cht">中文 (繁)</option><option value="ch">中文 (簡)</option></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-start green" id="ocr-btn" disabled>인식 시작</button>
|
||||
<div class="prog-box" id="ocr-prog">
|
||||
<div class="prog-header"><span class="prog-msg" id="ocr-pmsg">처리 중...</span><span class="prog-pct" id="ocr-ppct" style="color:var(--accent)">0%</span></div>
|
||||
<div class="prog-track"><div class="prog-fill" id="ocr-pfill" style="background:var(--accent)"></div></div>
|
||||
<div class="waveform" id="ocr-wave" style="display:none">
|
||||
<div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div><div class="wave-bar" style="background:var(--accent)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="err-box" id="ocr-err"></div>
|
||||
</section>
|
||||
|
||||
<!-- 결과 -->
|
||||
<section class="panel">
|
||||
<div class="panel-title">인식 결과</div>
|
||||
<div class="result-meta" id="ocr-meta">
|
||||
<div class="meta-chip">줄 수 <span id="ocr-mlines">—</span></div>
|
||||
<div class="meta-chip">모드 <span id="ocr-mmode">—</span></div>
|
||||
<div class="meta-chip" id="ocr-mbackend-chip">엔진 <span id="ocr-mbackend">—</span></div>
|
||||
<div class="meta-chip">표 <span id="ocr-mtables">—</span></div>
|
||||
</div>
|
||||
<div class="result-tabs" id="ocr-tabs">
|
||||
<button class="tab-btn active" data-tab="ocr-text">전체 텍스트</button>
|
||||
<button class="tab-btn" data-tab="ocr-lines">줄별 신뢰도</button>
|
||||
<button class="tab-btn" data-tab="ocr-tables">표 뷰어</button>
|
||||
</div>
|
||||
<div class="tab-content active" id="ocr-text">
|
||||
<div class="empty-state" id="ocr-empty"><div class="empty-icon">🔍</div><div class="empty-text">이미지 업로드 후<br>인식을 시작하면<br>결과가 표시됩니다</div></div>
|
||||
<textarea class="result-textarea" id="ocr-result" style="display:none" readonly></textarea>
|
||||
</div>
|
||||
<div class="tab-content" id="ocr-lines"><div class="lines-list" id="ocr-linelist"></div></div>
|
||||
<div class="tab-content" id="ocr-tables">
|
||||
<div id="ocr-tablelist" style="overflow-y:auto;max-height:480px"></div>
|
||||
<div class="empty-state" id="ocr-tableempty"><div class="empty-icon">📊</div><div class="empty-text">표 구조 분석 모드를<br>선택하면 표를<br>추출할 수 있습니다</div></div>
|
||||
</div>
|
||||
<div class="result-actions" id="ocr-actions">
|
||||
<button class="btn-act" id="ocr-copy">복사</button>
|
||||
<button class="btn-act primary" id="ocr-dl-txt">TXT 저장</button>
|
||||
<button class="btn-act excel" id="ocr-dl-xlsx" style="display:none">Excel 저장</button>
|
||||
<button class="btn-act" id="ocr-new">새 파일</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// STATE
|
||||
// ════════════════════════════════════════════════════════════
|
||||
let token = localStorage.getItem('vs_token') || null;
|
||||
let sttFile=null,sttOutputFile=null,sttTaskId=null;
|
||||
let ocrFile=null,ocrOutputTxt=null,ocrOutputXlsx=null,ocrTaskId=null;
|
||||
let ocrEngine='paddle', ocrMode='text', ocrModel='granite3.2-vision';
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// AUTH
|
||||
// ════════════════════════════════════════════════════════════
|
||||
async function checkAuth(){
|
||||
if(!token){showLogin();return}
|
||||
try{const r=await api('GET','/api/me');if(r.ok){const d=await r.json();document.getElementById('user-name').textContent=d.username;hideLogin()}else showLogin()}
|
||||
catch{showLogin()}
|
||||
}
|
||||
const showLogin=()=>document.getElementById('login-overlay').style.display='flex';
|
||||
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);
|
||||
document.getElementById('user-name').textContent=u;hideLogin();
|
||||
}catch{err.style.display='block';err.textContent='서버 연결 실패'}
|
||||
}
|
||||
document.getElementById('btn-logout').addEventListener('click',()=>{
|
||||
token=null;localStorage.removeItem('vs_token');showLogin();document.getElementById('inp-pass').value='';
|
||||
});
|
||||
const api=(method,url,body)=>{const o={method,headers:{Authorization:'Bearer '+(token||'')}};if(body)o.body=body;return fetch(url,o)};
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// NAV TABS
|
||||
// ════════════════════════════════════════════════════════════
|
||||
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');document.getElementById('page-'+btn.dataset.page).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// STT
|
||||
// ════════════════════════════════════════════════════════════
|
||||
const sttDrop=document.getElementById('stt-drop'),sttInput=document.getElementById('stt-input');
|
||||
sttInput.addEventListener('change',()=>setSttFile(sttInput.files[0]));
|
||||
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.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);
|
||||
try{const r=await api('POST','/api/transcribe',fd);const d=await r.json();if(!r.ok)throw new Error(d.detail||'업로드 실패');sttTaskId=d.task_id;pollTask(sttTaskId,d=>setProg('stt',d.progress||0,d.message||'처리 중...',false),showSttResult,e=>{document.getElementById('stt-err').style.display='block';document.getElementById('stt-err').textContent='⚠ '+e;setSttLoading(false)})}
|
||||
catch(e){document.getElementById('stt-err').style.display='block';document.getElementById('stt-err').textContent='⚠ '+e.message;setSttLoading(false)}
|
||||
});
|
||||
|
||||
function setSttLoading(on){document.getElementById('stt-btn').disabled=on;document.getElementById('stt-prog').style.display=on?'block':'none';if(on)setProg('stt',0,'준비 중...',false)}
|
||||
|
||||
function showSttResult(d){
|
||||
sttOutputFile=d.output_file;
|
||||
document.getElementById('stt-mlang').textContent=(d.language||'').toUpperCase();
|
||||
document.getElementById('stt-mdur').textContent=fmtDur(d.duration);
|
||||
document.getElementById('stt-msegs').textContent=(d.segments||[]).length+'개';
|
||||
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 r=document.createElement('div');r.className='seg-item';r.innerHTML=`<div class="seg-time">${fmtTime(s.start)}<br>→ ${fmtTime(s.end)}</div><div class="seg-text">${esc(s.text)}</div>`;sl.appendChild(r)});
|
||||
document.getElementById('stt-actions').style.display='flex';setSttLoading(false);
|
||||
}
|
||||
document.getElementById('stt-copy').addEventListener('click',()=>copyText(document.getElementById('stt-result').value,document.getElementById('stt-copy')));
|
||||
document.getElementById('stt-dl').addEventListener('click',()=>dlFile(sttOutputFile));
|
||||
document.getElementById('stt-new').addEventListener('click',resetStt);
|
||||
function resetStt(){sttFile=null;sttInput.value='';sttOutputFile=null;document.getElementById('stt-info').style.display='none';document.getElementById('stt-btn').disabled=true;document.getElementById('stt-prog').style.display='none';document.getElementById('stt-err').style.display='none';document.getElementById('stt-meta').style.display='none';document.getElementById('stt-tabs').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-actions').style.display='none';resetTabs('stt-tabs')}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// OCR — ENGINE SELECTOR
|
||||
// ════════════════════════════════════════════════════════════
|
||||
document.querySelectorAll('.engine-btn').forEach(btn=>{
|
||||
btn.addEventListener('click',()=>{
|
||||
document.querySelectorAll('.engine-btn').forEach(b=>b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
ocrEngine=btn.dataset.engine;
|
||||
const ollamaOpts=document.getElementById('ollama-options');
|
||||
const paddleLang=document.getElementById('paddle-lang-wrap');
|
||||
const ocrBtn=document.getElementById('ocr-btn');
|
||||
if(ocrEngine==='ollama'){
|
||||
ollamaOpts.classList.add('visible');
|
||||
paddleLang.style.display='none';
|
||||
ocrBtn.className='btn-start purple';
|
||||
ocrBtn.style.background='';
|
||||
} else {
|
||||
ollamaOpts.classList.remove('visible');
|
||||
paddleLang.style.display='grid';
|
||||
ocrBtn.className='btn-start green';
|
||||
}
|
||||
if(ocrFile) ocrBtn.disabled=false;
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Ollama 모델 카드 ───────────────────────────────────────
|
||||
document.querySelectorAll('.model-card').forEach(card=>{
|
||||
card.addEventListener('click',()=>{
|
||||
document.querySelectorAll('.model-card').forEach(c=>c.classList.remove('active'));
|
||||
card.classList.add('active');
|
||||
ocrModel=card.dataset.model;
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 커스텀 프롬프트 토글 ───────────────────────────────────
|
||||
document.getElementById('cprompt-toggle').addEventListener('click',()=>{
|
||||
const wrap=document.getElementById('custom-prompt-wrap');
|
||||
const arrow=document.getElementById('cprompt-arrow');
|
||||
const open=wrap.classList.toggle('visible');
|
||||
arrow.textContent=open?'▼':'▶';
|
||||
});
|
||||
|
||||
// ─── 인식 모드 ─────────────────────────────────────────────
|
||||
const modeDescs={
|
||||
text:'일반 텍스트와 글자를 인식합니다',
|
||||
structure:'표 구조를 감지하고 Excel로 저장합니다'
|
||||
};
|
||||
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=modeDescs[ocrMode]||'';
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// OCR — FILE & RUN
|
||||
// ════════════════════════════════════════════════════════════
|
||||
const ocrDrop=document.getElementById('ocr-drop'),ocrInput=document.getElementById('ocr-input');
|
||||
ocrInput.addEventListener('change',()=>setOcrFile(ocrInput.files[0]));
|
||||
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.getElementById('ocr-btn').addEventListener('click',async()=>{
|
||||
if(!ocrFile)return;
|
||||
document.getElementById('ocr-err').style.display='none';
|
||||
const isOllama=ocrEngine==='ollama';
|
||||
setOcrLoading(true,isOllama);
|
||||
const fd=new FormData();
|
||||
fd.append('file',ocrFile);
|
||||
fd.append('mode',ocrMode);
|
||||
fd.append('backend',ocrEngine);
|
||||
fd.append('ollama_model',ocrModel);
|
||||
fd.append('custom_prompt',document.getElementById('custom-prompt').value||'');
|
||||
try{
|
||||
const r=await api('POST','/api/ocr',fd);const d=await r.json();
|
||||
if(!r.ok)throw new Error(d.detail||'업로드 실패');
|
||||
ocrTaskId=d.task_id;
|
||||
pollTask(ocrTaskId,
|
||||
d=>setProg('ocr',d.progress||0,d.message||'처리 중...',isOllama),
|
||||
showOcrResult,
|
||||
e=>{document.getElementById('ocr-err').style.display='block';document.getElementById('ocr-err').textContent='⚠ '+e;setOcrLoading(false,isOllama)}
|
||||
);
|
||||
}catch(e){document.getElementById('ocr-err').style.display='block';document.getElementById('ocr-err').textContent='⚠ '+e.message;setOcrLoading(false,isOllama)}
|
||||
});
|
||||
|
||||
function setOcrLoading(on,isOllama=false){
|
||||
document.getElementById('ocr-btn').disabled=on;
|
||||
document.getElementById('ocr-prog').style.display=on?'block':'none';
|
||||
document.getElementById('ocr-wave').style.display=on?'flex':'none';
|
||||
const color=isOllama?'var(--purple)':'var(--accent)';
|
||||
document.getElementById('ocr-pfill').style.background=color;
|
||||
document.getElementById('ocr-ppct').style.color=color;
|
||||
document.querySelectorAll('#ocr-wave .wave-bar').forEach(b=>b.style.background=color);
|
||||
if(on)setProg('ocr',0,'준비 중...',isOllama);
|
||||
}
|
||||
|
||||
function showOcrResult(d){
|
||||
ocrOutputTxt=d.txt_file||null;ocrOutputXlsx=d.xlsx_file||null;
|
||||
const isOllama=d.backend==='ollama';
|
||||
const color=isOllama?'var(--purple)':'var(--accent)';
|
||||
|
||||
document.getElementById('ocr-mlines').textContent=(d.line_count||0)+'줄';
|
||||
document.getElementById('ocr-mmode').textContent=d.mode==='structure'?'구조분석':'텍스트';
|
||||
document.getElementById('ocr-mbackend').textContent=isOllama?`Ollama·${d.ollama_model||''}`:'PaddleOCR';
|
||||
document.getElementById('ocr-mbackend').style.color=color;
|
||||
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||'';
|
||||
|
||||
// 탭 버튼 색상 동기화
|
||||
document.querySelectorAll('#ocr-tabs .tab-btn').forEach(b=>{
|
||||
b.style.setProperty('--tw-color',color);
|
||||
b.addEventListener('click',()=>{if(b.classList.contains('active'))b.style.color=color;});
|
||||
});
|
||||
|
||||
// 줄별 신뢰도
|
||||
const ll=document.getElementById('ocr-linelist');ll.innerHTML='';
|
||||
(d.lines||[]).forEach(line=>{
|
||||
const conf=line.confidence||0,cls=conf>=0.9?'high':conf>=0.7?'mid':'low';
|
||||
const row=document.createElement('div');row.className='line-item';
|
||||
const confLabel=isOllama?'AI':''+Math.round(conf*100)+'%';
|
||||
row.innerHTML=`<div class="line-conf ${cls}">${confLabel}</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||[];
|
||||
if(tables.length===0){te.style.display='flex'}
|
||||
else{
|
||||
te.style.display='none';
|
||||
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">${toStyledTable(t.html||'')}</div>`;
|
||||
tl.appendChild(w);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('ocr-actions').style.display='flex';
|
||||
document.getElementById('ocr-dl-xlsx').style.display=ocrOutputXlsx?'inline-flex':'none';
|
||||
setOcrLoading(false,isOllama);
|
||||
document.getElementById('ocr-prog').style.display='none';
|
||||
document.getElementById('ocr-wave').style.display='none';
|
||||
}
|
||||
|
||||
function toStyledTable(html){return html.replace(/<table/g,'<table class="ocr-table"')}
|
||||
|
||||
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',resetOcr);
|
||||
|
||||
function resetOcr(){
|
||||
ocrFile=null;ocrInput.value='';ocrOutputTxt=null;ocrOutputXlsx=null;
|
||||
document.getElementById('ocr-info').style.display='none';
|
||||
document.getElementById('ocr-preview-wrap').style.display='none';
|
||||
document.getElementById('ocr-btn').disabled=true;
|
||||
document.getElementById('ocr-prog').style.display='none';
|
||||
document.getElementById('ocr-wave').style.display='none';
|
||||
document.getElementById('ocr-err').style.display='none';
|
||||
document.getElementById('ocr-meta').style.display='none';
|
||||
document.getElementById('ocr-tabs').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-actions').style.display='none';
|
||||
resetTabs('ocr-tabs');
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// RESULT TABS
|
||||
// ════════════════════════════════════════════════════════════
|
||||
document.addEventListener('click',e=>{
|
||||
if(!e.target.classList.contains('tab-btn'))return;
|
||||
const parent=e.target.closest('.result-tabs');
|
||||
parent.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
const panel=parent.closest('.panel');
|
||||
panel.querySelectorAll('.tab-content').forEach(c=>c.classList.remove('active'));
|
||||
const t=document.getElementById(e.target.dataset.tab);if(t)t.classList.add('active');
|
||||
});
|
||||
function resetTabs(id){const t=document.getElementById(id);if(!t)return;t.querySelectorAll('.tab-btn').forEach((b,i)=>b.classList.toggle('active',i===0));const p=t.closest('.panel');p.querySelectorAll('.tab-content').forEach((c,i)=>c.classList.toggle('active',i===0))}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// POLLING / UTILS
|
||||
// ════════════════════════════════════════════════════════════
|
||||
function pollTask(taskId,onProgress,onSuccess,onError){
|
||||
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();onProgress(d);
|
||||
if(d.state==='success'){clearInterval(t);onSuccess(d)}
|
||||
else if(d.state==='failure'){clearInterval(t);onError(d.message||'실패')}
|
||||
}catch{}
|
||||
},1500);
|
||||
}
|
||||
|
||||
async function dlFile(fn){
|
||||
if(!fn)return;
|
||||
try{const r=await api('GET','/api/download/'+fn);if(!r.ok)return;const b=await r.blob();const u=URL.createObjectURL(b);const a=document.createElement('a');a.href=u;a.download=fn;a.click();URL.revokeObjectURL(u)}catch{}
|
||||
}
|
||||
|
||||
function setProg(prefix,pct,msg,purple=false){
|
||||
document.getElementById(prefix+'-pfill').style.width=pct+'%';
|
||||
document.getElementById(prefix+'-pmsg').textContent=msg;
|
||||
document.getElementById(prefix+'-ppct').textContent=pct+'%';
|
||||
}
|
||||
|
||||
function showFileInfo(p,f){document.getElementById(p+'-info').style.display='block';document.getElementById(p+'-fname').textContent=f.name;document.getElementById(p+'-fsize').textContent=fmtBytes(f.size)}
|
||||
function fmtBytes(b){if(b<1024)return b+' B';if(b<1048576)return (b/1024).toFixed(1)+' KB';return (b/1048576).toFixed(1)+' MB'}
|
||||
function fmtDur(s){if(!s)return '—';return Math.floor(s/60)+'분 '+Math.floor(s%60)+'초'}
|
||||
function fmtTime(s){const m=Math.floor(s/60),ss=Math.floor(s%60);return String(m).padStart(2,'0')+':'+String(ss).padStart(2,'0')}
|
||||
function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||
async function copyText(text,btn){try{await navigator.clipboard.writeText(text);const o=btn.textContent;btn.textContent='복사됨 ✓';setTimeout(()=>btn.textContent=o,1500)}catch{}}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
checkAuth();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user