clpper / app.js
areebsatin's picture
feat: add clip duration selector (15-90s) with intelligent segment expansion
9705942
Raw
History Blame Contribute Delete
27.1 kB
/**
* AI SquadX VIP – Viral Shorts Generator
* State Machine: idle β†’ loading β†’ results | error
*/
'use strict';
// ─── Constants ──────────────────────────────────────────────────────────────
// Local AI Clipper backend (start with start_backend.bat)
// Relative path so it follows <base href> on Hugging Face Spaces (path prefix)
const BACKEND_URL = 'api/process';
const LOADING_MESSAGES = [
'Connecting to AI engine...',
'Fetching video metadata...',
'Transcribing audio...',
'Analyzing viral hooks...',
'Scoring engagement moments...',
'Identifying key highlights...',
'Clipping best segments...',
'Formatting to 9:16 vertical...',
'Rendering your shorts...',
'Almost there...',
];
const STEPS = [
{ id: 'step-1', label: 'Fetching video metadata', progress: 25 },
{ id: 'step-2', label: 'Analyzing viral hooks', progress: 50 },
{ id: 'step-3', label: 'Clipping & formatting', progress: 75 },
{ id: 'step-4', label: 'Rendering shorts', progress: 95 },
];
// ─── DOM Refs ────────────────────────────────────────────────────────────────
const screens = {
input: document.getElementById('screen-input'),
loading: document.getElementById('screen-loading'),
results: document.getElementById('screen-results'),
error: document.getElementById('screen-error'),
};
const urlInput = document.getElementById('youtube-url-input');
const generateBtn = document.getElementById('generate-btn');
const loadingMsg = document.getElementById('loading-message');
const progressBar = document.getElementById('progress-bar');
const resultsGallery = document.getElementById('results-gallery');
const resultsCount = document.getElementById('results-count');
const newVideoBtn = document.getElementById('new-video-btn');
const retryBtn = document.getElementById('retry-btn');
const errorMessage = document.getElementById('error-message');
// Advanced refs
const captionsToggle = document.getElementById('captions-toggle');
const captionStyleSelect = document.getElementById('caption-style-select');
const captionStyleWrapper = document.getElementById('caption-style-wrapper');
const headlineInput = document.getElementById('headline-input');
const ctaInput = document.getElementById('cta-input');
// Phase 2 refs
const reframeToggle = document.getElementById('reframe-toggle');
const progressBarToggle = document.getElementById('progress-bar-toggle');
const vibeSelect = document.getElementById('vibe-select');
// Retention Psychology refs
const retentionModeToggle = document.getElementById('retention-mode-toggle');
const colorModeSelect = document.getElementById('color-mode-select');
const watermarkInput = document.getElementById('watermark-input');
const safeZoneToggle = document.getElementById('safe-zone-toggle');
const durationRangeSelect = document.getElementById('duration-range-select');
const retentionToggleBtn = document.getElementById('retention-toggle-btn');
const retentionBody = document.getElementById('retention-body');
// ─── App State ───────────────────────────────────────────────────────────────
let loadingTimer = null;
let messageInterval = null;
let stepIndex = 0;
let msgIndex = 0;
let savedUrl = '';
let clipsCount = null; // null = Default (backend decides)
// ─── Screen Manager ──────────────────────────────────────────────────────────
function showScreen(name) {
Object.values(screens).forEach(s => s.classList.remove('active'));
const target = screens[name];
if (target) {
target.classList.add('active');
}
}
// ─── Loading Animations ───────────────────────────────────────────────────────
function startLoadingAnimation() {
// Reset
progressBar.style.width = '0%';
stepIndex = 0;
msgIndex = 0;
STEPS.forEach(s => {
const el = document.getElementById(s.id);
el.classList.remove('active', 'done');
});
// Rotate messages
loadingMsg.textContent = LOADING_MESSAGES[0];
messageInterval = setInterval(() => {
msgIndex = (msgIndex + 1) % LOADING_MESSAGES.length;
loadingMsg.style.opacity = '0';
setTimeout(() => {
loadingMsg.textContent = LOADING_MESSAGES[msgIndex];
loadingMsg.style.opacity = '1';
}, 250);
}, 2200);
// Advance steps
advanceStep();
}
function advanceStep() {
if (stepIndex >= STEPS.length) return;
const step = STEPS[stepIndex];
const stepEl = document.getElementById(step.id);
// Mark previous as done
if (stepIndex > 0) {
const prevEl = document.getElementById(STEPS[stepIndex - 1].id);
prevEl.classList.remove('active');
prevEl.classList.add('done');
}
stepEl.classList.add('active');
progressBar.style.width = step.progress + '%';
stepIndex++;
if (stepIndex < STEPS.length) {
loadingTimer = setTimeout(advanceStep, 3500);
}
}
function stopLoadingAnimation() {
clearInterval(messageInterval);
clearTimeout(loadingTimer);
messageInterval = null;
loadingTimer = null;
}
function completeAllSteps() {
STEPS.forEach(s => {
const el = document.getElementById(s.id);
el.classList.remove('active');
el.classList.add('done');
});
progressBar.style.width = '100%';
}
// ─── Backend Call (streaming NDJSON) ───────────────────────────────────────────
async function callBackend(youtubeUrl, { onTotal, onClip }) {
const modeEl = document.querySelector('input[name="crop-mode"]:checked');
const mode = modeEl ? modeEl.value : 'fill';
const payload = {
youtubeUrl,
mode,
captions: captionsToggle.checked,
captionStyle: captionStyleSelect ? captionStyleSelect.value : 'mrbeast',
headline: headlineInput.value.trim(),
cta: ctaInput.value.trim(),
reframe: reframeToggle.checked,
progressBar: progressBarToggle.checked,
vibe: vibeSelect.value,
// Retention Psychology
retentionMode: retentionModeToggle ? retentionModeToggle.checked : false,
colorMode: colorModeSelect ? colorModeSelect.value : 'off',
watermarkText: watermarkInput ? watermarkInput.value.trim() : '',
safeZone: safeZoneToggle ? safeZoneToggle.checked : false,
durationRange: durationRangeSelect ? durationRangeSelect.value : 'auto',
};
if (clipsCount !== null) payload.clips = clipsCount; // omit = backend default
let response;
try {
response = await fetch(BACKEND_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (e) {
console.error('[ClipperApp] Network error:', e);
throw new Error(
'Cannot reach the local backend. Make sure start_backend.bat is running on port 5000.'
);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // keep any incomplete trailing chunk
for (const line of lines) {
if (!line.trim()) continue;
let msg;
try { msg = JSON.parse(line); } catch { continue; }
if (msg.error) throw new Error(msg.error);
if (msg.total !== undefined) await onTotal(msg.total);
if (msg.clip !== undefined) await onClip(msg.clip, msg.index, msg);
// msg.warning is silently ignored (non-fatal)
}
}
}
// ─── Gallery Rendering ────────────────────────────────────────────────────────
function renderGallery(videoUrls) {
resultsGallery.innerHTML = '';
resultsCount.textContent = videoUrls.length;
videoUrls.forEach((url, i) => {
const card = createVideoCard(url, i + 1);
resultsGallery.appendChild(card);
});
}
function createVideoCard(url, index, retentionData = {}) {
const { viral_analysis = null, pacing = null, structure = null, triggers = null } = retentionData;
const card = document.createElement('article');
card.className = 'video-card';
card.style.animationDelay = `${(index - 1) * 0.08}s`;
card.setAttribute('role', 'listitem');
// ─── Viral Score Bar ──────────────────────────────────────────────────────
if (viral_analysis) {
const score = viral_analysis.final_score || 0;
const scorePct = Math.min(100, Math.max(0, score * 100));
const scoreColorClass = score > 0.75 ? 'score-green' : score >= 0.5 ? 'score-amber' : 'score-red';
const scoreBar = document.createElement('div');
scoreBar.className = 'viral-score-container';
scoreBar.innerHTML = `<div class="viral-score-fill ${scoreColorClass}" style="width: ${scorePct}%"></div>`;
card.appendChild(scoreBar);
// ─── Viral Signal Badges ────────────────────────────────────────────────
const signalsRow = document.createElement('div');
signalsRow.className = 'viral-signals-row';
const sigs = [
{ label: 'YT Heat', val: viral_analysis.heatmap_score, cls: 'badge-heat', id: 'heatmap' },
{ label: 'Energy', val: viral_analysis.energy_score, cls: 'badge-energy', id: 'energy' },
{ label: 'Text', val: viral_analysis.transcript_score, cls: 'badge-text', id: 'transcript' }
];
sigs.forEach(s => {
const badge = document.createElement('span');
badge.className = `viral-badge ${s.cls}`;
badge.textContent = `${s.label}: ${s.val.toFixed(2)}`;
// Highlight if it's the top signal
if (viral_analysis.top_signal === s.id) {
const topTag = document.createElement('span');
topTag.className = 'top-signal-tag';
topTag.textContent = 'TOP';
badge.appendChild(topTag);
}
signalsRow.appendChild(badge);
});
card.appendChild(signalsRow);
}
const videoWrapper = document.createElement('div');
videoWrapper.className = 'video-wrapper';
const video = document.createElement('video');
video.src = url;
video.controls = true;
video.setAttribute('preload', 'metadata');
video.setAttribute('playsinline', '');
video.setAttribute('aria-label', `Generated Short #${index}`);
videoWrapper.appendChild(video);
const footer = document.createElement('div');
footer.className = 'video-card-footer';
const label = document.createElement('p');
label.className = 'video-card-label';
label.textContent = `Short #${index}`;
const downloadBtn = document.createElement('button');
downloadBtn.className = 'btn-download';
downloadBtn.setAttribute('aria-label', `Download Short #${index}`);
downloadBtn.innerHTML = `
<svg class="dl-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<span class="dl-label">Download</span>`;
downloadBtn.addEventListener('click', async () => {
if (downloadBtn.disabled) return;
downloadBtn.disabled = true;
downloadBtn.querySelector('.dl-label').textContent = 'Downloading…';
try {
const res = await fetch(url);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `viral-short-${index}.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
} catch (err) {
console.error('Download failed:', err);
alert('Download failed. Please try right-clicking the video and saving it.');
}
downloadBtn.disabled = false;
downloadBtn.querySelector('.dl-label').textContent = 'Download';
});
footer.appendChild(label);
footer.appendChild(downloadBtn);
// ─── Retention Psychology Badges ─────────────────────────────────────────
if (pacing !== null || structure !== null || triggers !== null) {
const rp = document.createElement('div');
rp.className = 'rp-badges';
// Pacing score badge
if (pacing !== null) {
const pacingColor = pacing >= 70 ? '#22c55e' : pacing >= 40 ? '#fbbf24' : '#f87171';
const pacingBadge = document.createElement('div');
pacingBadge.className = 'rp-badge rp-pacing';
pacingBadge.innerHTML = `
<svg width="28" height="28" viewBox="0 0 36 36" aria-hidden="true">
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="3"/>
<circle cx="18" cy="18" r="14" fill="none" stroke="${pacingColor}" stroke-width="3"
stroke-dasharray="${Math.round(pacing * 0.88)} 88"
stroke-dashoffset="22" stroke-linecap="round" transform="rotate(-90 18 18)"/>
<text x="18" y="22" text-anchor="middle" fill="${pacingColor}" font-size="9" font-weight="700"
font-family="Inter,sans-serif">${pacing}</text>
</svg>
<span class="rp-badge-label">Pacing</span>
`;
rp.appendChild(pacingBadge);
}
// Hook / Body / Reward structure labels
if (structure) {
const segBadge = document.createElement('div');
segBadge.className = 'rp-badge rp-structure';
const hookDur = Math.round((structure.hook.end - structure.hook.start) * 10) / 10;
const rewardDur = Math.round((structure.reward.end - structure.reward.start) * 10) / 10;
segBadge.innerHTML = `
<span class="seg-pill seg-hook">🎣 Hook ${hookDur}s</span>
<span class="seg-pill seg-body">πŸ“Ή Body</span>
<span class="seg-pill seg-reward">πŸ† Reward ${rewardDur}s</span>
`;
rp.appendChild(segBadge);
}
// Trigger tag pills
if (triggers && triggers.tags && triggers.tags.length > 0) {
const tagRow = document.createElement('div');
tagRow.className = 'rp-badge rp-triggers';
tagRow.innerHTML = triggers.tags
.map(tag => `<span class="trigger-tag">${tag}</span>`)
.join('');
rp.appendChild(tagRow);
}
card.appendChild(rp);
}
card.appendChild(videoWrapper);
card.appendChild(footer);
return card;
}
// ─── Placeholder card (shown while clip is encoding) ──────────────────────────
function createPlaceholderCard(index) {
const card = document.createElement('article');
card.className = 'video-card video-card-placeholder';
card.dataset.cardIndex = index;
card.setAttribute('role', 'listitem');
card.style.animationDelay = `${index * 0.07}s`;
const wrapper = document.createElement('div');
wrapper.className = 'video-wrapper';
wrapper.innerHTML = `
<div class="placeholder-inner">
<div class="placeholder-spinner"></div>
<span class="placeholder-label">PROCESSING...</span>
</div>`;
const footer = document.createElement('div');
footer.className = 'video-card-footer';
footer.innerHTML = `
<p class="video-card-label" style="opacity:0.35">Short #${index + 1}</p>
<div class="btn-download-ghost"></div>`;
card.appendChild(wrapper);
card.appendChild(footer);
return card;
}
// ─── Validation ───────────────────────────────────────────────────────────────
function isValidYouTubeUrl(url) {
try {
const u = new URL(url.trim());
return (
(u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.searchParams.has('v') ||
u.hostname === 'youtu.be' && u.pathname.length > 1 ||
u.hostname === 'www.youtube.com' && u.pathname.startsWith('/shorts/')
);
} catch {
return false;
}
}
// ─── Results badge ─────────────────────────────────────────────────────────────
function showResultsBadge(visible) {
const badge = document.getElementById('results-loading-badge');
if (badge) badge.style.display = visible ? 'flex' : 'none';
}
// ─── Main Generate Flow ───────────────────────────────────────────────────────────
async function handleGenerate() {
const rawUrl = urlInput.value.trim();
if (!rawUrl) { shakeInput(); return; }
if (!isValidYouTubeUrl(rawUrl)) {
shakeInput();
showInputError('Please enter a valid YouTube URL.');
return;
}
clearInputError();
savedUrl = rawUrl;
urlInput.disabled = true;
generateBtn.disabled = true;
showScreen('loading');
startLoadingAnimation();
let firstClip = true;
let totalClips = 0;
let receivedClips = 0;
let receivedClipsData = [];
try {
await callBackend(rawUrl, {
async onTotal(n) {
totalClips = n;
stopLoadingAnimation();
completeAllSteps();
await delay(300);
resultsGallery.innerHTML = '';
resultsCount.textContent = '0';
showScreen('results');
showResultsBadge(true);
for (let i = 0; i < n; i++) {
resultsGallery.appendChild(createPlaceholderCard(i));
}
},
async onClip(url, index, data) {
receivedClips++;
// Collect data for sorting
receivedClipsData.push({ url, index, data });
// Sort by final_score descending
receivedClipsData.sort((a, b) => {
const sA = (a.data.viral_analysis || {}).final_score || 0;
const sB = (b.data.viral_analysis || {}).final_score || 0;
return sB - sA;
});
// Re-render gallery (to maintain sort order)
// We find the current cards/placeholders and update them or just refresh the whole gallery
// To keep it simple and correct, we re-render the gallery each time
resultsGallery.innerHTML = '';
// 1. First add the real cards (sorted)
receivedClipsData.forEach((clip, i) => {
const realCard = createVideoCard(clip.url, i + 1, clip.data);
resultsGallery.appendChild(realCard);
});
// 2. Then add the placeholders for the remaining slots
for (let i = receivedClips; i < totalClips; i++) {
resultsGallery.appendChild(createPlaceholderCard(i));
}
resultsCount.textContent = receivedClips;
if (receivedClips >= totalClips) showResultsBadge(false);
},
});
if (totalClips === 0) {
throw new Error('No shorts were generated. Please try a different video.');
}
showResultsBadge(false);
} catch (err) {
stopLoadingAnimation();
console.error('[ClipperApp] Error:', err);
// Detect common failure patterns and show user-friendly messages
const raw = (err.message || '').toLowerCase();
let friendlyMsg = '';
if (raw.includes('failed to resolve') || raw.includes('dns resolution failed') || raw.includes('no address associated')) {
friendlyMsg = '🌐 YouTube is unreachable from this server.\n\n'
+ 'This hosting platform appears to block outbound connections to YouTube.\n\n'
+ 'Try these fixes:\n'
+ '1. Upload a cookies.txt file to the Space\n'
+ '2. Switch to a GPU-enabled Space (better network access)\n'
+ '3. Try again in a few minutes (may be a temporary DNS issue)';
} else if (raw.includes('failed to extract') || raw.includes('player response')) {
friendlyMsg = '⚠️ YouTube blocked this request.\n\n'
+ 'YouTube detected automated access and refused the download.\n\n'
+ 'Fix: Upload a cookies.txt file exported from your browser to bypass this restriction.';
} else if (raw.includes('cannot reach') || raw.includes('network error') || raw.includes('failed to fetch')) {
friendlyMsg = 'πŸ”Œ Cannot connect to the backend server.\n\n'
+ 'Make sure your backend is running (start_backend.bat or check HF Space logs).';
}
errorMessage.textContent = friendlyMsg || err.message || 'An unexpected error occurred.';
errorMessage.style.whiteSpace = 'pre-line';
showScreen('error');
}
}
// ─── Input helpers ────────────────────────────────────────────────────────────
function shakeInput() {
const wrapper = urlInput.closest('.input-wrapper');
wrapper.style.animation = 'none';
wrapper.offsetHeight; // force reflow
wrapper.style.animation = 'shake 0.4s ease';
wrapper.addEventListener('animationend', () => {
wrapper.style.animation = '';
}, { once: true });
}
function showInputError(msg) {
let errEl = document.getElementById('input-error');
if (!errEl) {
errEl = document.createElement('p');
errEl.id = 'input-error';
errEl.style.cssText = 'font-size:0.8rem;color:#F87171;margin-top:-0.25rem;padding-left:0.25rem;';
urlInput.closest('.input-card').insertBefore(errEl, document.querySelector('.btn-generate'));
}
errEl.textContent = msg;
}
function clearInputError() {
const errEl = document.getElementById('input-error');
if (errEl) errEl.remove();
}
// ─── Utility ──────────────────────────────────────────────────────────────────
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// ─── Reset to Input Screen ────────────────────────────────────────────────────
function resetToInput() {
urlInput.disabled = false;
generateBtn.disabled = false;
urlInput.value = '';
clearInputError();
showScreen('input');
urlInput.focus();
}
// ─── Shake Keyframe (injected via JS for portability) ────────────────────────
(function injectShakeKeyframe() {
const style = document.createElement('style');
style.textContent = `
@keyframes shake {
0%,100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
`;
document.head.appendChild(style);
})();
// ─── Event Listeners ──────────────────────────────────────────────────────────
generateBtn.addEventListener('click', handleGenerate);
urlInput.addEventListener('keydown', e => {
if (e.key === 'Enter') handleGenerate();
});
newVideoBtn.addEventListener('click', resetToInput);
retryBtn.addEventListener('click', resetToInput);
// Auto-focus input on load
window.addEventListener('DOMContentLoaded', () => {
urlInput.focus();
// Character counter for inputs with maxlength attribute
document.querySelectorAll('input[maxlength]').forEach(input => {
const counter = input.parentElement.querySelector('.char-count');
if (counter) {
const update = () => { counter.textContent = input.value.length; };
input.addEventListener('input', update);
update();
}
});
});
// Show/hide caption style selector based on captions toggle
if (captionsToggle && captionStyleWrapper) {
captionsToggle.addEventListener('change', () => {
captionStyleWrapper.style.display = captionsToggle.checked ? 'block' : 'none';
});
}
// ─── Clip Count Stepper ───────────────────────────────────────────────────────
const clipsDisplay = document.getElementById('clips-display');
const clipsDecBtn = document.getElementById('clips-dec');
const clipsIncBtn = document.getElementById('clips-inc');
function updateClipsDisplay() {
clipsDisplay.classList.remove('count-pop');
void clipsDisplay.offsetWidth; // force reflow for re-trigger
clipsDisplay.classList.add('count-pop');
if (clipsCount === null) {
clipsDisplay.textContent = 'Default';
clipsDisplay.classList.add('clips-default');
} else {
clipsDisplay.textContent = clipsCount;
clipsDisplay.classList.remove('clips-default');
}
}
// Clips count stepper logic:
// Default (null) implies 5 clips (center value on 1-10 scale).
// Decrementing from Default β†’ 4, Incrementing from Default β†’ 6.
clipsDecBtn.addEventListener('click', () => {
if (clipsCount === null) {
clipsCount = 4; // Default (5) β†’ 4 (one step below)
} else if (clipsCount <= 1) {
clipsCount = null; // back to Default
} else {
clipsCount--;
}
updateClipsDisplay();
});
clipsIncBtn.addEventListener('click', () => {
if (clipsCount === null) {
clipsCount = 6; // Default (5) β†’ 6 (one step above)
} else {
clipsCount = Math.min(10, clipsCount + 1);
}
updateClipsDisplay();
});
// ─── Retention Psychology: collapsible toggle ─────────────────────────────────
if (retentionToggleBtn && retentionBody) {
// Start collapsed
retentionBody.style.maxHeight = '0';
retentionBody.style.opacity = '0';
retentionBody.style.overflow = 'hidden';
retentionToggleBtn.addEventListener('click', () => {
const isOpen = retentionToggleBtn.getAttribute('aria-expanded') === 'true';
retentionToggleBtn.setAttribute('aria-expanded', String(!isOpen));
if (isOpen) {
retentionBody.style.maxHeight = '0';
retentionBody.style.opacity = '0';
} else {
retentionBody.style.maxHeight = retentionBody.scrollHeight + 'px';
retentionBody.style.opacity = '1';
}
retentionToggleBtn.querySelector('.retention-chevron')
.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(180deg)';
});
}