재가공 화면에 'AI 자막(음성/화면)' 카드 추가. '생성' 한 번에:
- 왼쪽(음성): 저장된 Whisper 세그먼트(정밀 타임스탬프) → LibreTranslate 한국어
- 오른쪽(화면): 유튜브 URL → Gemini가 화면 박힌 자막 추출+한국어 번역
각 패널을 [00:00] 한국어 리스트로 표시, 각각 SRT 다운로드(audio_ko/screen_ko).
- GeminiSubtitleService 신규: youtube URL을 generativelanguage API에 전송,
responseMimeType=json 구조화 응답 파싱. buildRequest/extractSegments/sanitizeApiKey 순수+단위테스트
- sanitizeApiKey: 잘못 붙은 선행 '='·공백 정리(env var 오타 방어)
- geminiRestTemplate 빈(5분), POST /{id}/gemini-subtitles, CurationService.geminiScreenSubtitles
- 설정 gemini.api-key/model(gemini-2.5-flash), rework.html 분할 카드+JS
- 음성 한국어는 기존 translate(format=segments) 재사용
검증: 470 화면자막 → 한국어 27세그먼트 30초 추출(스타일 일본어 자막도 정확).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
900 lines
60 KiB
HTML
900 lines
60 KiB
HTML
<!DOCTYPE html>
|
||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||
layout:decorate="~{layout/base}">
|
||
|
||
<head>
|
||
<title>h-lab - 재가공</title>
|
||
</head>
|
||
|
||
<body>
|
||
<div layout:fragment="content">
|
||
<div class="page-header">
|
||
<div>
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<a th:href="@{/collection}" class="text-muted hover:text-white"><i data-lucide="arrow-left" style="width:20px;"></i></a>
|
||
<h1>재가공 작업공간</h1>
|
||
</div>
|
||
<p class="sub" id="metaLine">로딩 중...</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn btn-secondary" onclick="openHelp()"><i data-lucide="help-circle" style="width:15px;"></i> 사용법</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 사용법 모달 -->
|
||
<div id="helpModal" class="modal-overlay" onclick="if(event.target===this) closeHelp()">
|
||
<div class="modal-card">
|
||
<div class="modal-head"><h3>📖 재가공 사용법</h3><button class="modal-close" onclick="closeHelp()">×</button></div>
|
||
<div class="modal-body">
|
||
<!-- 이미지 중심: 원본 다운로드 → 전사 → 내보내기 한 번에 (NEW) -->
|
||
<div class="usage-guide">
|
||
<div class="ug-title">🎬 원본 다운로드 한 번에 <span class="ug-new">NEW</span></div>
|
||
<p class="ug-lead">유튜브에서 직접 받아 올릴 필요 없이, <b>버튼 한 번</b>으로 원본을 받아
|
||
<b>자동 전사</b>하고 그대로 <b>컷편집·내보내기</b>까지 이어집니다.</p>
|
||
|
||
<div class="ug-step">
|
||
<span class="ug-badge">1</span>
|
||
<div class="ug-step-body">
|
||
<div class="ug-step-t">‘원본 다운로드’ 클릭</div>
|
||
<div class="ug-step-d">저장된 영상 URL로 <b>yt-dlp</b>가 원본을 받아 서버에 보관합니다.
|
||
URL을 따로 입력할 필요가 없어요. (언어가 틀리면 왼쪽 <b>자동감지</b>에서 ko/en/zh/ja 선택)</div>
|
||
<img class="ug-img" th:src="@{/img/usage/dl-1-button.png}" alt="원본 다운로드 버튼" loading="lazy">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ug-step">
|
||
<span class="ug-badge">2</span>
|
||
<div class="ug-step-body">
|
||
<div class="ug-step-t">자동 전사 완료 → 시간 싱크 자막 생성</div>
|
||
<div class="ug-step-d">다운로드가 끝나면 <b>Whisper</b>가 바로 전사해 <b>[00:00] 자막</b> 세그먼트가 만들어집니다.
|
||
줄을 클릭하면 영상이 그 지점으로 이동해요.</div>
|
||
<img class="ug-img" th:src="@{/img/usage/dl-2-segments.png}" alt="자동 생성된 시간 싱크 자막" loading="lazy">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ug-step">
|
||
<span class="ug-badge">3</span>
|
||
<div class="ug-step-body">
|
||
<div class="ug-step-t">내보내기 (업로드 불필요)</div>
|
||
<div class="ug-step-d"><b>말 없는 구간 제거</b>·<b>배속</b>을 적용하고 <b>SRT / 영상</b>을 내보내면 CapCut에 그대로 import.
|
||
렌더는 <b>방금 받은 원본을 그대로 재사용</b>하므로 다시 업로드할 필요가 없습니다.</div>
|
||
<img class="ug-img" th:src="@{/img/usage/dl-3-export.png}" alt="내보내기(배속·말없는구간 제거·SRT/영상)" loading="lazy">
|
||
</div>
|
||
</div>
|
||
|
||
<p class="ug-tip">💡 직접 받은 파일을 쓰고 싶으면 옆의 <b>‘영상 업로드·전사’</b>로 올리면 됩니다(기존 방식 그대로).</p>
|
||
</div>
|
||
|
||
<div class="ug-divider"></div>
|
||
|
||
<div class="help-item">
|
||
<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="help-item">
|
||
<div class="hi-ic"><i data-lucide="list-video"></i></div>
|
||
<div><div class="hi-t">세그먼트 리스트</div><div class="hi-d"><b>[00:12] 자막</b> 줄을 클릭하면 영상이 그 지점으로 이동하고, 재생 중 현재 자막이 하이라이트됩니다.</div></div>
|
||
</div>
|
||
<div class="help-item">
|
||
<div class="hi-ic"><i data-lucide="scissors"></i></div>
|
||
<div><div class="hi-t">말 없는 구간 제거 · 배속</div><div class="hi-d"><b>말 없는 구간 제거</b>를 켜면 아무도 말 안 하는 부분을 잘라 영상을 타이트하게 만듭니다. <b>배속</b>도 적용(자막 타임스탬프 자동 보정). ‘미리보기’로 결과 길이를 확인하세요.</div></div>
|
||
</div>
|
||
<div class="help-item">
|
||
<div class="hi-ic"><i data-lucide="file-down"></i></div>
|
||
<div><div class="hi-t">내보내기 → CapCut</div><div class="hi-d"><b>SRT 자막</b>과 <b>영상</b>을 내보내 CapCut에 <b>둘 다 import</b>하면 자막·컷·배속이 맞춰집니다.</div></div>
|
||
</div>
|
||
<div class="help-item">
|
||
<div class="hi-ic"><i data-lucide="send"></i></div>
|
||
<div><div class="hi-t">재작성 · 발행</div><div class="hi-d">‘재작성(내 버전)’에 각색해 저장하면 상태가 <b>TARGET</b>으로 바뀝니다. 하단 <b>발행 준비</b>에서 제목·해시태그를 저장하면 발행 큐로 들어갑니다.</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.usage-guide { margin-bottom: 6px; }
|
||
.ug-title { font-size: 1.05rem; font-weight: 800; display:flex; align-items:center; gap:8px; margin-bottom: 6px; }
|
||
.ug-new { font-size: 0.65rem; font-weight: 800; letter-spacing: 0.04em; color:#fff;
|
||
background:#f97316; padding: 2px 7px; border-radius: 999px; }
|
||
.ug-lead { color: var(--text-2); font-size: 0.9rem; line-height: 1.6; margin-bottom: 14px; }
|
||
.ug-step { display:flex; gap: 12px; margin-bottom: 16px; }
|
||
.ug-badge { flex-shrink:0; width: 26px; height: 26px; border-radius: 50%;
|
||
background:#f97316; color:#fff; font-weight: 800; font-size: 0.85rem;
|
||
display:flex; align-items:center; justify-content:center; margin-top: 1px; }
|
||
.ug-step-body { flex: 1; min-width: 0; }
|
||
.ug-step-t { font-weight: 700; font-size: 0.95rem; margin-bottom: 3px; }
|
||
.ug-step-d { color: var(--text-2); font-size: 0.85rem; line-height: 1.6; margin-bottom: 9px; }
|
||
.ug-img { display:block; width: 100%; border: 1px solid var(--glass-border);
|
||
border-radius: var(--radius-md); box-shadow: 0 2px 10px rgba(0,0,0,0.10); }
|
||
.ug-tip { font-size: 0.85rem; color: var(--text-2); background: var(--surface-2);
|
||
border: 1px solid var(--glass-border); border-radius: var(--radius-md); padding: 9px 12px; margin-top: 4px; }
|
||
.ug-divider { height:1px; background: var(--glass-border); margin: 18px 0; }
|
||
</style>
|
||
|
||
<div style="display:grid; grid-template-columns: 360px 1fr; gap:1.5rem; align-items:start;">
|
||
<!-- 좌: 영상 + 정보 -->
|
||
<div class="card">
|
||
<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
|
||
style="display:none; width:100%; aspect-ratio:9/16; max-height:480px; border-radius:8px; background:#000; margin-bottom:12px;"></video>
|
||
<div class="flex flex-col gap-1 text-sm">
|
||
<div class="font-bold" id="vTitle" style="line-height:1.4;"></div>
|
||
<div class="text-muted" id="vChannel"></div>
|
||
<div class="flex gap-3 mt-2 text-muted" id="vStats"></div>
|
||
<a id="vLink" href="#" target="_blank" class="text-sm hover:underline mt-2" style="color:#60a5fa;">YouTube에서 열기 →</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 우: 스크립트 + 재작성 -->
|
||
<div class="flex flex-col gap-4">
|
||
<!-- 원본 스크립트 -->
|
||
<div class="card">
|
||
<div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:8px;">
|
||
<h3 class="text-lg font-bold">원본 스크립트</h3>
|
||
<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;">
|
||
<option value="">자동감지</option>
|
||
<option value="ko">한국어</option>
|
||
<option value="en">English</option>
|
||
<option value="zh">中文</option>
|
||
<option value="ja">日本語</option>
|
||
</select>
|
||
<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> 원본 다운로드
|
||
</button>
|
||
<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> 전사
|
||
</button>
|
||
<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> 영상 업로드·전사
|
||
<input type="file" id="videoFile" accept="video/*" style="display:none;" onchange="onVideoSelected(event)">
|
||
</label>
|
||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="extractBtn" onclick="extractScript()" title="YouTube 자막에서 평문 추출(타임스탬프 없음)">
|
||
<i data-lucide="download-cloud" style="width:15px;"></i> URL자막
|
||
</button>
|
||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="copyBtn" onclick="copyToEditor()">
|
||
<i data-lucide="copy" style="width:15px;"></i> 에디터로 복사
|
||
</button>
|
||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="translateBtn" onclick="translateToEditor('plain')" title="원본 스크립트를 한국어로 번역해 ‘재작성(내 버전)’ 칸에 넣기(평문 흐름)">
|
||
<i data-lucide="languages" style="width:15px;"></i> 번역→재작성
|
||
</button>
|
||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="translateTlBtn" onclick="translateToEditor('timeline')" title="세그먼트별 [00:00] 타임스탬프를 붙여 번역해 ‘재작성’ 칸에 넣기">
|
||
<i data-lucide="clock" style="width:15px;"></i> [00:00] 번역
|
||
</button>
|
||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="deleteDlBtn" onclick="deleteDownload()" style="display:none; color:#ef4444;" title="서버에 받아둔 원본 mp4 삭제(자막은 유지)">
|
||
<i data-lucide="trash-2" style="width:15px;"></i> 받은 원본 삭제
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="transcribeStatus" class="text-sm mb-2" style="display:none;"></div>
|
||
|
||
<!-- 영상 싱크 세그먼트 리스트 (CapCut형) -->
|
||
<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 자막 추출 시) -->
|
||
<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>
|
||
|
||
<!-- 내보내기 (배속 + 말 없는 구간 제거 + SRT/영상) -->
|
||
<div id="exportBar" style="display:none; margin-top:14px; padding-top:12px; border-top:1px solid var(--glass-border);">
|
||
<div class="flex items-center gap-2 mb-3" style="flex-wrap:wrap;">
|
||
<label class="text-sm text-muted">배속</label>
|
||
<input id="srtSpeed" type="number" value="1.0" step="0.1" min="0.1" max="2.0"
|
||
style="width:64px; padding:7px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text); outline:none; font-size:0.9rem;">
|
||
<span class="text-xs text-muted">배속 적용 시 타임스탬프 자동 보정 (0.5~2.0)</span>
|
||
</div>
|
||
|
||
<div style="background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); padding:10px 12px;">
|
||
<div class="flex items-center gap-2 mb-2" style="flex-wrap:wrap;">
|
||
<label class="flex items-center gap-1 text-sm" style="cursor:pointer;">
|
||
<input type="checkbox" id="trimOn" onchange="onTrimToggle()"> 말 없는 구간 제거
|
||
</label>
|
||
<span class="text-xs text-muted">앞뒤 여백</span>
|
||
<input id="trimPad" type="number" value="0.15" step="0.05" min="0" onchange="if(trimApplied)previewTrim()"
|
||
style="width:60px; padding:5px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:6px; color:var(--text); font-size:0.85rem;">
|
||
<span class="text-xs text-muted">최소 간격</span>
|
||
<input id="trimGap" type="number" value="0.3" step="0.1" min="0" onchange="if(trimApplied)previewTrim()"
|
||
style="width:60px; padding:5px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:6px; color:var(--text); font-size:0.85rem;">
|
||
</div>
|
||
<div id="trimInfo" class="text-xs text-muted"></div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2 mt-3" style="flex-wrap:wrap;">
|
||
<button class="btn btn-primary px-3 py-2 flex items-center gap-1" onclick="exportSrt()">
|
||
<i data-lucide="file-down" style="width:15px;"></i> SRT 내보내기
|
||
</button>
|
||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="koSrtBtn" onclick="exportKoSrt()" title="세그먼트를 한국어로 번역해 타임코드 맞춰 SRT로 내보내기">
|
||
<i data-lucide="languages" style="width:15px;"></i> 한국어 SRT
|
||
</button>
|
||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="renderBtn" onclick="renderVideo()">
|
||
<i data-lucide="scissors" style="width:15px;"></i> 영상 내보내기
|
||
</button>
|
||
<span class="text-xs text-muted">CapCut에 영상+SRT 함께 import</span>
|
||
</div>
|
||
<div id="renderStatus" class="text-sm mt-2" style="display:none;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.seg-row { display:flex; gap:10px; padding:7px 12px; cursor:pointer; border-bottom:1px solid var(--surface-2); font-size:0.9rem; line-height:1.5; }
|
||
.seg-row:hover { background:var(--surface-2); }
|
||
.seg-row.active { background:rgba(96,165,250,0.18); }
|
||
.seg-time { color:#60a5fa; font-variant-numeric:tabular-nums; flex-shrink:0; font-size:0.82rem; padding-top:1px; }
|
||
.seg-text { color:var(--text-2); }
|
||
</style>
|
||
|
||
<!-- AI 자막 (음성 Whisper / 화면 Gemini) -->
|
||
<div class="card">
|
||
<div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:8px;">
|
||
<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 한국어). 음성은 먼저 '전사' 필요">
|
||
<i data-lucide="sparkles" style="width:15px;"></i> 생성
|
||
</button>
|
||
</div>
|
||
<div id="aiStatus" class="text-sm mb-2" style="display:none;"></div>
|
||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
||
<div>
|
||
<div class="flex items-center justify-between mb-1" style="gap:6px;">
|
||
<div class="font-bold text-sm">🎙 음성 <span class="text-xs text-muted">Whisper·정밀</span></div>
|
||
<button class="btn btn-secondary" style="padding:4px 9px; font-size:0.8rem;" onclick="downloadAiSrt('audio')"><i data-lucide="file-down" style="width:13px;"></i> SRT</button>
|
||
</div>
|
||
<div id="aiAudioList" class="ai-list"><div class="ai-empty">‘생성’을 누르세요</div></div>
|
||
</div>
|
||
<div>
|
||
<div class="flex items-center justify-between mb-1" style="gap:6px;">
|
||
<div class="font-bold text-sm">🖼 화면 <span class="text-xs text-muted">Gemini·~1초</span></div>
|
||
<button class="btn btn-secondary" style="padding:4px 9px; font-size:0.8rem;" onclick="downloadAiSrt('screen')"><i data-lucide="file-down" style="width:13px;"></i> SRT</button>
|
||
</div>
|
||
<div id="aiScreenList" class="ai-list"><div class="ai-empty">‘생성’을 누르세요</div></div>
|
||
</div>
|
||
</div>
|
||
<style>
|
||
.ai-list { max-height:300px; overflow:auto; border:1px solid var(--glass-border);
|
||
border-radius:var(--radius-md); background:var(--surface-2); }
|
||
.ai-row { display:flex; gap:8px; padding:6px 10px; cursor:pointer; border-bottom:1px solid var(--surface);
|
||
font-size:0.85rem; line-height:1.5; }
|
||
.ai-row:hover { background:var(--surface); }
|
||
.ai-empty { padding:14px 10px; color:var(--text-2); font-size:0.85rem; text-align:center; }
|
||
</style>
|
||
</div>
|
||
|
||
<!-- 재작성 에디터 -->
|
||
<div class="card">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h3 class="text-lg font-bold">재작성 (내 버전)</h3>
|
||
<button class="btn btn-primary px-4 py-2 flex items-center gap-1" id="saveBtn" onclick="saveRework()">
|
||
<i data-lucide="save" style="width:15px;"></i> 저장
|
||
</button>
|
||
</div>
|
||
<textarea id="reworkText" placeholder="원본을 참고해 각색/수정한 스크립트를 여기에 작성하세요. 저장하면 상태가 TARGET(작업대상)으로 바뀝니다."
|
||
style="width:100%; min-height:280px; resize:vertical; padding:12px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text); outline:none; font-size:0.95rem; line-height:1.7;"></textarea>
|
||
<div class="text-sm text-muted mt-2" id="saveInfo"></div>
|
||
</div>
|
||
|
||
<!-- 발행 준비 -->
|
||
<div class="card">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h3 class="text-lg font-bold">📤 발행 준비</h3>
|
||
<span id="pubStatusBadge" class="text-sm text-muted"></span>
|
||
</div>
|
||
<div class="flex flex-col gap-3">
|
||
<div>
|
||
<label class="text-sm text-muted">발행 제목</label>
|
||
<input id="pubTitle" type="text" class="pub-in" placeholder="업로드할 새 제목">
|
||
</div>
|
||
<div>
|
||
<label class="text-sm text-muted">설명</label>
|
||
<textarea id="pubDesc" class="pub-in" style="min-height:80px; resize:vertical;" placeholder="영상 설명"></textarea>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm text-muted">해시태그</label>
|
||
<input id="pubTags" type="text" class="pub-in" placeholder="#태그1 #태그2">
|
||
</div>
|
||
<div class="flex gap-3" style="flex-wrap:wrap;">
|
||
<div style="flex:1; min-width:120px;">
|
||
<label class="text-sm text-muted">플랫폼</label>
|
||
<select id="pubPlatform" class="pub-in">
|
||
<option value="YOUTUBE">YouTube</option>
|
||
<option value="TIKTOK">TikTok</option>
|
||
<option value="REELS">Instagram Reels</option>
|
||
</select>
|
||
</div>
|
||
<div style="flex:1; min-width:160px;">
|
||
<label class="text-sm text-muted">예약 일시</label>
|
||
<input id="pubSchedule" type="datetime-local" class="pub-in">
|
||
</div>
|
||
<div style="flex:1; min-width:120px;">
|
||
<label class="text-sm text-muted">상태</label>
|
||
<select id="pubStatus" class="pub-in">
|
||
<option value="DRAFT">작성중</option>
|
||
<option value="READY">발행대기</option>
|
||
<option value="PUBLISHED">발행완료</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2 items-center">
|
||
<button class="btn btn-primary px-4 py-2 flex items-center gap-1" onclick="savePublish()" id="pubSaveBtn">
|
||
<i data-lucide="save" style="width:15px;"></i> 발행안 저장
|
||
</button>
|
||
<button class="btn btn-secondary px-4 py-2 flex items-center gap-1" onclick="markPublished()" id="pubDoneBtn">
|
||
<i data-lucide="check-circle" style="width:15px;"></i> 발행 완료(URL 기록)
|
||
</button>
|
||
<a th:href="@{/publish}" class="text-sm text-muted hover:text-white" style="margin-left:auto;">발행 큐 →</a>
|
||
</div>
|
||
<div id="pubInfo" class="text-sm text-muted"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.pub-in { width:100%; padding:9px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text); outline:none; font-size:0.9rem; }
|
||
.pub-in option { background:var(--surface); color:var(--text); }
|
||
</style>
|
||
|
||
<script th:inline="javascript">
|
||
/*<![CDATA[*/
|
||
const VIDEO_ID = /*[[${videoId}]]*/ 0;
|
||
const API = '/api/v1/channel-videos';
|
||
|
||
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); }
|
||
async function api(url, opts){
|
||
const res = await fetch(url, opts);
|
||
const json = await res.json().catch(()=>({}));
|
||
if(!res.ok || (json && json.success===false)) throw new Error((json && json.message) || ('HTTP '+res.status));
|
||
return json.data;
|
||
}
|
||
|
||
// ===== 세그먼트(영상 싱크) =====
|
||
let SEGMENTS = []; // 전사 원본 세그먼트(원본 영상 재생에 싱크)
|
||
let UPLOADED_FILE = null; // 업로드한 영상 File (렌더 재전송용)
|
||
let HAS_SERVER_FILE = false; // '원본 다운로드'로 서버에 캐시된 영상 보유 여부(렌더가 업로드 없이 재사용)
|
||
let AI_AUDIO = []; // AI 자막 - 음성(Whisper+한국어) 세그먼트
|
||
let AI_SCREEN = []; // AI 자막 - 화면(Gemini 한국어) 세그먼트
|
||
let trimApplied = false; // 말 없는 구간 제거 미리보기 적용 여부
|
||
let REMAPPED = []; // 무음 제거 후(배속 전) 세그먼트
|
||
|
||
// SRT/렌더에 쓸 현재 작업 세그먼트
|
||
function workingSegments(){ return trimApplied ? REMAPPED : SEGMENTS; }
|
||
|
||
function mmss(sec){
|
||
sec = Math.max(0, Math.floor(sec||0));
|
||
const m = Math.floor(sec/60), s = sec%60;
|
||
return String(m).padStart(2,'0')+':'+String(s).padStart(2,'0');
|
||
}
|
||
|
||
function localVideo(){
|
||
const v = document.getElementById('localVideo');
|
||
return (v && v.src) ? v : null;
|
||
}
|
||
|
||
// 받아둔 원본 mp4를 왼쪽 플레이어에 싣는다(Range 스트리밍) → 세그먼트 클릭 시 그 지점 재생.
|
||
function showServerVideoInPlayer(){
|
||
const v = document.getElementById('localVideo');
|
||
const src = location.origin + API + '/' + VIDEO_ID + '/download/file';
|
||
if(v.getAttribute('src') !== src){ v.src = src; }
|
||
v.style.display = 'block';
|
||
document.getElementById('player').style.display = 'none';
|
||
}
|
||
// 원본이 없을 때 YouTube 임베드로 되돌린다.
|
||
function showYoutubeInPlayer(){
|
||
const v = document.getElementById('localVideo');
|
||
v.removeAttribute('src'); if(v.load) v.load();
|
||
v.style.display = 'none';
|
||
document.getElementById('player').style.display = 'block';
|
||
}
|
||
|
||
function renderSegments(segs){
|
||
SEGMENTS = Array.isArray(segs) ? segs : [];
|
||
trimApplied = false; REMAPPED = [];
|
||
const cb = document.getElementById('trimOn'); if(cb) cb.checked = false;
|
||
const box = document.getElementById('segmentList');
|
||
const exportBar = document.getElementById('exportBar');
|
||
if(!SEGMENTS.length){
|
||
box.style.display = 'none'; box.innerHTML = '';
|
||
exportBar.style.display = 'none';
|
||
return;
|
||
}
|
||
box.innerHTML = SEGMENTS.map((sg,i)=>
|
||
'<div class="seg-row" data-i="'+i+'" data-start="'+sg.start+'" onclick="seekTo('+sg.start+')">'
|
||
+ '<span class="seg-time">'+mmss(sg.start)+'</span>'
|
||
+ '<span class="seg-text">'+esc(sg.text)+'</span></div>').join('');
|
||
box.style.display = 'block';
|
||
exportBar.style.display = 'block';
|
||
}
|
||
|
||
function seekTo(sec){
|
||
const v = localVideo();
|
||
if(v){ v.currentTime = sec; v.play().catch(()=>{}); }
|
||
}
|
||
|
||
function highlightAt(t){
|
||
const rows = document.querySelectorAll('#segmentList .seg-row');
|
||
let activeIdx = -1;
|
||
for(let i=0;i<SEGMENTS.length;i++){
|
||
const s = SEGMENTS[i];
|
||
if(t >= s.start && t < (s.end!=null ? s.end : Infinity)) { activeIdx = i; break; }
|
||
}
|
||
rows.forEach(r=>{
|
||
const on = Number(r.dataset.i)===activeIdx;
|
||
r.classList.toggle('active', on);
|
||
if(on) r.scrollIntoView({block:'nearest'});
|
||
});
|
||
}
|
||
|
||
function curSpeed(){ const s = parseFloat(document.getElementById('srtSpeed').value); return (s>0)?s:1.0; }
|
||
|
||
// 현재 작업 세그먼트(+배속)로 SRT 생성 → 다운로드. 말-구간 제거 적용 시 재매핑된 타임라인 반영.
|
||
function srtTime(sec){
|
||
sec = Math.max(0, sec);
|
||
const ms = Math.round(sec*1000);
|
||
const p=(n,l)=>String(n).padStart(l||2,'0');
|
||
return p(Math.floor(ms/3600000))+':'+p(Math.floor(ms%3600000/60000))+':'+p(Math.floor(ms%60000/1000))+','+p(ms%1000,3);
|
||
}
|
||
function buildSrt(segs, speed){
|
||
speed = speed>0?speed:1.0;
|
||
let out='', i=1;
|
||
for(const sg of segs){
|
||
let a=sg.start/speed, b=sg.end/speed;
|
||
if(a<0)a=0; if(b<a)b=a;
|
||
out += (i++)+'\n'+srtTime(a)+' --> '+srtTime(b)+'\n'+((sg.text||'').trim())+'\n\n';
|
||
}
|
||
return out;
|
||
}
|
||
function exportSrt(){
|
||
const blob = new Blob([buildSrt(workingSegments(), curSpeed())], {type:'application/x-subrip'});
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob); a.download = 'script_' + VIDEO_ID + '.srt';
|
||
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href);
|
||
}
|
||
|
||
// 세그먼트를 한국어로 번역해(서버 일괄번역) 기존 타임라인(배속·말없는구간 제거 반영)에 끼워 SRT 생성.
|
||
async function exportKoSrt(){
|
||
const btn = document.getElementById('koSrtBtn');
|
||
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '번역 중…';
|
||
try {
|
||
const r = await api(API + '/' + VIDEO_ID + '/translate?target=ko&format=segments', { method:'POST' });
|
||
const ko = (r && r.texts) || [];
|
||
if(!ko.length){ alert('번역할 세그먼트가 없습니다. 먼저 전사하세요.'); return; }
|
||
// 현재 작업 세그먼트(원본/무음제거 후)와 1:1 정렬 → 텍스트만 한국어로 교체(타임코드·배속 그대로)
|
||
const segs = workingSegments();
|
||
const koSegs = segs.map((sg, i) => ({ start: sg.start, end: sg.end, text: (ko[i] != null ? ko[i] : (sg.text || '')) }));
|
||
const blob = new Blob([buildSrt(koSegs, curSpeed())], {type:'application/x-subrip'});
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob); a.download = 'script_ko_' + VIDEO_ID + '.srt';
|
||
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href);
|
||
} catch(e){ alert('한국어 SRT 실패: ' + e.message); }
|
||
finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); }
|
||
}
|
||
|
||
// ===== AI 자막 (음성 Whisper / 화면 Gemini) =====
|
||
function renderAiPanel(elId, segs, error){
|
||
const box = document.getElementById(elId);
|
||
if(error){ box.innerHTML = '<div class="ai-empty" style="color:#f87171;">' + esc(error) + '</div>'; return; }
|
||
if(!segs || !segs.length){ box.innerHTML = '<div class="ai-empty">자막 없음</div>'; return; }
|
||
box.innerHTML = segs.map(sg =>
|
||
'<div class="ai-row" data-start="' + sg.start + '" onclick="seekTo(' + sg.start + ')">'
|
||
+ '<span class="seg-time">' + mmss(sg.start) + '</span>'
|
||
+ '<span class="seg-text">' + esc(sg.text) + '</span></div>').join('');
|
||
}
|
||
|
||
// 음성(Whisper 정밀+한국어) + 화면(Gemini 한국어)을 동시에 생성해 좌우 패널에 표시.
|
||
async function generateAiSubtitles(){
|
||
const btn = document.getElementById('aiGenBtn');
|
||
const status = document.getElementById('aiStatus');
|
||
const orig = btn.innerHTML; btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '생성 중…';
|
||
status.style.display = 'block'; status.style.color = '#facc15';
|
||
status.textContent = 'AI 자막 생성 중… (음성 번역 + Gemini 화면분석, 영상 길이에 따라 시간 걸림)';
|
||
|
||
// 왼쪽: 저장된 Whisper 세그먼트 → 한국어 번역(LibreTranslate)
|
||
const audioP = (async () => {
|
||
if(!SEGMENTS || !SEGMENTS.length) return { error: '음성 자막: 먼저 ‘전사’를 실행하세요' };
|
||
const r = await api(API + '/' + VIDEO_ID + '/translate?target=ko&format=segments', { method:'POST' });
|
||
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 || '')) })) };
|
||
})().catch(e => ({ error: '음성 번역 실패: ' + e.message }));
|
||
|
||
// 오른쪽: Gemini 화면 자막(한국어)
|
||
const screenP = api(API + '/' + VIDEO_ID + '/gemini-subtitles', { method:'POST' })
|
||
.then(r => ({ segments: (r && r.segments) || [] }))
|
||
.catch(e => ({ error: 'Gemini 실패: ' + e.message }));
|
||
|
||
const [audio, screen] = await Promise.all([audioP, screenP]);
|
||
AI_AUDIO = audio.segments || [];
|
||
AI_SCREEN = screen.segments || [];
|
||
renderAiPanel('aiAudioList', AI_AUDIO, audio.error);
|
||
renderAiPanel('aiScreenList', AI_SCREEN, screen.error);
|
||
|
||
status.style.color = '#4ade80';
|
||
status.textContent = '완료 · 음성 ' + AI_AUDIO.length + '개 · 화면 ' + AI_SCREEN.length + '개'
|
||
+ (audio.error ? ' · ⚠️음성:' + audio.error : '') + (screen.error ? ' · ⚠️화면:' + screen.error : '');
|
||
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig; if(window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
// 음성/화면 패널의 한국어 세그먼트를 SRT로 다운로드.
|
||
function downloadAiSrt(which){
|
||
const segs = which === 'audio' ? AI_AUDIO : AI_SCREEN;
|
||
if(!segs || !segs.length){ alert('먼저 ‘생성’을 눌러 ' + (which==='audio'?'음성':'화면') + ' 자막을 만드세요.'); return; }
|
||
const blob = new Blob([buildSrt(segs, 1.0)], {type:'application/x-subrip'});
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob); a.download = (which==='audio'?'audio_ko_':'screen_ko_') + VIDEO_ID + '.srt';
|
||
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href);
|
||
}
|
||
|
||
// ===== 말 없는 구간 제거 =====
|
||
function onTrimToggle(){
|
||
if(document.getElementById('trimOn').checked){ previewTrim(); }
|
||
else {
|
||
trimApplied = false; REMAPPED = [];
|
||
document.getElementById('trimInfo').textContent = '';
|
||
}
|
||
}
|
||
|
||
async function previewTrim(){
|
||
const pad = parseFloat(document.getElementById('trimPad').value) || 0;
|
||
const gap = parseFloat(document.getElementById('trimGap').value) || 0;
|
||
const info = document.getElementById('trimInfo');
|
||
try {
|
||
const p = await api(API + '/' + VIDEO_ID + '/trim-plan?pad=' + pad + '&minGap=' + gap);
|
||
REMAPPED = p.segments || [];
|
||
trimApplied = true;
|
||
document.getElementById('trimOn').checked = true;
|
||
const sp = curSpeed();
|
||
const kept = (p.keptDuration||0) / sp;
|
||
info.style.color = 'var(--text-2)';
|
||
info.innerHTML = '제거 ' + (p.removedCount||0) + '구간 · 결과 길이 ≈ <b>' + kept.toFixed(1) + 's</b>'
|
||
+ (sp!==1 ? (' (배속 ' + sp + 'x 포함)') : '')
|
||
+ ' · SRT/영상이 이 타임라인으로 출력됩니다';
|
||
} catch(e){
|
||
trimApplied = false;
|
||
info.style.color = '#f87171';
|
||
info.textContent = '미리보기 실패: ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function renderVideo(){
|
||
if(!UPLOADED_FILE && !HAS_SERVER_FILE){ alert('먼저 "원본 다운로드" 또는 "영상 업로드·전사"를 실행하세요. (렌더에는 원본 영상이 필요합니다)'); return; }
|
||
const pad = parseFloat(document.getElementById('trimPad').value) || 0;
|
||
const gap = parseFloat(document.getElementById('trimGap').value) || 0;
|
||
const speed = curSpeed();
|
||
const btn = document.getElementById('renderBtn');
|
||
const status = document.getElementById('renderStatus');
|
||
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '렌더 중…';
|
||
status.style.display = 'block'; status.style.color = '#facc15';
|
||
status.textContent = '영상 렌더링 중… (자르기+인코딩, 잠시 걸립니다)';
|
||
try {
|
||
const fd = new FormData();
|
||
if(UPLOADED_FILE) fd.append('file', UPLOADED_FILE); // 없으면 서버 캐시(원본 다운로드본) 사용
|
||
fd.append('pad', pad); fd.append('minGap', gap); fd.append('speed', speed);
|
||
const res = await fetch(API + '/' + VIDEO_ID + '/render', { method:'POST', body: fd });
|
||
if(!res.ok){
|
||
let msg = 'HTTP ' + res.status;
|
||
try { const j = await res.json(); if(j && j.message) msg = j.message; } catch(e){}
|
||
throw new Error(msg);
|
||
}
|
||
const blob = await res.blob();
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob); a.download = 'trimmed_' + VIDEO_ID + '.mp4';
|
||
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href);
|
||
status.style.color = '#4ade80';
|
||
status.textContent = '영상 내보내기 완료 · SRT도 함께 내보내 CapCut에 두 파일을 import하세요';
|
||
} catch(e){
|
||
status.style.color = '#f87171';
|
||
status.textContent = '렌더 실패: ' + e.message;
|
||
} finally {
|
||
btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons();
|
||
}
|
||
}
|
||
|
||
// 저장된 URL로 원본을 서버에서 yt-dlp로 받아 자동 전사. 성공 시 렌더도 업로드 없이 가능.
|
||
async function downloadOriginal(){
|
||
const status = document.getElementById('transcribeStatus');
|
||
const btn = document.getElementById('downloadBtn');
|
||
const orig = btn.innerHTML;
|
||
btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '다운로드 중…';
|
||
status.style.display = 'block'; status.style.color = '#facc15';
|
||
status.textContent = '원본 다운로드 중… (yt-dlp, 영상 길이에 따라 수십 초~수 분)';
|
||
// 1단계: 다운로드(빠름). 실패하면 여기서 종료.
|
||
try {
|
||
const d = await api(API + '/' + VIDEO_ID + '/download', { method:'POST' });
|
||
UPLOADED_FILE = null; // 다운로드본으로 전환(업로드본보다 우선순위 양보)
|
||
updateCacheUI(true, d.sizeBytes || 0); // 캐시 보유 → 삭제버튼 노출 + 왼쪽 플레이어를 받은 원본으로
|
||
const mb = d.sizeBytes ? ((d.sizeBytes/1048576).toFixed(1) + 'MB') : '';
|
||
status.style.color = '#4ade80';
|
||
status.textContent = '다운로드 완료 ✓ ' + mb + ' · 전사 중…';
|
||
} catch(e){
|
||
status.style.color = '#f87171';
|
||
status.textContent = '다운로드 실패: ' + e.message;
|
||
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
|
||
if(window.lucide) lucide.createIcons();
|
||
return;
|
||
}
|
||
// 2단계: 전사(분리). 실패해도 다운로드(영상)는 유지 → '전사' 버튼으로 재시도 가능.
|
||
try {
|
||
const s = await runTranscribeCached();
|
||
status.style.color = '#4ade80';
|
||
status.textContent = '다운로드+전사 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트'
|
||
+ (s.language ? (' · ' + s.language) : '')
|
||
+ (s.duration ? (' · ' + mmss(s.duration)) : '');
|
||
} catch(e){
|
||
status.style.color = '#fbbf24';
|
||
status.textContent = '다운로드는 완료 ✓ · 전사 실패(서버 혼잡일 수 있음): ' + e.message
|
||
+ ' — ‘전사’ 버튼으로 다시 시도하세요';
|
||
} finally {
|
||
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
|
||
if(window.lucide) lucide.createIcons();
|
||
}
|
||
}
|
||
|
||
// 받아둔 원본을 Whisper 로 전사(다운로드와 분리). 세그먼트를 렌더하고 응답을 반환.
|
||
async function runTranscribeCached(){
|
||
const lang = document.getElementById('langSel').value;
|
||
const url = API + '/' + VIDEO_ID + '/transcribe-cached' + (lang ? ('?language=' + lang) : '');
|
||
const s = await api(url, { method:'POST' });
|
||
renderSegments(s.segments);
|
||
document.getElementById('transcript').value = s.transcript || '';
|
||
return s;
|
||
}
|
||
|
||
// '전사' 버튼: 받아둔 원본만 다시 전사(전사만 실패했을 때 재시도용).
|
||
async function transcribeCached(){
|
||
const status = document.getElementById('transcribeStatus');
|
||
const btn = document.getElementById('transcribeCachedBtn');
|
||
const orig = btn.innerHTML; btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '전사 중…';
|
||
status.style.display = 'block'; status.style.color = '#facc15';
|
||
status.textContent = '전사 중… (Shorts 기준 보통 20~60초, 서버 혼잡 시 더 걸림)';
|
||
try {
|
||
const s = await runTranscribeCached();
|
||
status.style.color = '#4ade80';
|
||
status.textContent = '전사 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트'
|
||
+ (s.language ? (' · ' + s.language) : '')
|
||
+ (s.duration ? (' · ' + mmss(s.duration)) : '');
|
||
} catch(e){
|
||
status.style.color = '#f87171';
|
||
status.textContent = '전사 실패: ' + e.message;
|
||
} finally {
|
||
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
|
||
if(window.lucide) lucide.createIcons();
|
||
}
|
||
}
|
||
|
||
// 서버 캐시 보유 상태 → '받은 원본 삭제' 버튼 표시 + 렌더 캐시 사용 여부(HAS_SERVER_FILE) 갱신
|
||
function updateCacheUI(cached, sizeBytes){
|
||
HAS_SERVER_FILE = !!cached;
|
||
// 받아진 상태면 '원본 다운로드' 숨기고 '전사'·'받은 원본 삭제' 노출(없으면 반대)
|
||
const dl = document.getElementById('downloadBtn');
|
||
const tr = document.getElementById('transcribeCachedBtn');
|
||
const del = document.getElementById('deleteDlBtn');
|
||
if(dl) dl.style.display = cached ? 'none' : '';
|
||
if(tr) tr.style.display = cached ? '' : 'none';
|
||
if(del){
|
||
del.style.display = cached ? '' : 'none';
|
||
if(cached && sizeBytes){
|
||
del.title = '서버에 받아둔 원본(' + (sizeBytes/1048576).toFixed(1) + 'MB) 삭제(자막은 유지)';
|
||
}
|
||
}
|
||
// 업로드본이 없을 때만 플레이어를 서버 원본/YouTube로 전환(업로드본이 우선)
|
||
if(!UPLOADED_FILE){
|
||
if(cached) showServerVideoInPlayer();
|
||
else showYoutubeInPlayer();
|
||
}
|
||
}
|
||
|
||
// 받아둔 원본 mp4 삭제(자막/세그먼트는 유지)
|
||
async function deleteDownload(){
|
||
if(!confirm('서버에 받아둔 원본 영상(mp4)을 삭제할까요?\n자막·세그먼트는 그대로 유지됩니다.')) return;
|
||
const btn = document.getElementById('deleteDlBtn');
|
||
const status = document.getElementById('transcribeStatus');
|
||
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '삭제 중…';
|
||
try {
|
||
const r = await api(API + '/' + VIDEO_ID + '/download', { method:'DELETE' });
|
||
updateCacheUI(false, 0);
|
||
status.style.display = 'block'; status.style.color = '#9ca3af';
|
||
status.textContent = r && r.deleted ? '받은 원본을 삭제했습니다. (다시 받으려면 ‘원본 다운로드’)' : '삭제할 원본이 없습니다.';
|
||
} catch(e){
|
||
status.style.display = 'block'; status.style.color = '#f87171';
|
||
status.textContent = '원본 삭제 실패: ' + e.message;
|
||
} finally {
|
||
btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons();
|
||
}
|
||
}
|
||
|
||
async function onVideoSelected(ev){
|
||
const file = ev.target.files && ev.target.files[0];
|
||
if(!file) return;
|
||
UPLOADED_FILE = file; // 렌더(자르기) 재전송용으로 보관(업로드본이 서버 캐시보다 우선)
|
||
|
||
// 1) 업로드한 파일을 로컬에서 즉시 재생(<video>) + YouTube iframe 숨김
|
||
const v = document.getElementById('localVideo');
|
||
v.src = URL.createObjectURL(file);
|
||
v.style.display = 'block';
|
||
document.getElementById('player').style.display = 'none';
|
||
|
||
// 2) 서버로 보내 Whisper 전사
|
||
const status = document.getElementById('transcribeStatus');
|
||
const btn = document.getElementById('uploadBtn');
|
||
status.style.display = 'block'; status.style.color = '#facc15';
|
||
status.textContent = '전사 중… (Shorts 기준 보통 20~60초)';
|
||
btn.style.pointerEvents = 'none'; btn.style.opacity = '0.6';
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
const lang = document.getElementById('langSel').value;
|
||
if(lang) fd.append('language', lang);
|
||
const s = await api(API + '/' + VIDEO_ID + '/transcribe', { method:'POST', body: fd });
|
||
renderSegments(s.segments);
|
||
document.getElementById('transcript').value = s.transcript || '';
|
||
status.style.color = '#4ade80';
|
||
status.textContent = '전사 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트'
|
||
+ (s.language ? (' · ' + s.language) : '')
|
||
+ (s.duration ? (' · ' + mmss(s.duration)) : '');
|
||
} catch(e){
|
||
status.style.color = '#f87171';
|
||
status.textContent = '전사 실패: ' + e.message;
|
||
} finally {
|
||
btn.style.pointerEvents = ''; btn.style.opacity = '';
|
||
if(window.lucide) lucide.createIcons();
|
||
}
|
||
}
|
||
|
||
async function load(){
|
||
let v;
|
||
try { v = await api(API + '/' + VIDEO_ID); }
|
||
catch(e){ document.getElementById('metaLine').textContent = '영상을 불러오지 못했습니다: ' + e.message; return; }
|
||
|
||
document.getElementById('metaLine').textContent =
|
||
(v.isShorts?'Shorts · ':'') + '상태 ' + (v.interestStatus||'-') + (v.viewsPerSubRatio!=null?(' · 배율 '+Number(v.viewsPerSubRatio).toFixed(1)+'x'):'');
|
||
document.getElementById('vTitle').textContent = v.title || '';
|
||
document.getElementById('vChannel').textContent = v.channelTitle || '';
|
||
document.getElementById('vStats').innerHTML =
|
||
'<span>👁 '+fmt(v.viewCount)+'</span><span>👤 '+fmt(v.subscriberCount)+'</span><span>⚡ '+fmt(v.viewsPerHour)+'/h</span>';
|
||
document.getElementById('vLink').href = 'https://www.youtube.com/watch?v=' + v.videoId;
|
||
document.getElementById('player').innerHTML =
|
||
'<iframe width="100%" height="100%" src="https://www.youtube.com/embed/'+v.videoId+'" frameborder="0" allow="encrypted-media" allowfullscreen></iframe>';
|
||
document.getElementById('reworkText').value = v.reworkText || '';
|
||
|
||
// 스크립트(평문 + 영상 싱크 세그먼트)
|
||
try {
|
||
const s = await api(API + '/' + VIDEO_ID + '/script');
|
||
document.getElementById('transcript').value = s.hasScript ? s.transcript : '';
|
||
renderSegments(s.segments);
|
||
} catch(e){ /* ignore */ }
|
||
|
||
// 받아둔 원본(서버 캐시) 상태 → 삭제버튼/렌더 캐시 사용 갱신(이전 세션에 받아둔 것도 반영)
|
||
try {
|
||
const d = await api(API + '/' + VIDEO_ID + '/download');
|
||
updateCacheUI(!!d.cached, d.sizeBytes || 0);
|
||
} catch(e){ /* ignore */ }
|
||
|
||
// 업로드 영상 재생에 맞춰 현재 세그먼트 하이라이트
|
||
const lv = document.getElementById('localVideo');
|
||
lv.addEventListener('timeupdate', ()=> highlightAt(lv.currentTime));
|
||
|
||
if(window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
async function extractScript(){
|
||
const btn = document.getElementById('extractBtn');
|
||
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '추출 중...';
|
||
try {
|
||
const s = await api(API + '/' + VIDEO_ID + '/extract-script', { method:'POST' });
|
||
document.getElementById('transcript').value = s.transcript || '';
|
||
if(!s.transcript) alert('자막을 가져오지 못했습니다. (영상에 자막이 없거나 외부 서비스 차단)');
|
||
} catch(e){ alert('추출 실패: ' + e.message); }
|
||
finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); }
|
||
}
|
||
|
||
function copyToEditor(){
|
||
const t = document.getElementById('transcript').value;
|
||
if(!t){ alert('복사할 스크립트가 없습니다.'); return; }
|
||
const ed = document.getElementById('reworkText');
|
||
ed.value = ed.value ? (ed.value + '\n\n' + t) : t;
|
||
}
|
||
|
||
// 원본 스크립트를 한국어로 번역해 '재작성(내 버전)' 칸에 넣는다.
|
||
// format: 'plain'(평문 흐름) | 'timeline'([00:00] 세그먼트별 줄)
|
||
async function translateToEditor(format){
|
||
const tl = format === 'timeline';
|
||
const btn = document.getElementById(tl ? 'translateTlBtn' : 'translateBtn');
|
||
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '번역 중…';
|
||
try {
|
||
const url = API + '/' + VIDEO_ID + '/translate?target=ko' + (tl ? '&format=timeline' : '');
|
||
const r = await api(url, { method:'POST' });
|
||
const t = (r && r.translatedText) || '';
|
||
if(!t){ alert('번역 결과가 비었습니다. (원본 스크립트' + (tl ? '/세그먼트' : '') + '가 있는지 확인하세요)'); return; }
|
||
const ed = document.getElementById('reworkText');
|
||
ed.value = ed.value ? (ed.value + '\n\n' + t) : t;
|
||
ed.focus();
|
||
} catch(e){ alert('번역 실패: ' + e.message); }
|
||
finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); }
|
||
}
|
||
|
||
async function saveRework(){
|
||
const btn = document.getElementById('saveBtn');
|
||
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '저장 중...';
|
||
try {
|
||
const v = await api(API + '/' + VIDEO_ID + '/rework', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ reworkText: document.getElementById('reworkText').value })
|
||
});
|
||
const now = new Date();
|
||
document.getElementById('saveInfo').textContent = '저장됨 · 상태 ' + (v.interestStatus||'-') + ' · ' + now.toLocaleTimeString();
|
||
} catch(e){ alert('저장 실패: ' + e.message); }
|
||
finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); }
|
||
}
|
||
|
||
// ===== 발행 준비 =====
|
||
let pubId = null;
|
||
|
||
async function loadPublish(videoTitle){
|
||
let p = null;
|
||
try { p = await api('/api/v1/publish/by-video/' + VIDEO_ID); } catch(e){}
|
||
if(p){
|
||
pubId = p.id;
|
||
document.getElementById('pubTitle').value = p.title || '';
|
||
document.getElementById('pubDesc').value = p.description || '';
|
||
document.getElementById('pubTags').value = p.hashtags || '';
|
||
document.getElementById('pubPlatform').value = p.platform || 'YOUTUBE';
|
||
document.getElementById('pubStatus').value = p.status || 'DRAFT';
|
||
if(p.scheduledAt) document.getElementById('pubSchedule').value = String(p.scheduledAt).substring(0,16);
|
||
document.getElementById('pubStatusBadge').textContent = '상태: ' + (p.status||'DRAFT') + (p.publishedUrl?(' · '+p.publishedUrl):'');
|
||
} else {
|
||
// 신규: 제목 기본값으로 원본 제목 제안
|
||
document.getElementById('pubTitle').value = videoTitle || '';
|
||
document.getElementById('pubStatusBadge').textContent = '미작성';
|
||
}
|
||
}
|
||
|
||
async function savePublish(){
|
||
const btn = document.getElementById('pubSaveBtn');
|
||
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '저장 중...';
|
||
try {
|
||
const p = await api('/api/v1/publish/by-video/' + VIDEO_ID, {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({
|
||
title: document.getElementById('pubTitle').value,
|
||
description: document.getElementById('pubDesc').value,
|
||
hashtags: document.getElementById('pubTags').value,
|
||
platform: document.getElementById('pubPlatform').value,
|
||
scheduledAt: document.getElementById('pubSchedule').value,
|
||
status: document.getElementById('pubStatus').value
|
||
})
|
||
});
|
||
pubId = p.id;
|
||
document.getElementById('pubStatusBadge').textContent = '상태: ' + (p.status||'DRAFT');
|
||
document.getElementById('pubInfo').textContent = '저장됨 · ' + new Date().toLocaleTimeString();
|
||
} catch(e){ alert('저장 실패: ' + e.message); }
|
||
finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); }
|
||
}
|
||
|
||
async function markPublished(){
|
||
if(!pubId){ alert('먼저 발행안을 저장하세요.'); return; }
|
||
const url = prompt('업로드한 영상 URL을 입력하세요:');
|
||
if(url === null) return;
|
||
try {
|
||
const p = await api('/api/v1/publish/' + pubId + '/published', {
|
||
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url })
|
||
});
|
||
document.getElementById('pubStatus').value = 'PUBLISHED';
|
||
document.getElementById('pubStatusBadge').textContent = '상태: PUBLISHED · ' + (p.publishedUrl||'');
|
||
document.getElementById('pubInfo').textContent = '발행 완료 기록됨';
|
||
} catch(e){ alert('발행 완료 처리 실패: ' + e.message); }
|
||
}
|
||
|
||
function openHelp(){ document.getElementById('helpModal').classList.add('open'); }
|
||
function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); }
|
||
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeHelp(); });
|
||
|
||
// ?help=1 딥링크로 들어오면 사용법을 바로 펼친다(공유/안내용).
|
||
if (new URLSearchParams(location.search).get('help') === '1') openHelp();
|
||
|
||
(async ()=>{ await load(); await loadPublish(document.getElementById('vTitle').textContent); })();
|
||
/*]]>*/
|
||
</script>
|
||
</div>
|
||
</body>
|
||
|
||
</html>
|