407 lines
18 KiB
Python
407 lines
18 KiB
Python
"""Inline frontend HTML for ClipForge."""
|
|
|
|
HTML = r"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>ClipForge</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0a0a0a; --surface: #141414; --border: #2a2a2a; --border-hover: #444;
|
|
--text: #e0e0e0; --text-dim: #888; --text-bright: #fff;
|
|
--accent: #8b5cf6; --accent-hover: #7c3aed; --accent-dim: #6d28d9;
|
|
--green: #4ade80; --yellow: #fbbf24; --red: #f87171; --blue: #60a5fa;
|
|
--radius: 12px;
|
|
}
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
|
.container { max-width: 800px; margin: 0 auto; padding: 2rem 1.5rem; }
|
|
header { text-align: center; margin-bottom: 2.5rem; }
|
|
header h1 { font-size: 2rem; color: var(--text-bright); margin-bottom: .25rem; }
|
|
header p { color: var(--text-dim); font-size: .95rem; }
|
|
|
|
/* Tabs */
|
|
.tabs { display: flex; gap: .5rem; margin-bottom: 1.5rem; }
|
|
.tab { flex: 1; padding: .75rem; background: var(--surface); border: 1px solid var(--border);
|
|
border-radius: var(--radius); cursor: pointer; text-align: center; font-size: .9rem;
|
|
color: var(--text-dim); transition: all .2s; }
|
|
.tab:hover { border-color: var(--border-hover); color: var(--text); }
|
|
.tab.active { border-color: var(--accent); color: var(--accent); background: #1a1025; }
|
|
|
|
/* URL Input */
|
|
.url-section { display: flex; gap: .75rem; margin-bottom: 1.5rem; }
|
|
.url-input { flex: 1; padding: .75rem 1rem; background: var(--surface); border: 1px solid var(--border);
|
|
border-radius: var(--radius); color: var(--text); font-size: .95rem; outline: none; transition: border-color .2s; }
|
|
.url-input:focus { border-color: var(--accent); }
|
|
.url-input::placeholder { color: #555; }
|
|
|
|
/* Upload Zone */
|
|
.upload-zone { border: 2px dashed var(--border); border-radius: var(--radius); padding: 3rem 2rem;
|
|
text-align: center; cursor: pointer; transition: all .2s; margin-bottom: 1.5rem; position: relative; }
|
|
.upload-zone:hover, .upload-zone.dragover { border-color: var(--accent); background: #1a1025; }
|
|
.upload-zone svg { width: 48px; height: 48px; color: var(--text-dim); margin-bottom: 1rem; }
|
|
.upload-zone p { color: var(--text-dim); margin-bottom: .25rem; }
|
|
.upload-zone .hint { font-size: .8rem; color: #555; }
|
|
.upload-zone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
|
.file-name { color: var(--accent); font-weight: 500; margin-top: .5rem; }
|
|
|
|
/* Button */
|
|
.btn { padding: .75rem 2rem; background: var(--accent); color: #fff; border: none; border-radius: var(--radius);
|
|
font-size: .95rem; font-weight: 600; cursor: pointer; transition: background .2s; width: 100%; }
|
|
.btn:hover:not(:disabled) { background: var(--accent-hover); }
|
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
|
|
|
/* Progress */
|
|
.progress-section { display: none; margin-bottom: 2rem; }
|
|
.progress-section.visible { display: block; }
|
|
.progress-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem; }
|
|
.progress-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
|
.progress-status { font-weight: 600; text-transform: capitalize; }
|
|
.progress-pct { color: var(--accent); font-weight: 700; font-size: 1.1rem; }
|
|
.progress-bar-bg { height: 6px; background: #222; border-radius: 3px; overflow: hidden; margin-bottom: .75rem; }
|
|
.progress-bar { height: 100%; background: linear-gradient(90deg, var(--accent-dim), var(--accent)); border-radius: 3px;
|
|
transition: width .3s ease; width: 0%; }
|
|
.progress-msg { color: var(--text-dim); font-size: .85rem; }
|
|
.progress-status.complete { color: var(--green); }
|
|
.progress-status.failed { color: var(--red); }
|
|
|
|
/* Clips */
|
|
.clips-section { display: none; }
|
|
.clips-section.visible { display: block; }
|
|
.clips-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
|
.clips-header h2 { font-size: 1.25rem; color: var(--text-bright); }
|
|
.clips-count { color: var(--text-dim); font-size: .85rem; }
|
|
.clip-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
|
padding: 1.25rem; margin-bottom: .75rem; transition: border-color .2s; }
|
|
.clip-card:hover { border-color: var(--border-hover); }
|
|
.clip-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: .75rem; }
|
|
.clip-title { font-weight: 600; color: var(--text-bright); font-size: 1rem; }
|
|
.clip-score { display: flex; align-items: center; gap: .35rem; font-weight: 700; font-size: .95rem; white-space: nowrap; }
|
|
.clip-meta { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: .5rem; font-size: .8rem; color: var(--text-dim); }
|
|
.clip-meta span { display: flex; align-items: center; gap: .25rem; }
|
|
.clip-badge { display: inline-block; padding: .15rem .5rem; border-radius: 6px; font-size: .75rem;
|
|
font-weight: 600; text-transform: uppercase; letter-spacing: .03em; }
|
|
.badge-hook { background: #7c3aed22; color: #a78bfa; }
|
|
.badge-story { background: #2563eb22; color: #93c5fd; }
|
|
.badge-insight { background: #0891b222; color: #67e8f9; }
|
|
.badge-humor { background: #ca8a0422; color: #fcd34d; }
|
|
.badge-emotional { background: #db277822; color: #f9a8d4; }
|
|
.badge-controversial { background: #dc262622; color: #fca5a5; }
|
|
.badge-educational { background: #16a34a22; color: #86efac; }
|
|
.badge-general { background: #52525b22; color: #a1a1aa; }
|
|
.clip-reasoning { color: var(--text-dim); font-size: .85rem; line-height: 1.5; margin-top: .5rem; }
|
|
.clip-transcript { background: #0a0a0a; border-radius: 8px; padding: .75rem 1rem; margin-top: .75rem;
|
|
font-size: .8rem; color: #aaa; line-height: 1.5; font-style: italic; max-height: 80px; overflow-y: auto; }
|
|
.clip-actions { display: flex; gap: .5rem; margin-top: .75rem; }
|
|
.clip-btn { padding: .4rem .75rem; background: transparent; border: 1px solid var(--border); border-radius: 8px;
|
|
color: var(--text-dim); font-size: .8rem; cursor: pointer; transition: all .2s; text-decoration: none; }
|
|
.clip-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
|
|
/* History */
|
|
.history-section { margin-top: 2.5rem; border-top: 1px solid var(--border); padding-top: 1.5rem; }
|
|
.history-section h3 { font-size: 1rem; color: var(--text-dim); margin-bottom: 1rem; }
|
|
.history-item { display: flex; justify-content: space-between; align-items: center;
|
|
padding: .75rem 1rem; background: var(--surface); border: 1px solid var(--border);
|
|
border-radius: 8px; margin-bottom: .5rem; cursor: pointer; transition: border-color .2s; }
|
|
.history-item:hover { border-color: var(--border-hover); }
|
|
.history-title { font-size: .9rem; color: var(--text); }
|
|
.history-info { display: flex; gap: .75rem; align-items: center; font-size: .8rem; color: var(--text-dim); }
|
|
.history-badge { padding: .15rem .4rem; border-radius: 4px; font-size: .7rem; font-weight: 600; text-transform: uppercase; }
|
|
.history-badge.complete { background: #16a34a22; color: var(--green); }
|
|
.history-badge.failed { background: #dc262622; color: var(--red); }
|
|
.history-badge.pending, .history-badge.downloading, .history-badge.transcribing,
|
|
.history-badge.analyzing, .history-badge.extracting { background: #ca8a0422; color: var(--yellow); }
|
|
|
|
.new-btn { display: none; margin-bottom: 1.5rem; }
|
|
.new-btn.visible { display: block; }
|
|
.new-btn button { padding: .5rem 1.25rem; background: transparent; border: 1px solid var(--border);
|
|
border-radius: var(--radius); color: var(--text-dim); font-size: .85rem; cursor: pointer; transition: all .2s; }
|
|
.new-btn button:hover { border-color: var(--accent); color: var(--accent); }
|
|
|
|
@media (max-width: 600px) {
|
|
.url-section { flex-direction: column; }
|
|
.container { padding: 1.5rem 1rem; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1>ClipForge</h1>
|
|
<p>AI-powered video clipper — find the best moments automatically</p>
|
|
</header>
|
|
|
|
<div class="new-btn" id="newBtn">
|
|
<button onclick="resetUI()">+ New clip job</button>
|
|
</div>
|
|
|
|
<div id="inputSection">
|
|
<div class="tabs">
|
|
<div class="tab active" data-tab="url" onclick="switchTab('url')">YouTube URL</div>
|
|
<div class="tab" data-tab="upload" onclick="switchTab('upload')">Upload Video</div>
|
|
</div>
|
|
|
|
<div id="tabUrl">
|
|
<div class="url-section">
|
|
<input type="text" class="url-input" id="urlInput" placeholder="https://www.youtube.com/watch?v=..." />
|
|
</div>
|
|
<button class="btn" id="urlBtn" onclick="submitUrl()">Extract Clips</button>
|
|
</div>
|
|
|
|
<div id="tabUpload" style="display:none">
|
|
<div class="upload-zone" id="dropZone">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M12 16V4m0 0l-4 4m4-4l4 4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/>
|
|
</svg>
|
|
<p>Drop a video file here or click to browse</p>
|
|
<p class="hint">MP4, MOV, AVI, MKV up to 2 hours</p>
|
|
<div class="file-name" id="fileName"></div>
|
|
<input type="file" id="fileInput" accept="video/*" onchange="handleFile(this)" />
|
|
</div>
|
|
<button class="btn" id="uploadBtn" onclick="submitUpload()" disabled>Upload & Extract Clips</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="progress-section" id="progressSection">
|
|
<div class="progress-card">
|
|
<div class="progress-header">
|
|
<span class="progress-status" id="progressStatus">Pending</span>
|
|
<span class="progress-pct" id="progressPct">0%</span>
|
|
</div>
|
|
<div class="progress-bar-bg"><div class="progress-bar" id="progressBar"></div></div>
|
|
<div class="progress-msg" id="progressMsg">Waiting...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="clips-section" id="clipsSection">
|
|
<div class="clips-header">
|
|
<h2>Clips Found</h2>
|
|
<span class="clips-count" id="clipsCount"></span>
|
|
</div>
|
|
<div id="clipsList"></div>
|
|
</div>
|
|
|
|
<div class="history-section" id="historySection">
|
|
<h3>Recent Jobs</h3>
|
|
<div id="historyList"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API = '';
|
|
let currentJobId = null;
|
|
let eventSource = null;
|
|
let selectedFile = null;
|
|
|
|
function switchTab(tab) {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
|
document.getElementById('tabUrl').style.display = tab === 'url' ? '' : 'none';
|
|
document.getElementById('tabUpload').style.display = tab === 'upload' ? '' : 'none';
|
|
}
|
|
|
|
function handleFile(input) {
|
|
selectedFile = input.files[0];
|
|
if (selectedFile) {
|
|
document.getElementById('fileName').textContent = selectedFile.name;
|
|
document.getElementById('uploadBtn').disabled = false;
|
|
}
|
|
}
|
|
|
|
// Drag & drop
|
|
const dz = document.getElementById('dropZone');
|
|
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('dragover'); });
|
|
dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
|
|
dz.addEventListener('drop', e => {
|
|
e.preventDefault(); dz.classList.remove('dragover');
|
|
if (e.dataTransfer.files.length) {
|
|
document.getElementById('fileInput').files = e.dataTransfer.files;
|
|
handleFile(document.getElementById('fileInput'));
|
|
}
|
|
});
|
|
|
|
async function submitUrl() {
|
|
const url = document.getElementById('urlInput').value.trim();
|
|
if (!url) return;
|
|
document.getElementById('urlBtn').disabled = true;
|
|
try {
|
|
const res = await fetch(API + '/api/jobs', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({source_type: 'youtube', source_url: url})
|
|
});
|
|
const job = await res.json();
|
|
if (!res.ok) throw new Error(job.detail || 'Failed to create job');
|
|
startTracking(job.id);
|
|
} catch (e) {
|
|
alert('Error: ' + e.message);
|
|
document.getElementById('urlBtn').disabled = false;
|
|
}
|
|
}
|
|
|
|
async function submitUpload() {
|
|
if (!selectedFile) return;
|
|
document.getElementById('uploadBtn').disabled = true;
|
|
const form = new FormData();
|
|
form.append('file', selectedFile);
|
|
try {
|
|
const res = await fetch(API + '/api/jobs/upload', {method: 'POST', body: form});
|
|
const job = await res.json();
|
|
if (!res.ok) throw new Error(job.detail || 'Failed to upload');
|
|
startTracking(job.id);
|
|
} catch (e) {
|
|
alert('Error: ' + e.message);
|
|
document.getElementById('uploadBtn').disabled = false;
|
|
}
|
|
}
|
|
|
|
function startTracking(jobId) {
|
|
currentJobId = jobId;
|
|
document.getElementById('inputSection').style.display = 'none';
|
|
document.getElementById('newBtn').classList.add('visible');
|
|
const ps = document.getElementById('progressSection');
|
|
ps.classList.add('visible');
|
|
document.getElementById('clipsSection').classList.remove('visible');
|
|
|
|
// Connect SSE
|
|
if (eventSource) eventSource.close();
|
|
eventSource = new EventSource(API + '/api/jobs/' + jobId + '/progress');
|
|
eventSource.addEventListener('progress', e => {
|
|
const d = JSON.parse(e.data);
|
|
updateProgress(d);
|
|
if (d.status === 'complete' || d.status === 'failed') {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
if (d.status === 'complete') loadClips(jobId);
|
|
loadHistory();
|
|
}
|
|
});
|
|
eventSource.onerror = () => {
|
|
// SSE disconnected, fall back to polling
|
|
if (eventSource) eventSource.close();
|
|
eventSource = null;
|
|
pollJob(jobId);
|
|
};
|
|
}
|
|
|
|
function updateProgress(d) {
|
|
const pct = Math.round(d.progress * 100);
|
|
document.getElementById('progressBar').style.width = pct + '%';
|
|
document.getElementById('progressPct').textContent = pct + '%';
|
|
const statusEl = document.getElementById('progressStatus');
|
|
statusEl.textContent = d.status;
|
|
statusEl.className = 'progress-status ' + d.status;
|
|
document.getElementById('progressMsg').textContent = d.stage_message || '';
|
|
}
|
|
|
|
async function pollJob(jobId) {
|
|
try {
|
|
const res = await fetch(API + '/api/jobs/' + jobId);
|
|
const job = await res.json();
|
|
updateProgress({status: job.status, progress: job.progress, stage_message: job.stage_message});
|
|
if (job.status === 'complete') { loadClips(jobId); loadHistory(); }
|
|
else if (job.status === 'failed') { loadHistory(); }
|
|
else setTimeout(() => pollJob(jobId), 2000);
|
|
} catch { setTimeout(() => pollJob(jobId), 3000); }
|
|
}
|
|
|
|
async function loadClips(jobId) {
|
|
const res = await fetch(API + '/api/jobs/' + jobId + '/clips');
|
|
const clips = await res.json();
|
|
const section = document.getElementById('clipsSection');
|
|
section.classList.add('visible');
|
|
document.getElementById('clipsCount').textContent = clips.length + ' clip' + (clips.length !== 1 ? 's' : '');
|
|
|
|
const list = document.getElementById('clipsList');
|
|
list.innerHTML = clips.map(c => {
|
|
const dur = (c.end_time - c.start_time).toFixed(1);
|
|
const badge = 'badge-' + (c.category || 'general');
|
|
const scoreColor = c.virality_score >= 75 ? 'var(--green)' : c.virality_score >= 50 ? 'var(--yellow)' : 'var(--text-dim)';
|
|
return '<div class="clip-card">' +
|
|
'<div class="clip-top">' +
|
|
'<span class="clip-title">' + esc(c.title) + '</span>' +
|
|
'<span class="clip-score" style="color:' + scoreColor + '">' +
|
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14l-5-4.87 6.91-1.01z"/></svg>' +
|
|
c.virality_score + '</span>' +
|
|
'</div>' +
|
|
'<div class="clip-meta">' +
|
|
'<span class="clip-badge ' + badge + '">' + esc(c.category || 'general') + '</span>' +
|
|
'<span>' + fmtTime(c.start_time) + ' - ' + fmtTime(c.end_time) + '</span>' +
|
|
'<span>' + dur + 's</span>' +
|
|
'</div>' +
|
|
(c.reasoning ? '<div class="clip-reasoning">' + esc(c.reasoning) + '</div>' : '') +
|
|
(c.transcript_segment ? '<div class="clip-transcript">"' + esc(c.transcript_segment) + '"</div>' : '') +
|
|
'<div class="clip-actions">' +
|
|
'<a class="clip-btn" href="' + API + '/api/clips/' + c.id + '/preview" target="_blank">Preview</a>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
async function loadHistory() {
|
|
try {
|
|
const res = await fetch(API + '/api/jobs?limit=10');
|
|
const jobs = await res.json();
|
|
const list = document.getElementById('historyList');
|
|
list.innerHTML = jobs.map(j => {
|
|
const title = j.title || j.source_filename || j.source_url || 'Job';
|
|
const shortTitle = title.length > 50 ? title.slice(0, 50) + '...' : title;
|
|
const time = new Date(j.created_at).toLocaleString();
|
|
return '<div class="history-item" onclick="viewJob(\'' + j.id + '\',\'' + j.status + '\')">' +
|
|
'<span class="history-title">' + esc(shortTitle) + '</span>' +
|
|
'<div class="history-info">' +
|
|
'<span class="history-badge ' + j.status + '">' + j.status + '</span>' +
|
|
'<span>' + time + '</span>' +
|
|
'</div></div>';
|
|
}).join('');
|
|
} catch {}
|
|
}
|
|
|
|
async function viewJob(jobId, status) {
|
|
currentJobId = jobId;
|
|
document.getElementById('inputSection').style.display = 'none';
|
|
document.getElementById('newBtn').classList.add('visible');
|
|
document.getElementById('clipsSection').classList.remove('visible');
|
|
|
|
if (status === 'complete') {
|
|
const res = await fetch(API + '/api/jobs/' + jobId);
|
|
const job = await res.json();
|
|
updateProgress({status: job.status, progress: job.progress, stage_message: job.stage_message});
|
|
document.getElementById('progressSection').classList.add('visible');
|
|
loadClips(jobId);
|
|
} else if (status === 'failed') {
|
|
const res = await fetch(API + '/api/jobs/' + jobId);
|
|
const job = await res.json();
|
|
updateProgress({status: job.status, progress: job.progress, stage_message: job.error_message || job.stage_message});
|
|
document.getElementById('progressSection').classList.add('visible');
|
|
} else {
|
|
document.getElementById('progressSection').classList.add('visible');
|
|
startTracking(jobId);
|
|
}
|
|
}
|
|
|
|
function resetUI() {
|
|
document.getElementById('inputSection').style.display = '';
|
|
document.getElementById('newBtn').classList.remove('visible');
|
|
document.getElementById('progressSection').classList.remove('visible');
|
|
document.getElementById('clipsSection').classList.remove('visible');
|
|
document.getElementById('urlBtn').disabled = false;
|
|
document.getElementById('uploadBtn').disabled = !selectedFile;
|
|
document.getElementById('urlInput').value = '';
|
|
if (eventSource) { eventSource.close(); eventSource = null; }
|
|
}
|
|
|
|
function fmtTime(s) {
|
|
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
|
return m + ':' + String(sec).padStart(2, '0');
|
|
}
|
|
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
|
|
// URL enter key
|
|
document.getElementById('urlInput').addEventListener('keydown', e => { if (e.key === 'Enter') submitUrl(); });
|
|
|
|
// Load history on page load
|
|
loadHistory();
|
|
</script>
|
|
</body>
|
|
</html>"""
|