feat(rework): '전사' → '자막 추출(음성)' 용어 변경 + 왼쪽 영상 패널 sticky

- 이해하기 쉽게 모든 '전사' 문구를 '자막 추출'로 변경(메인 버튼은 '자막 추출(음성)')
- 좌측 영상 카드 position:sticky(top:1rem) → 스크롤해도 상단에 고정되어 항상 보임

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-25 18:12:26 +09:00
parent 7b94515180
commit 31b7720af9

View File

@ -26,11 +26,11 @@
<div class="modal-card"> <div class="modal-card">
<div class="modal-head"><h3>📖 재가공 사용법</h3><button class="modal-close" onclick="closeHelp()">&times;</button></div> <div class="modal-head"><h3>📖 재가공 사용법</h3><button class="modal-close" onclick="closeHelp()">&times;</button></div>
<div class="modal-body"> <div class="modal-body">
<!-- 이미지 중심: 원본 다운로드 → 전사 → 내보내기 한 번에 (NEW) --> <!-- 이미지 중심: 원본 다운로드 → 자막 추출 → 내보내기 한 번에 (NEW) -->
<div class="usage-guide"> <div class="usage-guide">
<div class="ug-title">🎬 원본 다운로드 한 번에 <span class="ug-new">NEW</span></div> <div class="ug-title">🎬 원본 다운로드 한 번에 <span class="ug-new">NEW</span></div>
<p class="ug-lead">유튜브에서 직접 받아 올릴 필요 없이, <b>버튼 한 번</b>으로 원본을 받아 <p class="ug-lead">유튜브에서 직접 받아 올릴 필요 없이, <b>버튼 한 번</b>으로 원본을 받아
<b>자동 전사</b>하고 그대로 <b>컷편집·내보내기</b>까지 이어집니다.</p> <b>자동 자막 추출</b>하고 그대로 <b>컷편집·내보내기</b>까지 이어집니다.</p>
<div class="ug-step"> <div class="ug-step">
<span class="ug-badge">1</span> <span class="ug-badge">1</span>
@ -45,8 +45,8 @@
<div class="ug-step"> <div class="ug-step">
<span class="ug-badge">2</span> <span class="ug-badge">2</span>
<div class="ug-step-body"> <div class="ug-step-body">
<div class="ug-step-t">자동 전사 완료 → 시간 싱크 자막 생성</div> <div class="ug-step-t">자동 자막 추출 완료 → 시간 싱크 자막 생성</div>
<div class="ug-step-d">다운로드가 끝나면 <b>Whisper</b>가 바로 전사<b>[00:00] 자막</b> 세그먼트가 만들어집니다. <div class="ug-step-d">다운로드가 끝나면 <b>Whisper</b>가 바로 자막 추출<b>[00:00] 자막</b> 세그먼트가 만들어집니다.
줄을 클릭하면 영상이 그 지점으로 이동해요.</div> 줄을 클릭하면 영상이 그 지점으로 이동해요.</div>
<img class="ug-img" th:src="@{/img/usage/dl-2-segments.png}" alt="자동 생성된 시간 싱크 자막" loading="lazy"> <img class="ug-img" th:src="@{/img/usage/dl-2-segments.png}" alt="자동 생성된 시간 싱크 자막" loading="lazy">
</div> </div>
@ -62,14 +62,14 @@
</div> </div>
</div> </div>
<p class="ug-tip">💡 직접 받은 파일을 쓰고 싶으면 옆의 <b>‘영상 업로드·전사</b>로 올리면 됩니다(기존 방식 그대로).</p> <p class="ug-tip">💡 직접 받은 파일을 쓰고 싶으면 옆의 <b>‘영상 업로드·자막 추출</b>로 올리면 됩니다(기존 방식 그대로).</p>
</div> </div>
<div class="ug-divider"></div> <div class="ug-divider"></div>
<div class="help-item"> <div class="help-item">
<div class="hi-ic"><i data-lucide="upload"></i></div> <div class="hi-ic"><i data-lucide="upload"></i></div>
<div><div class="hi-t">영상 업로드 · 전사</div><div class="hi-d">영상 파일을 올리면 <b>Whisper</b>가 말소리를 <b>시간 싱크 자막</b>으로 추출합니다. 언어는 자동감지(틀리면 옆에서 ko/en/zh/ja 선택). URL자막은 YouTube 자막을 평문으로(시간 없음) 가져옵니다.</div></div> <div><div class="hi-t">영상 업로드 · 자막 추출</div><div class="hi-d">영상 파일을 올리면 <b>Whisper</b>가 말소리를 <b>시간 싱크 자막</b>으로 추출합니다. 언어는 자동감지(틀리면 옆에서 ko/en/zh/ja 선택). URL자막은 YouTube 자막을 평문으로(시간 없음) 가져옵니다.</div></div>
</div> </div>
<div class="help-item"> <div class="help-item">
<div class="hi-ic"><i data-lucide="list-video"></i></div> <div class="hi-ic"><i data-lucide="list-video"></i></div>
@ -112,8 +112,8 @@
</style> </style>
<div style="display:grid; grid-template-columns: 360px 1fr; gap:1.5rem; align-items:start;"> <div style="display:grid; grid-template-columns: 360px 1fr; gap:1.5rem; align-items:start;">
<!-- 좌: 영상 + 정보 --> <!-- 좌: 영상 + 정보 (스크롤해도 상단에 따라다님) -->
<div class="card"> <div class="card" style="position:sticky; top:1rem; align-self:start;">
<div id="player" style="width:100%; aspect-ratio:9/16; max-height:480px; border-radius:8px; overflow:hidden; background:#000; margin-bottom:12px;"></div> <div id="player" style="width:100%; aspect-ratio:9/16; max-height:480px; border-radius:8px; overflow:hidden; background:#000; margin-bottom:12px;"></div>
<video id="localVideo" controls playsinline <video id="localVideo" controls playsinline
style="display:none; width:100%; aspect-ratio:9/16; max-height:480px; border-radius:8px; background:#000; margin-bottom:12px;"></video> style="display:none; width:100%; aspect-ratio:9/16; max-height:480px; border-radius:8px; background:#000; margin-bottom:12px;"></video>
@ -132,21 +132,21 @@
<div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:8px;"> <div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:8px;">
<h3 class="text-lg font-bold">원본 스크립트</h3> <h3 class="text-lg font-bold">원본 스크립트</h3>
<div class="flex gap-2" style="flex-wrap:wrap; align-items:center;"> <div class="flex gap-2" style="flex-wrap:wrap; align-items:center;">
<select id="langSel" title="전사 언어(자동감지가 틀릴 때 강제)" class="pub-in" style="width:auto; padding:7px 9px;"> <select id="langSel" title="자막 추출 언어(자동감지가 틀릴 때 강제)" class="pub-in" style="width:auto; padding:7px 9px;">
<option value="">자동감지</option> <option value="">자동감지</option>
<option value="ko">한국어</option> <option value="ko">한국어</option>
<option value="en">English</option> <option value="en">English</option>
<option value="zh">中文</option> <option value="zh">中文</option>
<option value="ja">日本語</option> <option value="ja">日本語</option>
</select> </select>
<button class="btn btn-primary px-3 py-2 flex items-center gap-1" id="downloadBtn" onclick="downloadOriginal()" title="저장된 URL로 원본을 yt-dlp로 받아 자동 전사(수동 다운로드+업로드 불필요)"> <button class="btn btn-primary px-3 py-2 flex items-center gap-1" id="downloadBtn" onclick="downloadOriginal()" title="저장된 URL로 원본을 yt-dlp로 받아 자동 자막 추출(수동 다운로드+업로드 불필요)">
<i data-lucide="download" style="width:15px;"></i> 원본 다운로드 <i data-lucide="download" style="width:15px;"></i> 원본 다운로드
</button> </button>
<button class="btn btn-primary px-3 py-2 flex items-center gap-1" id="transcribeCachedBtn" onclick="transcribeCached()" style="display:none;" title="받아둔 원본을 Whisper로 전사(전사만 다시 하기)"> <button class="btn btn-primary px-3 py-2 flex items-center gap-1" id="transcribeCachedBtn" onclick="transcribeCached()" style="display:none;" title="받아둔 원본을 Whisper로 자막 추출(자막 추출만 다시 하기)">
<i data-lucide="captions" style="width:15px;"></i> 전사 <i data-lucide="captions" style="width:15px;"></i> 자막 추출(음성)
</button> </button>
<label class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="uploadBtn" style="cursor:pointer;" title="직접 받은 영상 파일을 올려 전사"> <label class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="uploadBtn" style="cursor:pointer;" title="직접 받은 영상 파일을 올려 자막 추출">
<i data-lucide="upload" style="width:15px;"></i> 영상 업로드·전사 <i data-lucide="upload" style="width:15px;"></i> 영상 업로드·자막 추출
<input type="file" id="videoFile" accept="video/*" style="display:none;" onchange="onVideoSelected(event)"> <input type="file" id="videoFile" accept="video/*" style="display:none;" onchange="onVideoSelected(event)">
</label> </label>
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="extractBtn" onclick="extractScript()" title="YouTube 자막에서 평문 추출(타임스탬프 없음)"> <button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="extractBtn" onclick="extractScript()" title="YouTube 자막에서 평문 추출(타임스탬프 없음)">
@ -173,7 +173,7 @@
<div id="segmentList" style="display:none; max-height:340px; overflow:auto; border:1px solid var(--glass-border); border-radius:var(--radius-md); background:var(--surface-2);"></div> <div id="segmentList" style="display:none; max-height:340px; overflow:auto; border:1px solid var(--glass-border); border-radius:var(--radius-md); background:var(--surface-2);"></div>
<!-- 평문 폴백 (URL 자막 추출 시) --> <!-- 평문 폴백 (URL 자막 추출 시) -->
<textarea id="transcript" readonly placeholder="‘영상 업로드·전사’로 영상에서 싱크된 자막을 추출하거나, URL자막으로 YouTube 자막을 가져오세요." <textarea id="transcript" readonly placeholder="‘영상 업로드·자막 추출’로 영상에서 싱크된 자막을 추출하거나, URL자막으로 YouTube 자막을 가져오세요."
style="width:100%; min-height:140px; resize:vertical; padding:12px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text-2); outline:none; font-size:0.9rem; line-height:1.6;"></textarea> style="width:100%; min-height:140px; resize:vertical; padding:12px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text-2); outline:none; font-size:0.9rem; line-height:1.6;"></textarea>
<!-- 내보내기 (배속 + 말 없는 구간 제거 + SRT/영상) --> <!-- 내보내기 (배속 + 말 없는 구간 제거 + SRT/영상) -->
@ -228,7 +228,7 @@
<div class="card"> <div class="card">
<div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:8px;"> <div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:8px;">
<h3 class="text-lg font-bold">🤖 AI 자막 (음성 / 화면)</h3> <h3 class="text-lg font-bold">🤖 AI 자막 (음성 / 화면)</h3>
<button class="btn btn-primary px-3 py-2 flex items-center gap-1" id="aiGenBtn" onclick="generateAiSubtitles()" title="왼쪽=음성(Whisper 정밀+한국어), 오른쪽=화면(Gemini 한국어). 음성은 먼저 '전사' 필요"> <button class="btn btn-primary px-3 py-2 flex items-center gap-1" id="aiGenBtn" onclick="generateAiSubtitles()" title="왼쪽=음성(Whisper 정밀+한국어), 오른쪽=화면(Gemini 한국어). 음성은 먼저 '자막 추출' 필요">
<i data-lucide="sparkles" style="width:15px;"></i> 생성 <i data-lucide="sparkles" style="width:15px;"></i> 생성
</button> </button>
</div> </div>
@ -348,7 +348,7 @@
} }
// ===== 세그먼트(영상 싱크) ===== // ===== 세그먼트(영상 싱크) =====
let SEGMENTS = []; // 전사 원본 세그먼트(원본 영상 재생에 싱크) let SEGMENTS = []; // 자막 추출 원본 세그먼트(원본 영상 재생에 싱크)
let UPLOADED_FILE = null; // 업로드한 영상 File (렌더 재전송용) let UPLOADED_FILE = null; // 업로드한 영상 File (렌더 재전송용)
let HAS_SERVER_FILE = false; // '원본 다운로드'로 서버에 캐시된 영상 보유 여부(렌더가 업로드 없이 재사용) let HAS_SERVER_FILE = false; // '원본 다운로드'로 서버에 캐시된 영상 보유 여부(렌더가 업로드 없이 재사용)
let AI_AUDIO = []; // AI 자막 - 음성(Whisper+한국어) 세그먼트 let AI_AUDIO = []; // AI 자막 - 음성(Whisper+한국어) 세그먼트
@ -457,7 +457,7 @@
try { try {
const r = await api(API + '/' + VIDEO_ID + '/translate?target=ko&format=segments', { method:'POST' }); const r = await api(API + '/' + VIDEO_ID + '/translate?target=ko&format=segments', { method:'POST' });
const ko = (r && r.texts) || []; const ko = (r && r.texts) || [];
if(!ko.length){ alert('번역할 세그먼트가 없습니다. 먼저 전사하세요.'); return; } if(!ko.length){ alert('번역할 세그먼트가 없습니다. 먼저 자막 추출하세요.'); return; }
// 현재 작업 세그먼트(원본/무음제거 후)와 1:1 정렬 → 텍스트만 한국어로 교체(타임코드·배속 그대로) // 현재 작업 세그먼트(원본/무음제거 후)와 1:1 정렬 → 텍스트만 한국어로 교체(타임코드·배속 그대로)
const segs = workingSegments(); const segs = workingSegments();
const koSegs = segs.map((sg, i) => ({ start: sg.start, end: sg.end, text: (ko[i] != null ? ko[i] : (sg.text || '')) })); const koSegs = segs.map((sg, i) => ({ start: sg.start, end: sg.end, text: (ko[i] != null ? ko[i] : (sg.text || '')) }));
@ -495,7 +495,7 @@
// 왼쪽: 저장된 Whisper 세그먼트 → 한국어 번역(LibreTranslate) // 왼쪽: 저장된 Whisper 세그먼트 → 한국어 번역(LibreTranslate)
const audioP = (async () => { const audioP = (async () => {
// 말소리가 없는(음악·화면자막만) 영상은 오류가 아니라 '음성 없음' 안내. // 말소리가 없는(음악·화면자막만) 영상은 오류가 아니라 '음성 없음' 안내.
if(!SEGMENTS || !SEGMENTS.length) return { note: '음성 없음 — 말소리가 없거나(음악·화면자막만) 전사 미실행' }; if(!SEGMENTS || !SEGMENTS.length) return { note: '음성 없음 — 말소리가 없거나(음악·화면자막만) 자막 추출 미실행' };
const r = await api(API + '/' + VIDEO_ID + '/translate?target=ko&format=segments', { method:'POST' }); const r = await api(API + '/' + VIDEO_ID + '/translate?target=ko&format=segments', { method:'POST' });
const ko = (r && r.texts) || []; const ko = (r && r.texts) || [];
return { segments: SEGMENTS.map((sg, i) => ({ start: sg.start, end: sg.end, text: (ko[i] != null ? ko[i] : (sg.text || '')) })) }; return { segments: SEGMENTS.map((sg, i) => ({ start: sg.start, end: sg.end, text: (ko[i] != null ? ko[i] : (sg.text || '')) })) };
@ -560,7 +560,7 @@
} }
async function renderVideo(){ async function renderVideo(){
if(!UPLOADED_FILE && !HAS_SERVER_FILE){ alert('먼저 "원본 다운로드" 또는 "영상 업로드·전사"를 실행하세요. (렌더에는 원본 영상이 필요합니다)'); return; } if(!UPLOADED_FILE && !HAS_SERVER_FILE){ alert('먼저 "원본 다운로드" 또는 "영상 업로드·자막 추출"를 실행하세요. (렌더에는 원본 영상이 필요합니다)'); return; }
const pad = parseFloat(document.getElementById('trimPad').value) || 0; const pad = parseFloat(document.getElementById('trimPad').value) || 0;
const gap = parseFloat(document.getElementById('trimGap').value) || 0; const gap = parseFloat(document.getElementById('trimGap').value) || 0;
const speed = curSpeed(); const speed = curSpeed();
@ -593,7 +593,7 @@
} }
} }
// 저장된 URL로 원본을 서버에서 yt-dlp로 받아 자동 전사. 성공 시 렌더도 업로드 없이 가능. // 저장된 URL로 원본을 서버에서 yt-dlp로 받아 자동 자막 추출. 성공 시 렌더도 업로드 없이 가능.
async function downloadOriginal(){ async function downloadOriginal(){
const status = document.getElementById('transcribeStatus'); const status = document.getElementById('transcribeStatus');
const btn = document.getElementById('downloadBtn'); const btn = document.getElementById('downloadBtn');
@ -608,7 +608,7 @@
updateCacheUI(true, d.sizeBytes || 0); // 캐시 보유 → 삭제버튼 노출 + 왼쪽 플레이어를 받은 원본으로 updateCacheUI(true, d.sizeBytes || 0); // 캐시 보유 → 삭제버튼 노출 + 왼쪽 플레이어를 받은 원본으로
const mb = d.sizeBytes ? ((d.sizeBytes/1048576).toFixed(1) + 'MB') : ''; const mb = d.sizeBytes ? ((d.sizeBytes/1048576).toFixed(1) + 'MB') : '';
status.style.color = '#4ade80'; status.style.color = '#4ade80';
status.textContent = '다운로드 완료 ✓ ' + mb + ' · 전사 중…'; status.textContent = '다운로드 완료 ✓ ' + mb + ' · 자막 추출 중…';
} catch(e){ } catch(e){
status.style.color = '#f87171'; status.style.color = '#f87171';
status.textContent = '다운로드 실패: ' + e.message; status.textContent = '다운로드 실패: ' + e.message;
@ -616,24 +616,24 @@
if(window.lucide) lucide.createIcons(); if(window.lucide) lucide.createIcons();
return; return;
} }
// 2단계: 전사(분리). 실패해도 다운로드(영상)는 유지 → '전사' 버튼으로 재시도 가능. // 2단계: 자막 추출(분리). 실패해도 다운로드(영상)는 유지 → '자막 추출' 버튼으로 재시도 가능.
try { try {
const s = await runTranscribeCached(); const s = await runTranscribeCached();
status.style.color = '#4ade80'; status.style.color = '#4ade80';
status.textContent = '다운로드+전사 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트' status.textContent = '다운로드+자막 추출 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트'
+ (s.language ? (' · ' + s.language) : '') + (s.language ? (' · ' + s.language) : '')
+ (s.duration ? (' · ' + mmss(s.duration)) : ''); + (s.duration ? (' · ' + mmss(s.duration)) : '');
} catch(e){ } catch(e){
status.style.color = '#fbbf24'; status.style.color = '#fbbf24';
status.textContent = '다운로드는 완료 ✓ · 전사 실패(서버 혼잡일 수 있음): ' + e.message status.textContent = '다운로드는 완료 ✓ · 자막 추출 실패(서버 혼잡일 수 있음): ' + e.message
+ ' — 전사 버튼으로 다시 시도하세요'; + ' — 자막 추출 버튼으로 다시 시도하세요';
} finally { } finally {
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig; btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
if(window.lucide) lucide.createIcons(); if(window.lucide) lucide.createIcons();
} }
} }
// 받아둔 원본을 Whisper 로 전사(다운로드와 분리). 세그먼트를 렌더하고 응답을 반환. // 받아둔 원본을 Whisper 로 자막 추출(다운로드와 분리). 세그먼트를 렌더하고 응답을 반환.
async function runTranscribeCached(){ async function runTranscribeCached(){
const lang = document.getElementById('langSel').value; const lang = document.getElementById('langSel').value;
const url = API + '/' + VIDEO_ID + '/transcribe-cached' + (lang ? ('?language=' + lang) : ''); const url = API + '/' + VIDEO_ID + '/transcribe-cached' + (lang ? ('?language=' + lang) : '');
@ -643,22 +643,22 @@
return s; return s;
} }
// '전사' 버튼: 받아둔 원본만 다시 전사(전사만 실패했을 때 재시도용). // '자막 추출' 버튼: 받아둔 원본만 다시 자막 추출(자막 추출만 실패했을 때 재시도용).
async function transcribeCached(){ async function transcribeCached(){
const status = document.getElementById('transcribeStatus'); const status = document.getElementById('transcribeStatus');
const btn = document.getElementById('transcribeCachedBtn'); const btn = document.getElementById('transcribeCachedBtn');
const orig = btn.innerHTML; btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '전사 중…'; const orig = btn.innerHTML; btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '자막 추출 중…';
status.style.display = 'block'; status.style.color = '#facc15'; status.style.display = 'block'; status.style.color = '#facc15';
status.textContent = '전사 중… (Shorts 기준 보통 20~60초, 서버 혼잡 시 더 걸림)'; status.textContent = '자막 추출 중… (Shorts 기준 보통 20~60초, 서버 혼잡 시 더 걸림)';
try { try {
const s = await runTranscribeCached(); const s = await runTranscribeCached();
status.style.color = '#4ade80'; status.style.color = '#4ade80';
status.textContent = '전사 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트' status.textContent = '자막 추출 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트'
+ (s.language ? (' · ' + s.language) : '') + (s.language ? (' · ' + s.language) : '')
+ (s.duration ? (' · ' + mmss(s.duration)) : ''); + (s.duration ? (' · ' + mmss(s.duration)) : '');
} catch(e){ } catch(e){
status.style.color = '#f87171'; status.style.color = '#f87171';
status.textContent = '전사 실패: ' + e.message; status.textContent = '자막 추출 실패: ' + e.message;
} finally { } finally {
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig; btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
if(window.lucide) lucide.createIcons(); if(window.lucide) lucide.createIcons();
@ -668,7 +668,7 @@
// 서버 캐시 보유 상태 → '받은 원본 삭제' 버튼 표시 + 렌더 캐시 사용 여부(HAS_SERVER_FILE) 갱신 // 서버 캐시 보유 상태 → '받은 원본 삭제' 버튼 표시 + 렌더 캐시 사용 여부(HAS_SERVER_FILE) 갱신
function updateCacheUI(cached, sizeBytes){ function updateCacheUI(cached, sizeBytes){
HAS_SERVER_FILE = !!cached; HAS_SERVER_FILE = !!cached;
// 받아진 상태면 '원본 다운로드' 숨기고 '전사'·'받은 원본 삭제' 노출(없으면 반대) // 받아진 상태면 '원본 다운로드' 숨기고 '자막 추출'·'받은 원본 삭제' 노출(없으면 반대)
const dl = document.getElementById('downloadBtn'); const dl = document.getElementById('downloadBtn');
const tr = document.getElementById('transcribeCachedBtn'); const tr = document.getElementById('transcribeCachedBtn');
const del = document.getElementById('deleteDlBtn'); const del = document.getElementById('deleteDlBtn');
@ -717,11 +717,11 @@
v.style.display = 'block'; v.style.display = 'block';
document.getElementById('player').style.display = 'none'; document.getElementById('player').style.display = 'none';
// 2) 서버로 보내 Whisper 전사 // 2) 서버로 보내 Whisper 자막 추출
const status = document.getElementById('transcribeStatus'); const status = document.getElementById('transcribeStatus');
const btn = document.getElementById('uploadBtn'); const btn = document.getElementById('uploadBtn');
status.style.display = 'block'; status.style.color = '#facc15'; status.style.display = 'block'; status.style.color = '#facc15';
status.textContent = '전사 중… (Shorts 기준 보통 20~60초)'; status.textContent = '자막 추출 중… (Shorts 기준 보통 20~60초)';
btn.style.pointerEvents = 'none'; btn.style.opacity = '0.6'; btn.style.pointerEvents = 'none'; btn.style.opacity = '0.6';
try { try {
const fd = new FormData(); const fd = new FormData();
@ -732,12 +732,12 @@
renderSegments(s.segments); renderSegments(s.segments);
document.getElementById('transcript').value = s.transcript || ''; document.getElementById('transcript').value = s.transcript || '';
status.style.color = '#4ade80'; status.style.color = '#4ade80';
status.textContent = '전사 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트' status.textContent = '자막 추출 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트'
+ (s.language ? (' · ' + s.language) : '') + (s.language ? (' · ' + s.language) : '')
+ (s.duration ? (' · ' + mmss(s.duration)) : ''); + (s.duration ? (' · ' + mmss(s.duration)) : '');
} catch(e){ } catch(e){
status.style.color = '#f87171'; status.style.color = '#f87171';
status.textContent = '전사 실패: ' + e.message; status.textContent = '자막 추출 실패: ' + e.message;
} finally { } finally {
btn.style.pointerEvents = ''; btn.style.opacity = ''; btn.style.pointerEvents = ''; btn.style.opacity = '';
if(window.lucide) lucide.createIcons(); if(window.lucide) lucide.createIcons();