328 lines
18 KiB
HTML
328 lines
18 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>
|
|
<style>
|
|
.sf-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
|
.sf-form input { flex: 1; }
|
|
.sf-tabs { display:flex; gap:.35rem; border-bottom:1px solid var(--border); margin-bottom:1rem; }
|
|
.sf-tab { border:0; border-bottom:2px solid transparent; background:transparent; color:var(--text-2); padding:.7rem 1rem; font:600 .9rem/1 inherit; cursor:pointer; }
|
|
.sf-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
|
|
.sf-tab:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; border-radius:var(--r-sm); }
|
|
.sf-category { font-size:.68rem; padding:.18rem .45rem; border-radius:99px; background:var(--surface-2); color:var(--text-2); font-weight:700; }
|
|
.sf-category.POLITICS { background:rgba(68, 94, 160, .12); color:#445ea0; }
|
|
.sf-paste { display: none; margin-bottom: 1rem; }
|
|
.sf-paste.open { display: block; }
|
|
.sf-paste textarea { width: 100%; min-height: 160px; font-family: monospace; font-size: 0.8rem; }
|
|
.sf-job { border: 1px solid var(--border); border-radius: 10px; margin-bottom: 0.75rem; overflow: hidden; }
|
|
.sf-job-head { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.9rem; cursor: pointer; }
|
|
.sf-job-head img { width: 96px; border-radius: 6px; flex-shrink: 0; }
|
|
.sf-job-title { flex: 1; min-width: 0; }
|
|
.sf-job-title .t { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.sf-job-title .u { font-size: 0.75rem; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.sf-badge { font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 99px; font-weight: 600; flex-shrink: 0; }
|
|
.sf-badge.PENDING { background: rgba(196, 122, 18, 0.15); color: var(--warning); }
|
|
.sf-badge.DONE { background: rgba(var(--success-rgb), 0.15); color: var(--success); }
|
|
.sf-badge.FAILED { background: rgba(var(--danger-rgb), 0.15); color: var(--danger); }
|
|
.sf-subtitle { font-size:.7rem; color:var(--text-2); white-space:nowrap; }
|
|
.sf-subtitle.READY { color:var(--success); }
|
|
.sf-subtitle.FAILED, .sf-subtitle.UNAVAILABLE { color:var(--danger); }
|
|
.sf-clips { display: none; padding: 0.75rem 0.9rem; border-top: 1px solid var(--border); }
|
|
.sf-job.open .sf-clips { display: block; }
|
|
.sf-clip { border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 0.6rem; }
|
|
.sf-clip-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; flex-wrap: wrap; }
|
|
.sf-clip-head .no { font-weight: 700; }
|
|
.sf-clip pre { font-size: 0.72rem; white-space: pre-wrap; background: rgba(127, 127, 127, 0.08); border-radius: 6px; padding: 0.5rem; margin: 0.35rem 0; max-height: 180px; overflow-y: auto; }
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div layout:fragment="content">
|
|
<div class="page-header">
|
|
<div>
|
|
<h1>숏폼 큐</h1>
|
|
<p class="sub">유튜브 URL을 등록하면 Opal 숏폼 생성기 결과를 구간별로 저장·조회합니다.</p>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn btn-secondary" onclick="togglePaste()">
|
|
<i data-lucide="clipboard-paste" style="width:15px;"></i> 결과 붙여넣기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<form class="sf-form" onsubmit="registerJob(event)">
|
|
<input type="text" id="urlInput" placeholder="https://www.youtube.com/watch?v=..." required>
|
|
<button class="btn btn-primary" type="submit">큐에 등록</button>
|
|
</form>
|
|
|
|
<div class="sf-tabs" role="tablist" aria-label="콘텐츠 분류">
|
|
<button class="sf-tab active" id="sfTab-ENTERTAINMENT" role="tab" aria-selected="true"
|
|
onclick="switchCategory('ENTERTAINMENT')">예능</button>
|
|
<button class="sf-tab" id="sfTab-POLITICS" role="tab" aria-selected="false"
|
|
onclick="switchCategory('POLITICS')">정치</button>
|
|
</div>
|
|
|
|
<div class="sf-paste" id="pasteBox">
|
|
<input type="text" id="pasteUrl" placeholder="영상 URL" style="margin-bottom:0.5rem;width:100%;">
|
|
<textarea id="pasteRaw" placeholder="Opal Output 전체를 붙여넣으세요 (1===== ... 5===== 전부)"></textarea>
|
|
<button class="btn btn-primary" style="margin-top:0.5rem;" onclick="importResult()">파싱해서 저장</button>
|
|
</div>
|
|
|
|
<div id="jobList"></div>
|
|
</div>
|
|
|
|
<th:block layout:fragment="script">
|
|
<script>
|
|
let currentCategory = new URLSearchParams(location.search).get('category') === 'POLITICS'
|
|
? 'POLITICS' : 'ENTERTAINMENT';
|
|
let subtitlePollTimer = null;
|
|
const openJobIds = new Set();
|
|
|
|
async function api(path, opts) {
|
|
const res = await fetch(path, Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts));
|
|
const body = await res.json();
|
|
if (!body.success) throw new Error(body.message);
|
|
return body.data;
|
|
}
|
|
|
|
function togglePaste() {
|
|
document.getElementById('pasteBox').classList.toggle('open');
|
|
}
|
|
|
|
function switchCategory(category) {
|
|
currentCategory = category;
|
|
document.querySelectorAll('.sf-tab').forEach(tab => {
|
|
const active = tab.id === 'sfTab-' + category;
|
|
tab.classList.toggle('active', active);
|
|
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
});
|
|
const q = new URLSearchParams(location.search);
|
|
q.set('category', category);
|
|
history.replaceState(null, '', location.pathname + '?' + q.toString());
|
|
loadJobs();
|
|
}
|
|
|
|
async function registerJob(e) {
|
|
e.preventDefault();
|
|
const youtubeUrl = document.getElementById('urlInput').value.trim();
|
|
try {
|
|
await api('/api/shortform/jobs', { method: 'POST',
|
|
body: JSON.stringify({ youtubeUrl, category: currentCategory }) });
|
|
document.getElementById('urlInput').value = '';
|
|
loadJobs();
|
|
} catch (err) { alert(err.message); }
|
|
}
|
|
|
|
async function importResult() {
|
|
const youtubeUrl = document.getElementById('pasteUrl').value.trim();
|
|
const rawText = document.getElementById('pasteRaw').value;
|
|
if (!youtubeUrl || !rawText.trim()) { alert('URL과 결과 원문을 모두 입력하세요'); return; }
|
|
try {
|
|
await api('/api/shortform/import', { method: 'POST',
|
|
body: JSON.stringify({ youtubeUrl, rawText, category: currentCategory }) });
|
|
document.getElementById('pasteUrl').value = '';
|
|
document.getElementById('pasteRaw').value = '';
|
|
togglePaste();
|
|
loadJobs();
|
|
} catch (err) { alert(err.message); }
|
|
}
|
|
|
|
async function deleteJob(id, e) {
|
|
e.stopPropagation();
|
|
if (!confirm('이 작업을 삭제할까요?')) return;
|
|
await api('/api/shortform/jobs/' + id, { method: 'DELETE' });
|
|
openJobIds.delete(id);
|
|
loadJobs();
|
|
}
|
|
|
|
// 결과만 지우고 대기로 되돌린다. 등록 URL은 남으므로 큐를 다시 돌리면 그대로 재실행된다.
|
|
async function resetJob(id, e) {
|
|
e.stopPropagation();
|
|
if (!confirm('결과를 지우고 다시 대기 상태로 되돌릴까요?\n'
|
|
+ '클립과 Output 원문이 삭제됩니다. 등록된 영상은 그대로 남습니다.')) return;
|
|
try {
|
|
await api('/api/shortform/jobs/' + id + '/reset', { method: 'POST' });
|
|
loadJobs();
|
|
} catch (err) { alert('되돌리기 실패: ' + err.message); }
|
|
}
|
|
|
|
async function toggleJob(id, el, restoreOpen = false) {
|
|
if (el.classList.contains('open')) {
|
|
if (!restoreOpen) {
|
|
el.classList.remove('open');
|
|
openJobIds.delete(id);
|
|
}
|
|
return;
|
|
}
|
|
const detail = await api('/api/shortform/jobs/' + id);
|
|
const toolbar = `<div class="sf-clip-head" style="margin-bottom:0.6rem;">
|
|
<button class="btn btn-secondary" data-copy="${encodeURIComponent(detail.youtubeUrl || '')}"
|
|
data-complete-on-copy>영상 URL 복사</button>
|
|
<button class="btn btn-secondary" data-subtitle-copy
|
|
data-complete-on-copy ${detail.subtitleVtt ? '' : 'disabled'}>타임스탬프 자막 복사</button>
|
|
${detail.subtitleStatus === 'READY' || detail.subtitleStatus === 'COLLECTING' ? '' : `
|
|
<button class="btn btn-secondary" onclick="retrySubtitles(${detail.id}, event)">자막 다시 수집</button>`}
|
|
</div>`;
|
|
const step1Block = detail.step1Output
|
|
? `<details class="sf-clip" style="margin-bottom:0.6rem;">
|
|
<summary style="cursor:pointer;font-weight:600;">Step 1: 하이라이트 후보 구간 (선정 이유 포함)</summary>
|
|
<pre>${esc(detail.step1Output)}</pre>
|
|
</details>`
|
|
: '';
|
|
el.querySelector('.sf-clips').innerHTML = toolbar + step1Block + (detail.clips.length
|
|
? detail.clips.map(clipHtml).join('')
|
|
: '<p style="color:var(--text-3);">저장된 클립이 없습니다. 결과 붙여넣기 또는 "숏폼 큐 돌려줘"로 채우세요.</p>');
|
|
el.classList.add('open');
|
|
openJobIds.add(id);
|
|
el.querySelectorAll('[data-copy]').forEach(btn => {
|
|
const label = btn.dataset.label || 'capcut2 JSON 복사';
|
|
btn.onclick = async (e) => {
|
|
e.stopPropagation();
|
|
await navigator.clipboard.writeText(decodeURIComponent(btn.dataset.copy));
|
|
if (btn.hasAttribute('data-complete-on-copy')) {
|
|
await completeCopiedJob(id, el, btn);
|
|
} else {
|
|
btn.textContent = '복사됨!';
|
|
setTimeout(() => btn.textContent = label, 1200);
|
|
}
|
|
};
|
|
});
|
|
const subtitleButton = el.querySelector('[data-subtitle-copy]');
|
|
if (subtitleButton && detail.subtitleVtt) {
|
|
subtitleButton.onclick = async (e) => {
|
|
e.stopPropagation();
|
|
await navigator.clipboard.writeText(detail.subtitleVtt);
|
|
await completeCopiedJob(id, el, subtitleButton);
|
|
};
|
|
}
|
|
}
|
|
|
|
function esc(s) {
|
|
return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
async function completeCopiedJob(id, card, button) {
|
|
await api('/api/shortform/jobs/' + id + '/complete', { method: 'POST' });
|
|
button.textContent = '완료';
|
|
button.disabled = true;
|
|
const badge = card.querySelector('.sf-badge');
|
|
if (badge) {
|
|
badge.className = 'sf-badge DONE';
|
|
badge.textContent = 'DONE';
|
|
}
|
|
}
|
|
|
|
function clipHtml(c) {
|
|
return `<div class="sf-clip">
|
|
<div class="sf-clip-head">
|
|
<span class="no">ID ${c.clipNo}</span>
|
|
<span>${esc(c.titleTop || '')} / <b>${esc(c.titleMain || '(타이틀 파싱 실패)')}</b></span>
|
|
<span style="flex:1"></span>
|
|
<button class="btn btn-secondary" data-copy="${encodeURIComponent(c.capcutJson || '')}"
|
|
${c.capcutJson ? '' : 'disabled'}>capcut2 JSON 복사</button>
|
|
</div>
|
|
${c.titleCandidates ? `<pre>${esc(c.titleCandidates)}</pre>` : ''}
|
|
${c.durationTable ? `<pre>${esc(c.durationTable)}</pre>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
async function retrySubtitles(id, e) {
|
|
e.stopPropagation();
|
|
try {
|
|
await api('/api/shortform/jobs/' + id + '/subtitles/retry', { method: 'POST' });
|
|
loadJobs();
|
|
} catch (err) { alert('자막 수집 요청 실패: ' + err.message); }
|
|
}
|
|
|
|
async function loadJobs() {
|
|
const jobs = await api('/api/shortform/jobs?category=' + currentCategory);
|
|
const list = document.getElementById('jobList');
|
|
list.innerHTML = jobs.length ? '' : '<p style="color:var(--text-3);">등록된 작업이 없습니다.</p>';
|
|
jobs.forEach(j => {
|
|
const el = document.createElement('div');
|
|
el.className = 'sf-job';
|
|
el.dataset.jobId = j.id;
|
|
el.innerHTML = `<div class="sf-job-head">
|
|
<img src="https://img.youtube.com/vi/${j.videoId}/mqdefault.jpg" alt="">
|
|
<div class="sf-job-title">
|
|
<div class="t">${esc(j.videoTitle || j.videoId)}</div>
|
|
<div class="u">${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개</div>
|
|
<div class="sf-subtitle ${j.subtitleStatus}" data-subtitle-status="${j.subtitleStatus}"
|
|
title="${esc(j.subtitleError || '')}">자막 ${subtitleLabel(j)}</div>
|
|
</div>
|
|
<span class="sf-category ${j.category}">${j.category === 'POLITICS' ? '정치' : '예능'}</span>
|
|
<span class="sf-badge ${j.status}">${j.status}</span>
|
|
${j.status === 'PENDING' ? '' : `
|
|
<button class="btn btn-secondary" onclick="resetJob(${j.id}, event)"
|
|
title="결과를 지우고 다시 대기로 — 큐를 돌리면 재실행됩니다">
|
|
<i data-lucide="rotate-ccw" style="width:14px;"></i></button>`}
|
|
<button class="btn btn-secondary" onclick="deleteJob(${j.id}, event)"
|
|
title="작업 삭제">
|
|
<i data-lucide="trash-2" style="width:14px;"></i></button>
|
|
</div>
|
|
<div class="sf-clips"></div>`;
|
|
el.querySelector('.sf-job-head').onclick = () => toggleJob(j.id, el);
|
|
list.appendChild(el);
|
|
if (openJobIds.has(j.id)) {
|
|
toggleJob(j.id, el, true).catch(() => openJobIds.delete(j.id));
|
|
}
|
|
});
|
|
if (window.lucide) lucide.createIcons();
|
|
scheduleSubtitlePoll(jobs);
|
|
}
|
|
|
|
function scheduleSubtitlePoll(jobs) {
|
|
clearTimeout(subtitlePollTimer);
|
|
if (jobs.some(j => j.subtitleStatus === 'COLLECTING')) {
|
|
subtitlePollTimer = setTimeout(pollSubtitleStatuses, 2000);
|
|
}
|
|
}
|
|
|
|
async function pollSubtitleStatuses() {
|
|
try {
|
|
const jobs = await api('/api/shortform/jobs?category=' + currentCategory);
|
|
jobs.forEach(j => {
|
|
const card = document.querySelector(`.sf-job[data-job-id="${j.id}"]`);
|
|
const status = card && card.querySelector('[data-subtitle-status]');
|
|
if (!status) return;
|
|
const previous = status.dataset.subtitleStatus;
|
|
status.dataset.subtitleStatus = j.subtitleStatus;
|
|
status.className = 'sf-subtitle ' + j.subtitleStatus;
|
|
status.title = j.subtitleError || '';
|
|
status.textContent = '자막 ' + subtitleLabel(j);
|
|
if (previous === 'COLLECTING' && j.subtitleStatus === 'READY' && card.classList.contains('open')) {
|
|
refreshSubtitleCopy(j.id, card);
|
|
}
|
|
});
|
|
scheduleSubtitlePoll(jobs);
|
|
} catch (err) {
|
|
subtitlePollTimer = setTimeout(pollSubtitleStatuses, 5000);
|
|
}
|
|
}
|
|
|
|
async function refreshSubtitleCopy(id, card) {
|
|
const detail = await api('/api/shortform/jobs/' + id);
|
|
const button = card.querySelector('[data-subtitle-copy]');
|
|
if (!button || !detail.subtitleVtt) return;
|
|
button.disabled = false;
|
|
button.onclick = async (e) => {
|
|
e.stopPropagation();
|
|
await navigator.clipboard.writeText(detail.subtitleVtt);
|
|
await completeCopiedJob(id, card, button);
|
|
};
|
|
}
|
|
|
|
function subtitleLabel(job) {
|
|
if (job.subtitleStatus === 'COLLECTING' && job.subtitleError) return job.subtitleError;
|
|
return ({NOT_REQUESTED:'대기', COLLECTING:'수집 중…', READY:'준비 완료',
|
|
UNAVAILABLE:'없음', FAILED:'실패'})[job.subtitleStatus] || '대기';
|
|
}
|
|
|
|
switchCategory(currentCategory);
|
|
</script>
|
|
</th:block>
|
|
</body>
|
|
</html>
|