clip-forge/backend/app/frontend.py

499 lines
24 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-video { width: 100%; border-radius: 8px; margin-top: .75rem; background: #000; }
.clip-actions { display: flex; gap: .5rem; margin-top: .75rem; flex-wrap: wrap; align-items: center; }
.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; display: inline-flex; align-items: center; gap: .3rem; }
.clip-btn:hover { border-color: var(--accent); color: var(--accent); }
.clip-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.clip-btn.primary:hover { background: var(--accent-hover); }
.render-options { display: none; margin-top: .75rem; padding: .75rem; background: #0d0d0d; border: 1px solid var(--border); border-radius: 8px; }
.render-options.visible { display: block; }
.render-row { display: flex; gap: .75rem; align-items: center; margin-bottom: .5rem; flex-wrap: wrap; }
.render-row:last-child { margin-bottom: 0; }
.render-label { font-size: .8rem; color: var(--text-dim); min-width: 70px; }
.render-select { padding: .35rem .5rem; background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
color: var(--text); font-size: .8rem; outline: none; cursor: pointer; }
.render-select:focus { border-color: var(--accent); }
.style-preview { display: flex; gap: .5rem; flex-wrap: wrap; }
.style-chip { padding: .25rem .6rem; border: 1px solid var(--border); border-radius: 6px; font-size: .75rem;
color: var(--text-dim); cursor: pointer; transition: all .2s; }
.style-chip:hover { border-color: var(--accent); color: var(--accent); }
.style-chip.active { border-color: var(--accent); color: var(--accent); background: #1a1025; }
.render-status { font-size: .8rem; color: var(--text-dim); margin-top: .5rem; }
.render-status.done { color: var(--green); }
/* 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 &mdash; 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, i) => {
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>' : '') +
'<video class="clip-video" controls preload="none" poster="">' +
'<source src="' + API + '/api/clips/' + c.id + '/preview" type="video/mp4">' +
'</video>' +
(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 + '/download" download>Download</a>' +
'<button class="clip-btn primary" onclick="toggleRender(\'' + c.id + '\')">Add Captions</button>' +
'</div>' +
'<div class="render-options" id="render-' + c.id + '">' +
'<div class="render-row">' +
'<span class="render-label">Captions</span>' +
'<div class="style-preview" id="styles-' + c.id + '">' +
'<div class="style-chip active" onclick="selectStyle(\'' + c.id + '\',\'tiktok\',this)" data-style="tiktok">TikTok</div>' +
'<div class="style-chip" onclick="selectStyle(\'' + c.id + '\',\'hormozi\',this)" data-style="hormozi">Hormozi</div>' +
'<div class="style-chip" onclick="selectStyle(\'' + c.id + '\',\'karaoke\',this)" data-style="karaoke">Karaoke</div>' +
'<div class="style-chip" onclick="selectStyle(\'' + c.id + '\',\'minimal\',this)" data-style="minimal">Minimal</div>' +
'<div class="style-chip" onclick="selectStyle(\'' + c.id + '\',\'none\',this)" data-style="none">None</div>' +
'</div>' +
'</div>' +
'<div class="render-row">' +
'<span class="render-label">Aspect</span>' +
'<select class="render-select" id="aspect-' + c.id + '">' +
'<option value="9:16">9:16 (Shorts/Reels)</option>' +
'<option value="16:9">16:9 (YouTube)</option>' +
'<option value="1:1">1:1 (Square)</option>' +
'<option value="4:5">4:5 (Instagram)</option>' +
'</select>' +
'</div>' +
'<div class="render-row">' +
'<button class="clip-btn primary" onclick="renderClip(\'' + c.id + '\')">Render</button>' +
'</div>' +
'<div class="render-status" id="rstatus-' + c.id + '"></div>' +
'</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; }
// Render controls
const renderStyles = {}; // clip_id -> style
function toggleRender(clipId) {
const el = document.getElementById('render-' + clipId);
el.classList.toggle('visible');
}
function selectStyle(clipId, style, chip) {
renderStyles[clipId] = style;
document.querySelectorAll('#styles-' + clipId + ' .style-chip').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
}
async function renderClip(clipId) {
const style = renderStyles[clipId] || 'tiktok';
const aspect = document.getElementById('aspect-' + clipId).value;
const statusEl = document.getElementById('rstatus-' + clipId);
statusEl.textContent = 'Rendering...';
statusEl.className = 'render-status';
try {
const res = await fetch(API + '/api/clips/' + clipId + '/render', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({aspect_ratio: aspect, subtitle_style: style})
});
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Render failed'); }
const render = await res.json();
pollRender(clipId, render.id);
} catch (e) { statusEl.textContent = 'Error: ' + e.message; }
}
async function pollRender(clipId, renderId) {
const statusEl = document.getElementById('rstatus-' + clipId);
try {
const res = await fetch(API + '/api/renders/' + renderId);
const r = await res.json();
if (r.status === 'complete') {
statusEl.className = 'render-status done';
statusEl.innerHTML = 'Done! <a class="clip-btn" href="' + API + '/api/renders/' + renderId + '/download" download>Download rendered clip</a>' +
' <a class="clip-btn" href="' + API + '/api/renders/' + renderId + '/preview" target="_blank">Preview</a>';
} else if (r.status === 'failed') {
statusEl.textContent = 'Failed: ' + (r.error_message || 'unknown error');
} else {
statusEl.textContent = 'Rendering... ' + Math.round(r.progress * 100) + '%';
setTimeout(() => pollRender(clipId, renderId), 2000);
}
} catch { setTimeout(() => pollRender(clipId, renderId), 3000); }
}
// URL enter key
document.getElementById('urlInput').addEventListener('keydown', e => { if (e.key === 'Enter') submitUrl(); });
// Load history on page load
loadHistory();
</script>
</body>
</html>"""