h-lab/src/main/resources/templates/rework.html
hehihoho3@gmail.com 6380a28a6f fix(rework): Gemini 실패 사유를 사용자에게 명확히 + 음성 없음 메시지 정정
- GeminiSubtitleService: 403(프로젝트 차단)/429(한도)/400(키 무효)을 한국어 메시지로
  IllegalArgumentException throw → 'Internal Server Error' 대신 실제 사유 노출
- rework.html: 음성 패널 빈 경우 '말소리 없음(음악/화면자막만) 또는 전사 미실행'으로 정정

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:16:29 +09:00

900 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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()">&times;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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>