YoutubeScript / templates /index.html
Heebin Moon
Claude Opus 4.6
Switch default LLM to Gemini Flash-Lite and fix SSE for Cloudflare
97fa2d2
Raw History Blame Contribute Delete
60.1 kB
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YouTube Script Extractor</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #e1e1e1;
min-height: 100vh;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
}
h1 {
text-align: center;
font-size: 28px;
margin-bottom: 8px;
color: #fff;
}
.subtitle {
text-align: center;
color: #888;
margin-bottom: 40px;
font-size: 14px;
}
.card {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
}
.form-group { margin-bottom: 16px; }
label {
display: block;
font-size: 13px;
color: #aaa;
margin-bottom: 6px;
font-weight: 500;
}
input[type="text"], select {
width: 100%;
padding: 10px 14px;
background: #0f0f0f;
border: 1px solid #333;
border-radius: 8px;
color: #fff;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
input[type="text"]:focus, select:focus { border-color: #ff4444; }
.row { display: flex; gap: 12px; }
.row .form-group { flex: 1; }
.checkbox-group {
display: flex;
gap: 16px;
align-items: center;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
color: #e1e1e1;
font-size: 14px;
}
input[type="checkbox"] {
accent-color: #ff4444;
width: 16px;
height: 16px;
}
.btn {
width: 100%;
padding: 12px;
background: #ff4444;
color: #fff;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn:hover { background: #e03030; }
.btn:disabled {
background: #333;
color: #666;
cursor: not-allowed;
}
.mode-note {
font-size: 12px;
color: #666;
margin-top: 4px;
}
/* ========= ์ง„ํ–‰ ์ƒํƒœ ========= */
#progress-section { display: none; }
.steps { display: flex; flex-direction: column; gap: 0; }
.step {
padding: 16px 0;
border-bottom: 1px solid #222;
color: #555;
transition: color 0.3s;
}
.step:last-child { border-bottom: none; }
.step.active { color: #fff; }
.step.done { color: #4caf50; }
.step.error { color: #ff4444; }
.step-header {
display: flex;
align-items: center;
gap: 12px;
}
.step-icon {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
background: #222;
color: #555;
transition: all 0.3s;
}
.step.active .step-icon {
background: #ff4444;
color: #fff;
}
.step.done .step-icon {
background: #4caf50;
color: #fff;
}
.step-title { font-size: 14px; font-weight: 500; }
.step-percent {
margin-left: auto;
font-size: 13px;
font-weight: 600;
font-family: 'SF Mono', Menlo, monospace;
color: #555;
min-width: 40px;
text-align: right;
}
.step.active .step-percent { color: #ff4444; }
.step.done .step-percent { color: #4caf50; }
/* ํ”„๋กœ๊ทธ๋ ˆ์Šค ๋ฐ” */
.step-progress {
height: 4px;
background: #222;
border-radius: 2px;
margin: 10px 0 0 40px;
overflow: hidden;
}
.step-progress-bar {
height: 100%;
background: #ff4444;
border-radius: 2px;
width: 0%;
transition: width 0.4s ease;
}
.step.done .step-progress-bar {
background: #4caf50;
width: 100%;
}
.step-progress-bar.indeterminate {
width: 30%;
animation: indeterminate 1.5s ease-in-out infinite;
}
@keyframes indeterminate {
0% { margin-left: 0%; }
50% { margin-left: 70%; }
100% { margin-left: 0%; }
}
/* ์ƒ์„ธ ์ •๋ณด */
.step-detail {
font-size: 12px;
color: #555;
margin: 6px 0 0 40px;
min-height: 16px;
transition: color 0.3s;
}
.step.active .step-detail { color: #999; }
.step.done .step-detail { color: #4caf5099; }
/* ํƒ€์ด๋จธ + ํŒ */
.progress-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #222;
}
.elapsed {
font-size: 13px;
color: #888;
font-family: 'SF Mono', Menlo, monospace;
}
.tip {
font-size: 12px;
color: #666;
font-style: italic;
max-width: 60%;
text-align: right;
animation: fadeInOut 6s ease-in-out infinite;
}
@keyframes fadeInOut {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* ========= ๊ฒฐ๊ณผ ========= */
#result-section { display: none; }
.result-banner {
background: #1a2e1a;
border: 1px solid #2d5a2d;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 12px;
}
.result-banner-icon { font-size: 24px; flex-shrink: 0; }
.result-banner-text { flex: 1; }
.result-banner-title {
font-size: 15px;
font-weight: 600;
color: #4caf50;
margin-bottom: 4px;
}
.result-banner-info { font-size: 13px; color: #888; }
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.result-title { font-size: 16px; font-weight: 600; color: #fff; }
.result-lang {
font-size: 12px;
background: #333;
color: #aaa;
padding: 4px 10px;
border-radius: 12px;
}
.result-text {
background: #0f0f0f;
border-radius: 8px;
padding: 16px;
font-size: 14px;
line-height: 1.7;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
color: #ccc;
}
.download-buttons { display: flex; gap: 10px; margin-top: 16px; }
.btn-download {
flex: 1;
padding: 12px;
background: #1a3a1a;
border: 1px solid #2d5a2d;
border-radius: 8px;
color: #4caf50;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-align: center;
text-decoration: none;
transition: background 0.2s;
}
.btn-download:hover { background: #2d5a2d; color: #fff; }
.save-path {
font-size: 12px;
color: #666;
margin-top: 12px;
font-family: 'SF Mono', Menlo, monospace;
padding: 8px 12px;
background: #111;
border-radius: 6px;
}
.error-msg {
color: #ff4444;
padding: 12px;
font-size: 14px;
background: #2a1515;
border-radius: 8px;
border: 1px solid #4a2020;
}
.btn-new {
width: 100%;
padding: 10px;
background: transparent;
border: 1px solid #444;
border-radius: 8px;
color: #aaa;
font-size: 14px;
cursor: pointer;
margin-top: 12px;
transition: all 0.2s;
}
.btn-new:hover { border-color: #888; color: #fff; }
/* ========= LLM ์˜ต์…˜ (MD/๋ฒˆ์—ญ ๊ณต์šฉ) ========= */
.llm-options {
display: none;
margin-top: 12px;
padding: 16px;
background: #111;
border-radius: 8px;
border: 1px solid #2a2a2a;
}
.llm-options.visible { display: block; }
.llm-options .form-group { margin-bottom: 12px; }
.llm-options .form-group:last-child { margin-bottom: 0; }
.llm-select-desc {
font-size: 12px;
color: #888;
margin-top: 4px;
}
/* ========= ์„ค์ • ๋ฒ„ํŠผ ========= */
.llm-header { display: flex; gap: 8px; align-items: stretch; }
.llm-header > div { flex: 1; }
.btn-settings {
padding: 8px 14px;
background: #222;
border: 1px solid #333;
border-radius: 8px;
color: #aaa;
font-size: 16px;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
position: relative;
}
.btn-settings:hover { background: #333; color: #fff; border-color: #555; }
.settings-dot {
display: none;
position: absolute;
top: 4px; right: 4px;
width: 8px; height: 8px;
border-radius: 50%;
background: #4caf50;
}
.settings-dot.active { display: block; }
.workflow-summary {
font-size: 12px;
color: #4caf50;
margin-top: 6px;
line-height: 1.5;
}
/* ========= ์›Œํฌํ”Œ๋กœ์šฐ ์„ค์ • ๋ชจ๋‹ฌ ========= */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.modal {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 16px;
padding: 28px;
width: 540px;
max-width: 90vw;
max-height: 85vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.modal-header h3 { color: #fff; font-size: 18px; margin: 0; }
.modal-close {
background: none;
border: none;
color: #666;
font-size: 24px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: all 0.2s;
line-height: 1;
}
.modal-close:hover { background: #333; color: #fff; }
.modal-desc {
color: #888;
font-size: 13px;
margin-bottom: 20px;
line-height: 1.5;
}
.modal-step-row {
border: 1px solid #2a2a2a;
border-radius: 10px;
padding: 16px;
margin-bottom: 12px;
background: #111;
}
.modal-step-label {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.modal-step-name {
font-size: 14px;
font-weight: 600;
color: #e1e1e1;
}
.modal-step-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: #222;
color: #666;
}
.modal-step-badge.custom {
background: #1a3a1a;
color: #4caf50;
}
.modal-step-fields { display: flex; flex-direction: column; gap: 8px; }
.modal-step-fields select,
.modal-step-fields input[type="text"] {
width: 100%;
padding: 8px 12px;
background: #0f0f0f;
border: 1px solid #333;
border-radius: 6px;
color: #fff;
font-size: 13px;
outline: none;
}
.modal-step-fields select:focus,
.modal-step-fields input[type="text"]:focus { border-color: #ff4444; }
.modal-step-sub {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #222;
}
.modal-step-sub label {
font-size: 12px;
color: #888;
margin-bottom: 4px;
}
.modal-step-sub select,
.modal-step-sub input[type="text"] {
width: 100%;
padding: 6px 10px;
background: #0f0f0f;
border: 1px solid #333;
border-radius: 6px;
color: #fff;
font-size: 12px;
outline: none;
margin-bottom: 6px;
}
.modal-save-btn {
margin-top: 8px;
padding: 10px;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<h1>YouTube Script Extractor</h1>
<p class="subtitle">YouTube ์˜์ƒ์˜ ์Œ์„ฑ์„ ํ…์ŠคํŠธ๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค</p>
<div class="card" id="form-section">
<div class="form-group">
<label>YouTube URL</label>
<input type="text" id="url" placeholder="https://www.youtube.com/watch?v=..." onkeydown="if(event.key==='Enter') startTranscription()">
</div>
<div class="row">
<div class="form-group">
<label>๋ณ€ํ™˜ ๋ชจ๋“œ</label>
<select id="mode" onchange="toggleApiKey()">
<option value="api">Whisper API (๋น ๋ฅด๊ณ  ์ •ํ™•)</option>
<option value="local">๋กœ์ปฌ Whisper (๋ฌด๋ฃŒ)</option>
</select>
</div>
<div class="form-group" id="model-group" style="display:none;">
<label>๋ชจ๋ธ ํฌ๊ธฐ</label>
<select id="model_size">
<option value="tiny">tiny (39MB, ๊ฐ€์žฅ ๋น ๋ฆ„)</option>
<option value="base" selected>base (74MB, ๊ท ํ˜•)</option>
<option value="small">small (244MB, ์ข‹์Œ)</option>
<option value="medium">medium (769MB, ๋†’์Œ)</option>
<option value="large">large (1.5GB, ์ตœ๊ณ )</option>
</select>
</div>
</div>
<div class="form-group" id="api-key-group">
<label>OpenAI API Key</label>
<input type="text" id="api_key" placeholder="sk-...">
<p class="mode-note">์„œ๋ฒ„์— OPENAI_API_KEY ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์–ด ์žˆ์œผ๋ฉด ๋น„์›Œ๋‘ฌ๋„ ๋ฉ๋‹ˆ๋‹ค.</p>
</div>
<div class="form-group">
<label>์ถœ๋ ฅ ํ˜•์‹</label>
<div class="checkbox-group">
<label><input type="checkbox" id="fmt_txt" checked> TXT (ํ…์ŠคํŠธ)</label>
<label><input type="checkbox" id="fmt_srt" checked> SRT (์ž๋ง‰)</label>
<label><input type="checkbox" id="fmt_md" onchange="toggleLlmOptions()"> MD (AI ์ •๋ฆฌ)</label>
<label><input type="checkbox" id="fmt_translate" onchange="toggleLlmOptions()"> ๋ฒˆ์—ญ</label>
<label><input type="checkbox" id="fmt_keyframes" onchange="toggleLlmOptions()"> ํ‚คํ”„๋ ˆ์ž„ ๋ถ„์„</label>
</div>
</div>
<!-- LLM ์˜ต์…˜ (MD/๋ฒˆ์—ญ ๊ณต์šฉ) -->
<div class="llm-options" id="llm-options">
<div class="form-group">
<label>๊ธฐ๋ณธ LLM <span style="color:#666; font-weight:400;">(๋ชจ๋“  AI ๋‹จ๊ณ„์— ์ ์šฉ)</span></label>
<div class="llm-header">
<div>
<select id="llm-select" onchange="onLLMChange()">
<option value="">๋กœ๋”ฉ ์ค‘...</option>
</select>
</div>
<button type="button" class="btn-settings" id="btn-settings" onclick="openSettingsModal()" title="์›Œํฌํ”Œ๋กœ์šฐ ์„ค์ •">โš™<span class="settings-dot" id="settings-dot"></span></button>
</div>
<p class="llm-select-desc" id="llm-select-desc"></p>
<p class="workflow-summary" id="workflow-summary" style="display:none;"></p>
</div>
<div class="form-group" id="md-api-key-group" style="display:none;">
<label id="md-api-key-label">LLM API Key</label>
<input type="text" id="md_api_key" placeholder="API ํ‚ค ์ž…๋ ฅ...">
<p class="mode-note" id="md-api-key-note"></p>
</div>
<div class="form-group" id="md-ollama-group" style="display:none;">
<label>Ollama ๋ชจ๋ธ</label>
<input type="text" id="md_ollama_model" value="llama3.2" placeholder="llama3.2">
<p class="mode-note">ollama pull &lt;๋ชจ๋ธ๋ช…&gt; ์œผ๋กœ ๋จผ์ € ๋‹ค์šด๋กœ๋“œ ํ•„์š”</p>
</div>
<div class="form-group" id="translate-lang-group" style="display:none;">
<label>๋ฒˆ์—ญ ์–ธ์–ด</label>
<select id="translate-lang">
<option value="">๋กœ๋”ฉ ์ค‘...</option>
</select>
</div>
</div>
<div class="form-group">
<label>์ €์žฅ ๊ฒฝ๋กœ</label>
<input type="text" id="output_dir" placeholder="./output" value="./output">
<p class="mode-note">๋น„์›Œ๋‘๋ฉด ๊ธฐ๋ณธ ๊ฒฝ๋กœ(./output)์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. ์ ˆ๋Œ€ ๊ฒฝ๋กœ๋„ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค (์˜ˆ: /Users/์ด๋ฆ„/Desktop)</p>
</div>
<button class="btn" id="submit-btn" onclick="startTranscription()">์Šคํฌ๋ฆฝํŠธ ์ถ”์ถœ ์‹œ์ž‘</button>
</div>
<!-- ์ง„ํ–‰ ์ƒํƒœ -->
<div class="card" id="progress-section">
<div class="steps">
<div class="step" id="step-download" data-step="download">
<div class="step-header">
<div class="step-icon">1</div>
<div class="step-title">์˜ค๋””์˜ค ๋‹ค์šด๋กœ๋“œ</div>
<div class="step-percent" id="pct-download"></div>
</div>
<div class="step-progress"><div class="step-progress-bar" id="bar-download"></div></div>
<div class="step-detail" id="detail-download">YouTube์—์„œ ์˜ค๋””์˜ค๋ฅผ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค</div>
</div>
<div class="step" id="step-keyframe_extract" data-step="keyframe_extract" style="display:none;">
<div class="step-header">
<div class="step-icon">2</div>
<div class="step-title">ํ‚คํ”„๋ ˆ์ž„ ์ถ”์ถœ</div>
<div class="step-percent" id="pct-keyframe_extract"></div>
</div>
<div class="step-progress"><div class="step-progress-bar" id="bar-keyframe_extract"></div></div>
<div class="step-detail" id="detail-keyframe_extract">์˜์ƒ์—์„œ ์ฃผ์š” ์žฅ๋ฉด์„ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค</div>
</div>
<div class="step" id="step-keyframe_analysis" data-step="keyframe_analysis" style="display:none;">
<div class="step-header">
<div class="step-icon">3</div>
<div class="step-title">ํ‚คํ”„๋ ˆ์ž„ ๋ถ„์„</div>
<div class="step-percent" id="pct-keyframe_analysis"></div>
</div>
<div class="step-progress"><div class="step-progress-bar" id="bar-keyframe_analysis"></div></div>
<div class="step-detail" id="detail-keyframe_analysis">Vision AI๋กœ ํ™”๋ฉด์„ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค</div>
</div>
<div class="step" id="step-transcribe" data-step="transcribe">
<div class="step-header">
<div class="step-icon">2</div>
<div class="step-title">์Œ์„ฑ ์ธ์‹</div>
<div class="step-percent" id="pct-transcribe"></div>
</div>
<div class="step-progress"><div class="step-progress-bar" id="bar-transcribe"></div></div>
<div class="step-detail" id="detail-transcribe">Whisper๋กœ ์Œ์„ฑ์„ ํ…์ŠคํŠธ๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค</div>
</div>
<div class="step" id="step-format" data-step="format" style="display:none;">
<div class="step-header">
<div class="step-icon">3</div>
<div class="step-title">AI ๋งˆํฌ๋‹ค์šด ์ •๋ฆฌ</div>
<div class="step-percent" id="pct-format"></div>
</div>
<div class="step-progress"><div class="step-progress-bar" id="bar-format"></div></div>
<div class="step-detail" id="detail-format">LLM์œผ๋กœ ํ…์ŠคํŠธ๋ฅผ ๊ตฌ์กฐํ™”ํ•ฉ๋‹ˆ๋‹ค</div>
</div>
<div class="step" id="step-translate" data-step="translate" style="display:none;">
<div class="step-header">
<div class="step-icon">4</div>
<div class="step-title">๋ฒˆ์—ญ</div>
<div class="step-percent" id="pct-translate"></div>
</div>
<div class="step-progress"><div class="step-progress-bar" id="bar-translate"></div></div>
<div class="step-detail" id="detail-translate">์„ ํƒํ•œ ์–ธ์–ด๋กœ ๋ฒˆ์—ญํ•ฉ๋‹ˆ๋‹ค</div>
</div>
<div class="step" id="step-save" data-step="save">
<div class="step-header">
<div class="step-icon" id="icon-save">3</div>
<div class="step-title">ํŒŒ์ผ ์ €์žฅ</div>
<div class="step-percent" id="pct-save"></div>
</div>
<div class="step-progress"><div class="step-progress-bar" id="bar-save"></div></div>
<div class="step-detail" id="detail-save">๊ฒฐ๊ณผ ํŒŒ์ผ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค</div>
</div>
</div>
<div class="progress-footer">
<div class="elapsed" id="elapsed"></div>
<div class="tip" id="tip"></div>
</div>
</div>
<!-- ๊ฒฐ๊ณผ -->
<div class="card" id="result-section">
<div class="result-banner">
<div class="result-banner-icon">&#10003;</div>
<div class="result-banner-text">
<div class="result-banner-title">์Šคํฌ๋ฆฝํŠธ ์ถ”์ถœ ์™„๋ฃŒ</div>
<div class="result-banner-info" id="result-info"></div>
</div>
</div>
<div class="result-header">
<span class="result-title" id="result-title"></span>
<span class="result-lang" id="result-lang"></span>
</div>
<div class="result-text" id="result-text"></div>
<div class="download-buttons" id="download-buttons"></div>
<div class="save-path" id="save-path"></div>
<button class="btn-new" onclick="resetForm()">์ƒˆ ์˜์ƒ ์ถ”์ถœํ•˜๊ธฐ</button>
</div>
<!-- ์—๋Ÿฌ -->
<div class="card" id="error-section" style="display:none;">
<div class="error-msg" id="error-msg"></div>
<button class="btn-new" onclick="resetForm()">๋‹ค์‹œ ์‹œ๋„ํ•˜๊ธฐ</button>
</div>
</div>
<!-- ์›Œํฌํ”Œ๋กœ์šฐ ์„ค์ • ๋ชจ๋‹ฌ -->
<div class="modal-overlay" id="settings-modal" style="display:none;" onclick="if(event.target===this)closeSettingsModal()">
<div class="modal">
<div class="modal-header">
<h3>์›Œํฌํ”Œ๋กœ์šฐ ์„ค์ •</h3>
<button class="modal-close" onclick="closeSettingsModal()">&times;</button>
</div>
<p class="modal-desc">๊ฐ ๋‹จ๊ณ„๋ณ„๋กœ ๋‹ค๋ฅธ LLM์„ ์ง€์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.<br>๋น„์›Œ๋‘๋ฉด ๊ธฐ๋ณธ LLM(<strong id="modal-global-llm-name" style="color:#e1e1e1;">โ€”</strong>)์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.</p>
<!-- MD ์ •๋ฆฌ -->
<div class="modal-step-row">
<div class="modal-step-label">
<span class="modal-step-name">MD ์ •๋ฆฌ</span>
<span class="modal-step-badge" id="badge-format">๊ธฐ๋ณธ LLM</span>
</div>
<div class="modal-step-fields">
<select id="modal-format-llm" onchange="onModalLLMChange('format')">
<option value="">๊ธฐ๋ณธ LLM ์‚ฌ์šฉ</option>
</select>
<input type="text" id="modal-format-api-key" placeholder="API Key (๋น„์›Œ๋‘๋ฉด ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์‚ฌ์šฉ)" style="display:none;">
</div>
</div>
<!-- ๋ฒˆ์—ญ -->
<div class="modal-step-row">
<div class="modal-step-label">
<span class="modal-step-name">๋ฒˆ์—ญ</span>
<span class="modal-step-badge" id="badge-translate">๊ธฐ๋ณธ LLM</span>
</div>
<div class="modal-step-fields">
<select id="modal-translate-llm" onchange="onModalLLMChange('translate')">
<option value="">๊ธฐ๋ณธ LLM ์‚ฌ์šฉ</option>
</select>
<input type="text" id="modal-translate-api-key" placeholder="API Key (๋น„์›Œ๋‘๋ฉด ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์‚ฌ์šฉ)" style="display:none;">
</div>
</div>
<!-- ํ‚คํ”„๋ ˆ์ž„ ๋ถ„์„ -->
<div class="modal-step-row">
<div class="modal-step-label">
<span class="modal-step-name">ํ‚คํ”„๋ ˆ์ž„ ๋ถ„์„</span>
<span class="modal-step-badge" id="badge-keyframe">๊ธฐ๋ณธ LLM</span>
</div>
<div class="modal-step-fields">
<select id="modal-keyframe-llm" onchange="onModalLLMChange('keyframe')">
<option value="">๊ธฐ๋ณธ LLM ์‚ฌ์šฉ (Vision ์ง€์› ํ•„์š”)</option>
</select>
<input type="text" id="modal-keyframe-api-key" placeholder="API Key (๋น„์›Œ๋‘๋ฉด ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์‚ฌ์šฉ)" style="display:none;">
</div>
<div class="modal-step-sub">
<label>์ถ”์ถœ ๋ฐฉ์‹</label>
<select id="keyframe-method" onchange="onKeyframeMethodChange()">
<option value="scene">์žฅ๋ฉด ์ „ํ™˜ ๊ฐ์ง€ (์ถ”์ฒœ)</option>
<option value="interval">๊ณ ์ • ๊ฐ„๊ฒฉ</option>
</select>
<div id="keyframe-interval-group" style="display:none;">
<label>๊ฐ„๊ฒฉ (์ดˆ)</label>
<input type="text" id="keyframe-interval" value="30" placeholder="30">
</div>
</div>
</div>
<button class="btn modal-save-btn" onclick="saveModalSettings()">์„ค์ • ์ €์žฅ</button>
</div>
</div>
<script>
let timerInterval = null;
let startTime = null;
let tipInterval = null;
let selectedLLM = 'gemini-flash-lite';
let llmModels = [];
let languages = [];
let llmLoaded = false;
let langLoaded = false;
let visionModels = [];
let visionLoaded = false;
// ๋‹จ๊ณ„๋ณ„ LLM ์„ค์ • (๋ชจ๋‹ฌ์—์„œ ๊ด€๋ฆฌ)
let perStepConfig = {
format: { llm: '', apiKey: '' },
translate: { llm: '', apiKey: '' },
keyframe: { llm: '', apiKey: '', method: 'scene', interval: 30 },
};
const TIPS = [
"Whisper๋Š” 90๊ฐœ ์ด์ƒ์˜ ์–ธ์–ด๋ฅผ ์ž๋™์œผ๋กœ ๊ฐ์ง€ํ•ฉ๋‹ˆ๋‹ค",
"SRT ํŒŒ์ผ์€ ๋Œ€๋ถ€๋ถ„์˜ ๋™์˜์ƒ ํ”Œ๋ ˆ์ด์–ด์—์„œ ์ž๋ง‰์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค",
"์˜์ƒ์ด ๊ธธ์ˆ˜๋ก ๋ณ€ํ™˜ ์‹œ๊ฐ„์ด ์˜ค๋ž˜ ๊ฑธ๋ฆฝ๋‹ˆ๋‹ค",
"API ๋ชจ๋“œ๋Š” ๋กœ์ปฌ ๋ชจ๋“œ๋ณด๋‹ค ๋ณดํ†ต 5~10๋ฐฐ ๋น ๋ฆ…๋‹ˆ๋‹ค",
"TXT ํŒŒ์ผ์€ ํ…์ŠคํŠธ ํŽธ์ง‘๊ธฐ์—์„œ ๋ฐ”๋กœ ์—ด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค",
"์ถ”์ถœ๋œ ์Šคํฌ๋ฆฝํŠธ๋Š” ./output ํด๋”์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค",
"๋กœ์ปฌ ๋ชจ๋“œ๋Š” ์ธํ„ฐ๋„ท ์—†์ด๋„ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค",
"Whisper API ๋น„์šฉ์€ 1๋ถ„๋‹น ์•ฝ $0.006์ž…๋‹ˆ๋‹ค",
"MD ์ถœ๋ ฅ์€ LLM์ด ํ…์ŠคํŠธ๋ฅผ ๊น”๋”ํ•˜๊ฒŒ ๊ตฌ์กฐํ™”ํ•ฉ๋‹ˆ๋‹ค",
"Ollama๋Š” ๋กœ์ปฌ์—์„œ ๋ฌด๋ฃŒ๋กœ LLM์„ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค",
"๋ฒˆ์—ญ ๊ธฐ๋Šฅ์œผ๋กœ ์›๋ณธ๊ณผ ๋ฒˆ์—ญ๋ณธ์„ ๋™์‹œ์— ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค",
"ํ‚คํ”„๋ ˆ์ž„ ๋ถ„์„์œผ๋กœ ๋ฐœํ‘œ ์Šฌ๋ผ์ด๋“œ๋‚˜ ํ™”๋ฉด ๋‚ด์šฉ์„ MD์— ํฌํ•จํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค",
"์›Œํฌํ”Œ๋กœ์šฐ ์„ค์ •์—์„œ ๋‹จ๊ณ„๋ณ„๋กœ ๋‹ค๋ฅธ LLM์„ ์ง€์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค",
"ํ‚คํ”„๋ ˆ์ž„ ๋ถ„์„์— Gemini Flash๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋ฌด๋ฃŒ์ž…๋‹ˆ๋‹ค",
];
// ๋‹ค์šด๋กœ๋“œ ๋ฒ„ํŠผ ๋ผ๋ฒจ ๋งคํ•‘
const FMT_LABELS = {
'txt': 'TXT',
'srt': 'SRT',
'md': 'MD',
};
let STEP_ORDER = ['download', 'transcribe', 'save'];
function getStepOrder() {
const useMd = document.getElementById('fmt_md').checked;
const useTranslate = document.getElementById('fmt_translate').checked;
const useKeyframes = document.getElementById('fmt_keyframes').checked;
const order = ['download'];
if (useKeyframes) {
order.push('keyframe_extract');
order.push('keyframe_analysis');
}
order.push('transcribe');
if (useMd) order.push('format');
if (useTranslate) order.push('translate');
order.push('save');
return order;
}
function needsLLM() {
return document.getElementById('fmt_md').checked ||
document.getElementById('fmt_translate').checked ||
document.getElementById('fmt_keyframes').checked;
}
function toggleApiKey() {
const mode = document.getElementById('mode').value;
document.getElementById('api-key-group').style.display = mode === 'api' ? '' : 'none';
document.getElementById('model-group').style.display = mode === 'local' ? '' : 'none';
}
function toggleLlmOptions() {
const opts = document.getElementById('llm-options');
const translateChecked = document.getElementById('fmt_translate').checked;
const keyframesChecked = document.getElementById('fmt_keyframes').checked;
if (needsLLM()) {
opts.classList.add('visible');
if (!llmLoaded) loadLLMModels();
if (translateChecked && !langLoaded) loadLanguages();
if (keyframesChecked && !visionLoaded) loadVisionModels();
} else {
opts.classList.remove('visible');
}
// ๋ฒˆ์—ญ ์–ธ์–ด ์„ ํƒ ํ‘œ์‹œ/์ˆจ๊ธฐ๊ธฐ
document.getElementById('translate-lang-group').style.display =
translateChecked ? '' : 'none';
}
async function loadLLMModels() {
try {
const res = await fetch('/api/llm-models?sort=price');
const data = await res.json();
llmModels = data.models;
llmLoaded = true;
renderLLMDropdown();
} catch (e) {
console.error('LLM ๋ชจ๋ธ ๋กœ๋”ฉ ์‹คํŒจ:', e);
}
}
async function loadLanguages() {
try {
const res = await fetch('/api/languages');
const data = await res.json();
languages = data.languages;
langLoaded = true;
renderLanguageDropdown();
} catch (e) {
console.error('์–ธ์–ด ๋ชฉ๋ก ๋กœ๋”ฉ ์‹คํŒจ:', e);
}
}
function renderLLMDropdown() {
const select = document.getElementById('llm-select');
select.innerHTML = '';
llmModels.forEach(model => {
const opt = document.createElement('option');
opt.value = model.id;
opt.textContent = model.version
? `${model.name} [${model.version}]`
: model.name;
if (model.id === selectedLLM) opt.selected = true;
select.appendChild(opt);
});
onLLMChange();
}
function renderLanguageDropdown() {
const select = document.getElementById('translate-lang');
select.innerHTML = '';
languages.forEach(lang => {
const opt = document.createElement('option');
opt.value = lang.code;
opt.textContent = `${lang.name} (${lang.code})`;
select.appendChild(opt);
});
}
function onLLMChange() {
const select = document.getElementById('llm-select');
selectedLLM = select.value;
const model = llmModels.find(m => m.id === selectedLLM);
if (!model) return;
// ์„ค๋ช… ํ…์ŠคํŠธ ์—…๋ฐ์ดํŠธ
document.getElementById('llm-select-desc').textContent = model.description;
// API ํ‚ค / Ollama ๋ชจ๋ธ UI ์—…๋ฐ์ดํŠธ
const keyGroup = document.getElementById('md-api-key-group');
const ollamaGroup = document.getElementById('md-ollama-group');
if (selectedLLM === 'ollama') {
keyGroup.style.display = 'none';
ollamaGroup.style.display = '';
} else if (model.needs_key) {
keyGroup.style.display = '';
ollamaGroup.style.display = 'none';
const envKey = model.env_key || '';
document.getElementById('md-api-key-label').textContent = model.name + ' API Key';
document.getElementById('md-api-key-note').textContent =
envKey ? `์„œ๋ฒ„์— ${envKey} ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์–ด ์žˆ์œผ๋ฉด ๋น„์›Œ๋‘ฌ๋„ ๋ฉ๋‹ˆ๋‹ค.` : '';
} else {
keyGroup.style.display = 'none';
ollamaGroup.style.display = 'none';
}
// ์›Œํฌํ”Œ๋กœ์šฐ ์š”์•ฝ ์—…๋ฐ์ดํŠธ (๊ธฐ๋ณธ LLM ๋ณ€๊ฒฝ ์‹œ "๊ธฐ๋ณธ LLM ์‚ฌ์šฉ" ๋ฑƒ์ง€๋„ ๊ฐฑ์‹ )
updateWorkflowSummary();
}
function startTimer() {
startTime = Date.now();
timerInterval = setInterval(() => {
const sec = Math.floor((Date.now() - startTime) / 1000);
const min = Math.floor(sec / 60);
const s = sec % 60;
document.getElementById('elapsed').textContent =
min > 0 ? `${min}๋ถ„ ${s}์ดˆ ๊ฒฝ๊ณผ` : `${s}์ดˆ ๊ฒฝ๊ณผ`;
}, 1000);
}
function stopTimer() {
if (timerInterval) clearInterval(timerInterval);
if (tipInterval) clearInterval(tipInterval);
}
function startTips() {
let tipIdx = Math.floor(Math.random() * TIPS.length);
const tipEl = document.getElementById('tip');
tipEl.textContent = TIPS[tipIdx];
tipInterval = setInterval(() => {
tipIdx = (tipIdx + 1) % TIPS.length;
tipEl.style.opacity = '0';
setTimeout(() => {
tipEl.textContent = TIPS[tipIdx];
tipEl.style.opacity = '';
}, 300);
}, 6000);
}
function updateStep(step, percent, detail) {
const stepEl = document.getElementById('step-' + step);
const barEl = document.getElementById('bar-' + step);
const pctEl = document.getElementById('pct-' + step);
const detailEl = document.getElementById('detail-' + step);
if (!stepEl) return;
// ํ˜„์žฌ ์Šคํ…์„ active๋กœ
const stepIdx = STEP_ORDER.indexOf(step);
STEP_ORDER.forEach((s, i) => {
const el = document.getElementById('step-' + s);
if (!el) return;
const icon = el.querySelector('.step-icon');
if (i < stepIdx) {
el.className = 'step done';
icon.innerHTML = '&#10003;';
} else if (i === stepIdx) {
el.className = 'step active';
icon.textContent = String(i + 1);
}
});
// ํ”„๋กœ๊ทธ๋ ˆ์Šค ๋ฐ”
if (percent < 0) {
barEl.className = 'step-progress-bar indeterminate';
barEl.style.width = '';
pctEl.textContent = '';
} else {
barEl.className = 'step-progress-bar';
barEl.style.width = percent + '%';
pctEl.textContent = percent + '%';
}
if (detail) {
detailEl.textContent = detail;
}
if (percent >= 100) {
stepEl.className = 'step done';
stepEl.querySelector('.step-icon').innerHTML = '&#10003;';
pctEl.textContent = '100%';
}
}
function setAllStepsDone() {
STEP_ORDER.forEach(step => {
const el = document.getElementById('step-' + step);
if (!el) return;
el.className = 'step done';
el.querySelector('.step-icon').innerHTML = '&#10003;';
document.getElementById('bar-' + step).style.width = '100%';
document.getElementById('bar-' + step).className = 'step-progress-bar';
document.getElementById('pct-' + step).textContent = '100%';
});
}
function resetForm() {
document.getElementById('form-section').style.display = 'block';
document.getElementById('progress-section').style.display = 'none';
document.getElementById('result-section').style.display = 'none';
document.getElementById('error-section').style.display = 'none';
document.getElementById('submit-btn').disabled = false;
document.getElementById('submit-btn').textContent = '์Šคํฌ๋ฆฝํŠธ ์ถ”์ถœ ์‹œ์ž‘';
stopTimer();
document.getElementById('elapsed').textContent = '';
document.getElementById('tip').textContent = '';
// ์„ ํƒ์  ์Šคํ… ์ˆจ๊ธฐ๊ธฐ
document.getElementById('step-format').style.display = 'none';
document.getElementById('step-translate').style.display = 'none';
document.getElementById('step-keyframe_extract').style.display = 'none';
document.getElementById('step-keyframe_analysis').style.display = 'none';
const allSteps = ['download', 'keyframe_extract', 'keyframe_analysis', 'transcribe', 'format', 'translate', 'save'];
allSteps.forEach((step, i) => {
const el = document.getElementById('step-' + step);
if (!el) return;
el.className = 'step';
el.querySelector('.step-icon').textContent = String(i + 1);
document.getElementById('bar-' + step).style.width = '0%';
document.getElementById('bar-' + step).className = 'step-progress-bar';
document.getElementById('pct-' + step).textContent = '';
});
document.getElementById('detail-download').textContent = 'YouTube์—์„œ ์˜ค๋””์˜ค๋ฅผ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค';
document.getElementById('detail-keyframe_extract').textContent = '์˜์ƒ์—์„œ ์ฃผ์š” ์žฅ๋ฉด์„ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค';
document.getElementById('detail-keyframe_analysis').textContent = 'Vision AI๋กœ ํ™”๋ฉด์„ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค';
document.getElementById('detail-transcribe').textContent = 'Whisper๋กœ ์Œ์„ฑ์„ ํ…์ŠคํŠธ๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค';
document.getElementById('detail-format').textContent = 'LLM์œผ๋กœ ํ…์ŠคํŠธ๋ฅผ ๊ตฌ์กฐํ™”ํ•ฉ๋‹ˆ๋‹ค';
document.getElementById('detail-translate').textContent = '์„ ํƒํ•œ ์–ธ์–ด๋กœ ๋ฒˆ์—ญํ•ฉ๋‹ˆ๋‹ค';
document.getElementById('detail-save').textContent = '๊ฒฐ๊ณผ ํŒŒ์ผ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค';
}
function getDownloadLabel(fmt) {
// txt_ko โ†’ "TXT (ํ•œ๊ตญ์–ด)", md_zhcn โ†’ "MD (ไธญๆ–‡(็ฎ€ไฝ“))"
if (fmt.includes('_')) {
const parts = fmt.split('_');
const baseFmt = parts[0].toUpperCase();
const langCode = parts.slice(1).join('_');
// ์–ธ์–ด ์ฝ”๋“œ๋ฅผ ์›๋ž˜ ํ˜•ํƒœ๋กœ ๋ณต์› ์‹œ๋„ (zhcn โ†’ zh-CN ๋“ฑ)
const langEntry = languages.find(l =>
l.code.replace('-', '').toLowerCase() === langCode.toLowerCase()
);
const langName = langEntry ? langEntry.name : langCode.toUpperCase();
return `${baseFmt} (${langName})`;
}
return (FMT_LABELS[fmt] || fmt.toUpperCase());
}
async function startTranscription() {
const url = document.getElementById('url').value.trim();
if (!url) { alert('YouTube URL์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'); return; }
const mode = document.getElementById('mode').value;
const apiKey = document.getElementById('api_key').value.trim();
const modelSize = document.getElementById('model_size').value;
const outputDir = document.getElementById('output_dir').value.trim() || './output';
const formats = [];
if (document.getElementById('fmt_txt').checked) formats.push('txt');
if (document.getElementById('fmt_srt').checked) formats.push('srt');
if (document.getElementById('fmt_md').checked) formats.push('md');
const useTranslate = document.getElementById('fmt_translate').checked;
const translateLang = useTranslate ? document.getElementById('translate-lang').value : '';
const enableKeyframes = document.getElementById('fmt_keyframes').checked;
if (formats.length === 0 && !useTranslate) {
alert('์ถœ๋ ฅ ํ˜•์‹์„ ํ•˜๋‚˜ ์ด์ƒ ์„ ํƒํ•ด์ฃผ์„ธ์š”.');
return;
}
// LLM ํŒŒ๋ผ๋ฏธํ„ฐ (MD ๋˜๋Š” ๋ฒˆ์—ญ ์‚ฌ์šฉ ์‹œ)
const useLlm = needsLLM();
const mdLlm = useLlm ? selectedLLM : '';
const mdApiKey = useLlm ? document.getElementById('md_api_key').value.trim() : '';
const mdOllamaModel = useLlm ? document.getElementById('md_ollama_model').value.trim() || 'llama3.2' : 'llama3.2';
// STEP_ORDER๋ฅผ ๋™์ ์œผ๋กœ ์„ค์ •
STEP_ORDER = getStepOrder();
// ์„ ํƒ์  ์Šคํ… ํ‘œ์‹œ/์ˆจ๊ธฐ๊ธฐ + ์•„์ด์ฝ˜ ๋ฒˆํ˜ธ ์กฐ์ •
const formatStep = document.getElementById('step-format');
const translateStep = document.getElementById('step-translate');
const keyExtStep = document.getElementById('step-keyframe_extract');
const keyAnalStep = document.getElementById('step-keyframe_analysis');
const useMd = formats.includes('md');
formatStep.style.display = useMd ? '' : 'none';
translateStep.style.display = useTranslate ? '' : 'none';
keyExtStep.style.display = enableKeyframes ? '' : 'none';
keyAnalStep.style.display = enableKeyframes ? '' : 'none';
// ์Šคํ… ๋ฒˆํ˜ธ ์žฌ๋ฐฐ์ •
STEP_ORDER.forEach((s, i) => {
const el = document.getElementById('step-' + s);
if (el) el.querySelector('.step-icon').textContent = String(i + 1);
});
const btn = document.getElementById('submit-btn');
btn.disabled = true;
btn.textContent = '์ฒ˜๋ฆฌ ์ค‘...';
document.getElementById('form-section').style.display = 'none';
document.getElementById('progress-section').style.display = 'block';
document.getElementById('result-section').style.display = 'none';
document.getElementById('error-section').style.display = 'none';
startTimer();
startTips();
try {
const res = await fetch('/api/transcribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url, mode, api_key: apiKey, model_size: modelSize,
formats, output_dir: outputDir,
md_llm: mdLlm, md_api_key: mdApiKey, md_ollama_model: mdOllamaModel,
translate_lang: translateLang,
// Per-step LLM overrides (๋ชจ๋‹ฌ ์„ค์ •)
format_llm: perStepConfig.format.llm,
format_api_key: perStepConfig.format.apiKey,
translate_llm: perStepConfig.translate.llm,
translate_api_key: perStepConfig.translate.apiKey,
keyframe_llm: perStepConfig.keyframe.llm,
keyframe_api_key: perStepConfig.keyframe.apiKey,
// ํ‚คํ”„๋ ˆ์ž„ ์˜ต์…˜
enable_keyframes: enableKeyframes,
keyframe_method: perStepConfig.keyframe.method,
keyframe_interval: parseInt(perStepConfig.keyframe.interval) || 30,
}),
});
const data = await res.json();
if (data.error) {
showError(data.error);
return;
}
const jobId = data.job_id;
let pollSeen = 0;
const pollInterval = setInterval(async () => {
try {
const res = await fetch(`/api/status/${jobId}`);
const job = await res.json();
if (job.error && !job.progress) {
clearInterval(pollInterval);
stopTimer();
showError(job.error);
return;
}
while (pollSeen < job.progress.length) {
const msg = job.progress[pollSeen];
if (msg.step && msg.percent !== undefined) {
updateStep(msg.step, msg.percent, msg.detail || '');
}
pollSeen++;
}
if (job.status === 'completed') {
clearInterval(pollInterval);
stopTimer();
setAllStepsDone();
setTimeout(() => showResult(job.result), 500);
} else if (job.status === 'error') {
clearInterval(pollInterval);
stopTimer();
showError(job.error);
}
} catch (err) {
clearInterval(pollInterval);
stopTimer();
showError('์„œ๋ฒ„ ์—ฐ๊ฒฐ์ด ๋Š์–ด์กŒ์Šต๋‹ˆ๋‹ค. ์„œ๋ฒ„๊ฐ€ ์‹คํ–‰ ์ค‘์ธ์ง€ ํ™•์ธํ•ด์ฃผ์„ธ์š”.');
}
}, 1000);
} catch (err) {
stopTimer();
showError(`์š”์ฒญ ์‹คํŒจ: ${err.message}`);
}
}
function showError(message) {
document.getElementById('progress-section').style.display = 'none';
document.getElementById('error-section').style.display = 'block';
document.getElementById('error-msg').textContent = message;
}
function showResult(result) {
document.getElementById('progress-section').style.display = 'none';
document.getElementById('result-section').style.display = 'block';
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const min = Math.floor(elapsed / 60);
const sec = elapsed % 60;
const timeStr = min > 0 ? `${min}๋ถ„ ${sec}์ดˆ` : `${sec}์ดˆ`;
document.getElementById('result-info').textContent =
`๊ฐ์ง€ ์–ธ์–ด: ${result.language} | ์†Œ์š” ์‹œ๊ฐ„: ${timeStr}`;
document.getElementById('result-title').textContent = result.title;
document.getElementById('result-lang').textContent = result.language;
document.getElementById('result-text').textContent = result.text;
const downloadBtns = document.getElementById('download-buttons');
downloadBtns.innerHTML = '';
const filenames = [];
const dirParam = result.output_dir_raw ? `?dir=${encodeURIComponent(result.output_dir_raw)}` : '';
for (const [fmt, filename] of Object.entries(result.files)) {
filenames.push(filename);
const a = document.createElement('a');
a.href = `/api/download/${encodeURIComponent(filename)}${dirParam}`;
a.className = 'btn-download';
a.download = filename;
a.textContent = `${getDownloadLabel(fmt)} ๋‹ค์šด๋กœ๋“œ`;
downloadBtns.appendChild(a);
}
const dir = result.output_dir || './output';
document.getElementById('save-path').textContent =
'์ €์žฅ ์œ„์น˜: ' + filenames.map(f => dir + '/' + f).join(', ');
}
// ========= Vision ๋ชจ๋ธ ๋กœ๋”ฉ =========
async function loadVisionModels() {
try {
const res = await fetch('/api/vision-models?sort=price');
const data = await res.json();
visionModels = data.models;
visionLoaded = true;
} catch (e) {
console.error('Vision ๋ชจ๋ธ ๋กœ๋”ฉ ์‹คํŒจ:', e);
}
}
// ========= ์›Œํฌํ”Œ๋กœ์šฐ ์„ค์ • ๋ชจ๋‹ฌ =========
function openSettingsModal() {
// ๋ชจ๋‹ฌ ์—ด๊ธฐ ์ „ ๋ชจ๋ธ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ ํ™•์ธ
if (!llmLoaded) loadLLMModels().then(() => populateModalDropdowns());
if (!visionLoaded) loadVisionModels().then(() => populateModalDropdowns());
populateModalDropdowns();
restoreModalFromConfig();
// ๊ธฐ๋ณธ LLM ์ด๋ฆ„ ํ‘œ์‹œ
const globalModel = llmModels.find(m => m.id === selectedLLM);
document.getElementById('modal-global-llm-name').textContent =
globalModel ? globalModel.name : selectedLLM;
document.getElementById('settings-modal').style.display = 'flex';
}
function closeSettingsModal() {
document.getElementById('settings-modal').style.display = 'none';
}
function populateModalDropdowns() {
const globalModel = llmModels.find(m => m.id === selectedLLM);
const globalName = globalModel ? globalModel.name : '๊ธฐ๋ณธ LLM';
// MD ์ •๋ฆฌ / ๋ฒˆ์—ญ ๋“œ๋กญ๋‹ค์šด (์ „์ฒด LLM)
['format', 'translate'].forEach(step => {
const select = document.getElementById(`modal-${step}-llm`);
const currentVal = select.value;
select.innerHTML = `<option value="">๊ธฐ๋ณธ LLM ์‚ฌ์šฉ (${globalName})</option>`;
llmModels.forEach(model => {
const opt = document.createElement('option');
opt.value = model.id;
opt.textContent = model.version
? `${model.name} [${model.version}]`
: model.name;
select.appendChild(opt);
});
select.value = currentVal || '';
});
// ํ‚คํ”„๋ ˆ์ž„ ๋“œ๋กญ๋‹ค์šด (Vision ์ง€์› ๋ชจ๋ธ๋งŒ)
const kfSelect = document.getElementById('modal-keyframe-llm');
const kfVal = kfSelect.value;
const isGlobalVision = globalModel && globalModel.supports_vision !== false;
kfSelect.innerHTML = isGlobalVision
? `<option value="">๊ธฐ๋ณธ LLM ์‚ฌ์šฉ (${globalName})</option>`
: `<option value="">์„ ํƒ ํ•„์š” (๊ธฐ๋ณธ LLM์ด Vision ๋ฏธ์ง€์›)</option>`;
visionModels.forEach(model => {
const opt = document.createElement('option');
opt.value = model.id;
opt.textContent = model.version
? `${model.name} [${model.version}]`
: model.name;
kfSelect.appendChild(opt);
});
kfSelect.value = kfVal || '';
updateModalBadges();
}
function restoreModalFromConfig() {
document.getElementById('modal-format-llm').value = perStepConfig.format.llm;
document.getElementById('modal-format-api-key').value = perStepConfig.format.apiKey;
document.getElementById('modal-translate-llm').value = perStepConfig.translate.llm;
document.getElementById('modal-translate-api-key').value = perStepConfig.translate.apiKey;
document.getElementById('modal-keyframe-llm').value = perStepConfig.keyframe.llm;
document.getElementById('modal-keyframe-api-key').value = perStepConfig.keyframe.apiKey;
document.getElementById('keyframe-method').value = perStepConfig.keyframe.method;
document.getElementById('keyframe-interval').value = perStepConfig.keyframe.interval;
onKeyframeMethodChange();
['format', 'translate', 'keyframe'].forEach(s => onModalLLMChange(s));
updateModalBadges();
}
function onModalLLMChange(step) {
const select = document.getElementById(`modal-${step}-llm`);
const apiKeyInput = document.getElementById(`modal-${step}-api-key`);
const val = select.value;
if (val && val !== 'ollama') {
// ์„ ํƒํ•œ ๋ชจ๋ธ์˜ needs_key ํ™•์ธ
const allModels = step === 'keyframe' ? visionModels : llmModels;
const model = allModels.find(m => m.id === val);
if (model && model.needs_key) {
apiKeyInput.style.display = '';
apiKeyInput.placeholder = model.env_key
? `API Key (${model.env_key} ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์žˆ์œผ๋ฉด ์ƒ๋žต ๊ฐ€๋Šฅ)`
: 'API Key ์ž…๋ ฅ...';
} else {
apiKeyInput.style.display = 'none';
}
} else {
apiKeyInput.style.display = 'none';
}
updateModalBadges();
}
function updateModalBadges() {
const globalModel = llmModels.find(m => m.id === selectedLLM);
const globalName = globalModel ? globalModel.name : '๊ธฐ๋ณธ';
['format', 'translate', 'keyframe'].forEach(step => {
const select = document.getElementById(`modal-${step}-llm`);
const badge = document.getElementById(`badge-${step}`);
if (select.value) {
const allModels = step === 'keyframe' ? visionModels : llmModels;
const model = allModels.find(m => m.id === select.value);
badge.textContent = model ? model.name : select.value;
badge.className = 'modal-step-badge custom';
} else {
badge.textContent = globalName;
badge.className = 'modal-step-badge';
}
});
}
function onKeyframeMethodChange() {
const method = document.getElementById('keyframe-method').value;
document.getElementById('keyframe-interval-group').style.display =
method === 'interval' ? '' : 'none';
}
function saveModalSettings() {
perStepConfig.format.llm = document.getElementById('modal-format-llm').value;
perStepConfig.format.apiKey = document.getElementById('modal-format-api-key').value.trim();
perStepConfig.translate.llm = document.getElementById('modal-translate-llm').value;
perStepConfig.translate.apiKey = document.getElementById('modal-translate-api-key').value.trim();
perStepConfig.keyframe.llm = document.getElementById('modal-keyframe-llm').value;
perStepConfig.keyframe.apiKey = document.getElementById('modal-keyframe-api-key').value.trim();
perStepConfig.keyframe.method = document.getElementById('keyframe-method').value;
perStepConfig.keyframe.interval = parseInt(document.getElementById('keyframe-interval').value) || 30;
updateWorkflowSummary();
closeSettingsModal();
}
function updateWorkflowSummary() {
const dot = document.getElementById('settings-dot');
const summary = document.getElementById('workflow-summary');
// ์˜ค๋ฒ„๋ผ์ด๋“œ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ
const overrides = [];
const getModelName = (id, models) => {
const m = models.find(x => x.id === id);
return m ? m.name : id;
};
if (perStepConfig.format.llm) {
overrides.push(`MD์ •๋ฆฌ โ†’ ${getModelName(perStepConfig.format.llm, llmModels)}`);
}
if (perStepConfig.translate.llm) {
overrides.push(`๋ฒˆ์—ญ โ†’ ${getModelName(perStepConfig.translate.llm, llmModels)}`);
}
if (perStepConfig.keyframe.llm) {
overrides.push(`ํ‚คํ”„๋ ˆ์ž„ โ†’ ${getModelName(perStepConfig.keyframe.llm, visionModels)}`);
}
if (overrides.length > 0) {
dot.className = 'settings-dot active';
summary.style.display = '';
summary.textContent = 'โš™ ๋‹จ๊ณ„๋ณ„ ์„ค์ •: ' + overrides.join(' | ');
} else {
dot.className = 'settings-dot';
summary.style.display = 'none';
summary.textContent = '';
}
}
</script>
</body>
</html>