/** * 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 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 = `
`; 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 = ` Download`; 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 = ` Pacing `; 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 = ` 🎣 Hook ${hookDur}s 📹 Body 🏆 Reward ${rewardDur}s `; 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 => `${tag}`) .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 = `
PROCESSING...
`; const footer = document.createElement('div'); footer.className = 'video-card-footer'; footer.innerHTML = `

Short #${index + 1}

`; 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)'; }); }