/* ============================================================ MARQUEE STUDIO — JAVASCRIPT marquee.js (type="module") ============================================================ */ // Local state — declared first so the dynamic import below can reference it let isOfflineMode = false; // Dynamic import so landing screen works even if Gradio CDN fails to load let Client = null; try { const gradioModule = await import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"); Client = gradioModule.Client; } catch (importErr) { console.warn('[marquee] Gradio client failed to load — running in offline/demo mode.', importErr); isOfflineMode = true; } const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => [...r.querySelectorAll(s)]; const reduce = matchMedia('(prefers-reduced-motion:reduce)').matches; const pad = n => String(n).padStart(2, '0'); let currentVibe = 'football'; let currentVoiceModel = 'chatterbox'; let SESSION = null, VIDEO_URL = null, DUR = 0, REAL_SCRIPT = [], selectedFile = null; let TTS_LINES = []; let stars = []; /* ============================================================ THEME LANGUAGE PARAMETERS (Per Vibe) ============================================================ */ const THEME_COPY = { football: { mastheadTag: "Everyone's a headliner.", mastheadSub: "Turn any clip into a live broadcast.", step1Ttl: "Roll Tape", step1Sub: "Drop a clip", step2Ttl: "Scouting", step2Sub: "Find the players", step3Ttl: "Squad", step3Sub: "Name them up", step4Ttl: "Kick Off", step4Sub: "Go live", rollCardTitle: "Roll the Footage", dropTitle: "Drop your clip on the pitch", castBtnLabel: "Scout the pitch", liveBtnLabel: "Kick off | go live", stageTitle: "Squad Select", stageEmptyTitle:"No players spotted", stageEmptySub: "Roll a clip and hit Scout the pitch | every face gets a squad card.", backBtnLabel: "Back to dugout", pbpTitle: "Match commentary", noteText: "Your clip stays on your device. Player detection runs locally | Marquee only writes the captions.", }, diva: { mastheadTag: "Everyone's a headliner.", mastheadSub: "Your private red carpet, darling.", step1Ttl: "The Footage", step1Sub: "Drop your clip", step2Ttl: "Spotting", step2Sub: "Who's arriving", step3Ttl: "Guest List", step3Sub: "Name them, darling", step4Ttl: "Showtime", step4Sub: "Walk the carpet", rollCardTitle: "The Footage", dropTitle: "Drop the clip, darling", castBtnLabel: "Who's arriving?", liveBtnLabel: "Walk the carpet | go live", stageTitle: "Tonight's Guests", stageEmptyTitle:"The carpet is empty", stageEmptySub: "Roll a clip and hit Who's arriving? | every face gets a star card.", backBtnLabel: "Back to the green room", pbpTitle: "Red carpet commentary", noteText: "Your clip is yours, darling. Detection is entirely local | Marquee only writes what it sees.", }, wildlife: { mastheadTag: "Everyone's a headliner.", mastheadSub: "Observing humans in their natural habitat.", step1Ttl: "The Footage", step1Sub: "Drop a clip", step2Ttl: "Field Survey", step2Sub: "Locate specimens", step3Ttl: "Field Notes", step3Sub: "Name the creatures", step4Ttl: "Roll Camera", step4Sub: "Observe", rollCardTitle: "The Footage", dropTitle: "Place your clip here quietly", castBtnLabel: "Locate specimens", liveBtnLabel: "Roll camera | observe", stageTitle: "Field Notes", stageEmptyTitle:"No specimens located", stageEmptySub: "Roll a clip and hit Locate specimens | each creature gets a field card.", backBtnLabel: "Back to field base", pbpTitle: "Field observations", noteText: "Your footage remains undisturbed. Detection runs locally | Marquee only records observations.", }, boxing: { mastheadTag: "Everyone's a headliner.", mastheadSub: "Ringside. Every move is championship-defining.", step1Ttl: "The Fight Tape", step1Sub: "Drop the clip", step2Ttl: "Corner Scan", step2Sub: "Find the fighters", step3Ttl: "Fighter Cards", step3Sub: "Name the contenders", step4Ttl: "Ring Bell", step4Sub: "Go ringside", rollCardTitle: "Fight Tape", dropTitle: "Drop the fight footage", castBtnLabel: "Find the fighters", liveBtnLabel: "Ding ding | go ringside", stageTitle: "Fighter Cards", stageEmptyTitle:"No fighters in the ring", stageEmptySub: "Roll a clip and hit Find the fighters | every face gets a fighter card.", backBtnLabel: "Back to the corner", pbpTitle: "Ringside commentary", noteText: "Your footage stays local. Fighter detection never leaves your device | Marquee only calls the action.", }, masterchef: { mastheadTag: "Everyone's a headliner.", mastheadSub: "This is not a cooking show. This is a reckoning.", step1Ttl: "The Footage", step1Sub: "Submit your clip", step2Ttl: "Assessment", step2Sub: "Identify contestants", step3Ttl: "Contestants", step3Sub: "Name them", step4Ttl: "Begin the Cook", step4Sub: "Judge them", rollCardTitle: "Submit Your Footage", dropTitle: "Place your clip on the pass", castBtnLabel: "Assess the contestants", liveBtnLabel: "Begin the cook | judge them", stageTitle: "Contestant Board", stageEmptyTitle:"No contestants present", stageEmptySub: "Submit a clip and hit Assess the contestants | each face gets a contestant card.", backBtnLabel: "Back to the kitchen", pbpTitle: "Judge's commentary", noteText: "Your footage is submitted locally. Identification runs on-device | Marquee only delivers the verdict.", }, }; /* ============================================================ PREVIEW SIMULATOR FALLBACK DATA ============================================================ */ const MOCK_SCRIPTS = { football: [ { t: 1, text: "Welcome to the pitch! The players are in position, and tension is through the roof." }, { t: 5, text: "Look at {Star1}! Unbelievable close control. A magnificent exhibition of touch and spatial awareness." }, { t: 11, text: "And now {Star2} steps in! Making their presence known. Defending with extreme, calculated precision!" }, { t: 17, text: "Play is turning fast. We are witness to absolute tactical magic here today!" }, { t: 23, text: "The stadium holds its breath. A phenomenal display of pure champion determination!" } ], diva: [ { t: 1, text: "Darling, the spotlight is officially active! Let's examine who is gracing us with their presence." }, { t: 5, text: "Here comes {Star1}! Oh, that structure. That posture. It screams pure designer elegance, but are we loving the accessories?" }, { t: 11, text: "But wait! {Star2} is making a move! The crowd is absolutely gasping. The pure theatricality of this entrance is divine." }, { t: 17, text: "It is an absolute masterclass in walking the carpet, darling. Spectacularly unbothered." }, { t: 23, text: "We are witnessing iconic history tonight. Put it on the cover of VOGUE!" } ], wildlife: [ { t: 1, text: "We are deep in the Serengeti. A spectacular ecosystem unfolding before us." }, { t: 5, text: "Notice {Star1} rising from the grasslands. Moving with deliberate, majestic intent." }, { t: 11, text: "A second specimen has appeared! That is {Star2}. Tracking the perimeter. Truly a stunning sight." }, { t: 17, text: "Observe how they interact. A delicate dance of absolute natural balance." }, { t: 23, text: "Nature at its most pristine and untouched. A truly marvelous encounter." } ], boxing: [ { t: 1, text: "Ladies and gentlemen... we are RINGSIDE! The absolute atmosphere of champion combat!" }, { t: 5, text: "In the red corner... {Star1}! Unmatched speed. A fighter with absolutely nothing to lose!" }, { t: 11, text: "And facing them... {Star2}! Heavy feet, iron jaw. What a spectacular counter-stance!" }, { t: 17, text: "They are exchanging glances now! The arena is deafening! Nobody is backing down!" }, { t: 23, text: "Ding ding! The bell signals pure, raw tactical chaos. Who takes the belt?!" } ], masterchef: [ { t: 1, text: "This kitchen is a place of precision. There is absolutely no room for failure today." }, { t: 5, text: "Contestant {Star1} is at the pass. Their knife skills are elegant, but that reduction smells of pure desperation." }, { t: 11, text: "Now look at {Star2}. A brave concept. Bold plating. But let's see if the flavor lives up to the presentation." }, { t: 17, text: "The tension is absolute. This is not a cooking show, it is a clinical reckoning of your technique." }, { t: 23, text: "The judges are silent. A single bite will seal your absolute fate today." } ] }; const MOCK_FACE_CROPS = [ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' fill='%23FFC94D'%3E%3Ccircle cx='50' cy='45' r='20'/%3E%3Cpath d='M20 85 C20 65 35 55 50 55 C65 55 80 65 80 85 Z'/%3E%3C/svg%3E", "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' fill='%233DE7FF'%3E%3Ccircle cx='50' cy='45' r='20'/%3E%3Cpath d='M20 85 C20 65 35 55 50 55 C65 55 80 65 80 85 Z'/%3E%3C/svg%3E", "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' fill='%23FF4D9A'%3E%3Ccircle cx='50' cy='45' r='20'/%3E%3Cpath d='M20 85 C20 65 35 55 50 55 C65 55 80 65 80 85 Z'/%3E%3C/svg%3E" ]; /* ============================================================ LANDING — Vibe Selector ============================================================ */ // Landing is visible by default (no splash screen). It hides once a vibe is picked. const landingView = $('#landingView'); $('#landingVibes').addEventListener('click', e => { const card = e.target.closest('.lv-card'); if (!card) return; const vibe = card.dataset.vibe; landingView.classList.add('hidden'); $('#appShell').hidden = false; applyVibe(vibe); }); /* ============================================================ THEME APPLICATION ============================================================ */ const VIBES = { football: { acc:'#i-whistle', vc:'--star-lime', who:'Football Championship', coco:"World Cup energy. Roaring play-by-play commentary for the beautiful game." }, diva: { acc:'#i-glam', vc:'--star-hot', who:'Diva Hour Glamour', coco:"Red carpet glamour. Fashion feedback, high drama, and unsolicited feedback." }, wildlife: { acc:'#i-paw', vc:'--star-cyan', who:'Wildlife Expedition', coco:"Hushed. Awestruck. Narrating your friends like rare creatures in the wild." }, boxing: { acc:'#i-glove', vc:'--star-tang', who:'Ringside Boxing', coco:"Ringside. Every move is championship-defining. Nobody is safe from the hype." }, masterchef: { acc:'#i-chef', vc:'--star-gold', who:'MasterChef Judgment', coco:"High stakes. Brutal precision. A cooking-show judge who takes everything personally." }, }; function applyVibe(vibe) { document.body.setAttribute('data-vibe', vibe); currentVibe = vibe; const copy = THEME_COPY[vibe] || THEME_COPY.football; const vibeData = VIBES[vibe] || {}; const tag = $('#mastheadTag'); if (tag) { tag.childNodes[0].textContent = copy.mastheadTag; const small = tag.querySelector('small'); if (small) small.textContent = copy.mastheadSub; } _setText('step1Ttl', copy.step1Ttl); _setText('step1Sub', copy.step1Sub); _setText('step2Ttl', copy.step2Ttl); _setText('step2Sub', copy.step2Sub); _setText('step3Ttl', copy.step3Ttl); _setText('step3Sub', copy.step3Sub); _setText('step4Ttl', copy.step4Ttl); _setText('step4Sub', copy.step4Sub); _setText('rollCardTitle', copy.rollCardTitle); _setText('dropTitle', copy.dropTitle); _setText('castBtnLabel', copy.castBtnLabel); _setText('liveBtnLabel', copy.liveBtnLabel); _setText('stageTitle', copy.stageTitle); _setText('stageEmptyTitle', copy.stageEmptyTitle); _setHTML('stageEmptySub', copy.stageEmptySub); _setText('pbpTitle', copy.pbpTitle); _setHTML('noteText', copy.noteText); _setText('backBtnLabel', copy.backBtnLabel); _setText('vibeBannerName', vibeData.who || ''); _setText('vibeBannerDesc', vibeData.coco || ''); const vibeBanner = $('#vibeBanner'); if (vibeBanner && vibeData) { vibeBanner.style.setProperty('--vc', `var(${vibeData.vc})`); vibeBanner.style.borderColor = `color-mix(in srgb, var(${vibeData.vc}) 25%, transparent)`; vibeBanner.style.backgroundColor = `color-mix(in srgb, var(${vibeData.vc}) 8%, var(--stage))`; } const vibeBannerIcon = $('#vibeBannerIcon'); if (vibeBannerIcon && vibeData) { vibeBannerIcon.innerHTML = ``; vibeBannerIcon.style.color = `var(${vibeData.vc})`; vibeBannerIcon.style.backgroundColor = `color-mix(in srgb, var(${vibeData.vc}) 15%, transparent)`; } const bannerWave = $('#bannerWave'); if (bannerWave && vibeData) { bannerWave.style.setProperty('--vc', `var(${vibeData.vc})`); } updateChangeShowButton(vibe); playSoundEffect(vibe); } function _setText(id, val) { const el = $('#' + id); if (el && val !== undefined) el.textContent = val; } function _setHTML(id, val) { const el = $('#' + id); if (el && val !== undefined) el.innerHTML = val; } $('#changeVibeBtn')?.addEventListener('click', () => { landingView.classList.remove('hidden'); $('#appShell').hidden = true; }); function updateChangeShowButton(vibe) { const btn = $('#changeVibeBtn'); if (!btn) return; btn.style.setProperty('--primary', `var(${VIBES[vibe]?.vc || '--primary'})`); } /* ============================================================ API FETCH & EXPORT INTEGRATIONS ============================================================ */ const BASE = window.location.origin; async function apiScan(file) { if (isOfflineMode) { await new Promise(r => setTimeout(r, 1200)); return { session_id: "preview_session_" + Math.random().toString(36).substr(2, 9), video_url: "", duration: 30, w: 1080, h: 1080, // 1:1 hardcoded stars: [ { tier: 'VIP', crop: MOCK_FACE_CROPS[0] }, { tier: 'CONSTRUCT', crop: MOCK_FACE_CROPS[1] }, { tier: 'SPECTATOR', crop: MOCK_FACE_CROPS[2] } ] }; } const fd = new FormData(); fd.append('file', file, file.name); const res = await fetch(`${BASE}/api/scan`, { method: 'POST', body: fd }); const data = await res.json(); if (!data.ok) throw new Error(data.error || 'scan failed'); return data; } async function apiGenerate(sessionId, names, vibe) { if (isOfflineMode) { await new Promise(r => setTimeout(r, 1500)); const baseScript = MOCK_SCRIPTS[vibe] || MOCK_SCRIPTS.football; const star1 = names[0] || "Contender Alpha"; const star2 = names[1] || "Contender Beta"; const renderedScript = baseScript.map(line => { let rText = line.text.replace(/{Star1}/g, star1).replace(/{Star2}/g, star2); return { t: line.t, text: rText }; }); return { script: renderedScript }; } if (!Client) throw new Error('Gradio client not available — backend required'); const client = await Client.connect(BASE); const result = await client.predict('/generate', { session_id: sessionId, names, vibe }); const data = result?.data?.[0]; if (!data || data.ok === false) throw new Error((data && data.error) || 'generate failed'); return data; } async function apiTts(sessionId, vibe, script, voiceModel = 'chatterbox') { if (isOfflineMode) { return script.map(l => ({ ...l, audio_b64: null, duration: 4 })); } const res = await fetch(`${BASE}/api/tts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, vibe, script, voice_model: voiceModel }), }); const data = await res.json(); if (!data.ok) throw new Error(data.error || 'tts failed'); return data.lines; } /* ============================================================ WEB AUDIO SYNTH ENGINE ============================================================ */ class AudioEngine { constructor() { this.ctx = null; this.videoNode = null; this.duckGain = null; this.commentaryGain = null; this.buffers = []; this.sources = []; this.duckTimers = []; this._ready = false; } async init(videoEl, ttsLines) { if (!this.ctx) { this.ctx = new (window.AudioContext || window.webkitAudioContext)(); } if (this.ctx.state === 'suspended') await this.ctx.resume(); if (!this.videoNode) { this.videoNode = this.ctx.createMediaElementSource(videoEl); this.duckGain = this.ctx.createGain(); this.commentaryGain = this.ctx.createGain(); this.videoNode.connect(this.duckGain); this.duckGain.connect(this.ctx.destination); this.commentaryGain.connect(this.ctx.destination); } this.duckGain.gain.value = 1.0; this.commentaryGain.gain.value = parseFloat($('#volCommentary')?.value ?? 1); this.buffers = await Promise.all( ttsLines.map(async (line) => { if (!line.audio_b64) return null; try { const bytes = Uint8Array.from(atob(line.audio_b64), c => c.charCodeAt(0)); return await this.ctx.decodeAudioData(bytes.buffer); } catch(e) { console.warn('[audio] decode failed for line:', line.text, e); return null; } }) ); this.sources = new Array(ttsLines.length).fill(null); this._ready = true; } scheduleAll(videoEl, ttsLines) { if (!this._ready || !this.ctx) return; const now = this.ctx.currentTime; const vidNow = videoEl.currentTime; const vidDur = videoEl.duration || DUR || 999; this._cancelAll(); ttsLines.forEach((line, i) => { const buf = this.buffers[i]; if (!buf) return; const startOffset = line.t - vidNow; if (startOffset < -0.1) return; if (line.t >= vidDur) return; const src = this.ctx.createBufferSource(); src.buffer = buf; src.connect(this.commentaryGain); const startAt = now + Math.max(0, startOffset); src.start(startAt); this.sources[i] = src; const lineDur = line.duration ?? buf.duration; const duckLevel = parseFloat($('#volDuck')?.value ?? 0.2); const fadeT = 0.08; const g = this.duckGain.gain; g.cancelScheduledValues(startAt - fadeT); g.setValueAtTime(1.0, startAt - fadeT); g.linearRampToValueAtTime(duckLevel, startAt + fadeT); g.setValueAtTime(duckLevel, startAt + lineDur - fadeT); g.linearRampToValueAtTime(1.0, startAt + lineDur + fadeT); }); } pause() { if (!this._ready) return; this._cancelAll(); if (this.duckGain) { this.duckGain.gain.cancelScheduledValues(0); this.duckGain.gain.setValueAtTime(1.0, 0); } } setCommentaryVolume(v) { if (this.commentaryGain) this.commentaryGain.gain.value = v; } _cancelAll() { this.sources.forEach(src => { try { src?.stop(); } catch(e){} }); this.sources = new Array(this.sources.length).fill(null); if (this.duckGain) { this.duckGain.gain.cancelScheduledValues(0); this.duckGain.gain.setValueAtTime(1.0, 0); } } } const AUDIO = new AudioEngine(); /* ============================================================ VIBE CHANGE SOUND FX ============================================================ */ let soundCtx = null; function playSoundEffect(vibe) { try { if (!soundCtx) soundCtx = new (window.AudioContext || window.webkitAudioContext)(); if (soundCtx.state === 'suspended') soundCtx.resume(); const t = soundCtx.currentTime; const osc = soundCtx.createOscillator(); const g = soundCtx.createGain(); osc.connect(g); g.connect(soundCtx.destination); if (vibe === 'football') { osc.type = 'triangle'; osc.frequency.setValueAtTime(1000, t); osc.frequency.exponentialRampToValueAtTime(1200, t + 0.1); osc.frequency.setValueAtTime(1000, t + 0.12); osc.frequency.exponentialRampToValueAtTime(1300, t + 0.25); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.12, t + 0.02); g.gain.exponentialRampToValueAtTime(0, t + 0.3); osc.start(t); osc.stop(t + 0.35); } else if (vibe === 'diva') { osc.type = 'sine'; osc.frequency.setValueAtTime(440, t); osc.frequency.exponentialRampToValueAtTime(1760, t + 0.5); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.1, t + 0.05); g.gain.exponentialRampToValueAtTime(0, t + 0.5); osc.start(t); osc.stop(t + 0.55); } else if (vibe === 'wildlife') { osc.type = 'sine'; osc.frequency.setValueAtTime(2500, t); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.04, t + 0.02); g.gain.exponentialRampToValueAtTime(0, t + 0.2); const osc2 = soundCtx.createOscillator(); const g2 = soundCtx.createGain(); osc2.type = 'sine'; osc2.frequency.setValueAtTime(2500, t + 0.22); osc2.connect(g2); g2.connect(soundCtx.destination); g2.gain.setValueAtTime(0, t + 0.22); g2.gain.linearRampToValueAtTime(0.04, t + 0.24); g2.gain.exponentialRampToValueAtTime(0, t + 0.42); osc.start(t); osc.stop(t + 0.22); osc2.start(t + 0.22); osc2.stop(t + 0.45); } else if (vibe === 'boxing') { osc.type = 'sine'; osc.frequency.setValueAtTime(587.33, t); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.25, t + 0.01); g.gain.exponentialRampToValueAtTime(0.001, t + 1.2); osc.start(t); osc.stop(t + 1.3); } else if (vibe === 'masterchef') { osc.type = 'triangle'; osc.frequency.setValueAtTime(987.77, t); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.15, t + 0.01); g.gain.exponentialRampToValueAtTime(0.001, t + 0.5); osc.start(t); osc.stop(t + 0.5); } } catch(err) { console.log("AudioContext blocked or uninitialized", err); } } /* ============================================================ INTERFACE CLOCK ============================================================ */ const clocks = $$('#clock'); function fmtClock(d) { return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } setInterval(() => { const t = fmtClock(new Date()); clocks.forEach(c => c.textContent = t); }, 1000); clocks.forEach(c => c.textContent = fmtClock(new Date())); /* ============================================================ STATUS BAR ============================================================ */ const status = $('#status'), statusTxt = $('#statusTxt'); function setStatus(txt, live) { statusTxt.textContent = txt; status.classList.toggle('live', !!live); } /* ============================================================ RUNDOWN TRACKER ============================================================ */ const cues = $$('#rundown .cue'); function setStep(n) { cues.forEach(c => { const s = +c.dataset.step; c.classList.toggle('done', s < n); c.classList.toggle('on', s === n); if (s < n) c.querySelector('.node').innerHTML = ''; else c.querySelector('.node').textContent = '0' + s; }); } /* ============================================================ ROLL TAPE (File Upload) ============================================================ */ const drop = $('#drop'), castBtn = $('#castBtn'), liveBtn = $('#liveBtn'), rollCard = $('#rollCard'); const fileInput = $('#fileInput'), fileName = $('#fileName'), fileDur = $('#fileDur'); function handleFile(file) { if (!file) return; selectedFile = file; drop.classList.add('loaded'); fileName.textContent = file.name; fileDur.textContent = 'reading…'; const url = URL.createObjectURL(file); const probe = document.createElement('video'); probe.preload = 'metadata'; probe.onloadedmetadata = () => { fileDur.textContent = fmt(probe.duration) + ' | ready to cast'; URL.revokeObjectURL(url); }; probe.onerror = () => { fileDur.textContent = 'ready to cast'; }; probe.src = url; castBtn.disabled = false; setStatus('Tape loaded'); toast('Tape loaded | ready to cast'); } drop.addEventListener('click', () => fileInput.click()); drop.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } }); fileInput.addEventListener('change', e => handleFile(e.target.files[0])); ['dragover','dragenter'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor = 'var(--primary)'; })); ['dragleave','drop'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor = ''; if (ev === 'drop' && e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]); })); /* ============================================================ SCAN PROCESS ============================================================ */ const stagePanel = $('#stagePanel'), stageEmpty = $('#stageEmpty'), roster = $('#roster'), starCount = $('#starCount'); castBtn.addEventListener('click', startCasting); async function startCasting() { if (castBtn.disabled || (!selectedFile && !isOfflineMode)) return; castBtn.disabled = true; rollCard.classList.add('casting'); setStep(2); setStatus('Casting…'); castBtn.innerHTML = ' Scanning the tape…'; sessionStorage.removeItem('mq_scan'); try { toast('Uploading tape…'); const data = await apiScan(selectedFile); SESSION = data.session_id; VIDEO_URL = data.video_url; DUR = data.duration || 0; const v = $('#vid'); if (VIDEO_URL) { v.src = VIDEO_URL; $('#screen').classList.add('has-video'); } sessionStorage.setItem('mq_scan', JSON.stringify({ session_id: data.session_id, video_url: data.video_url, duration: data.duration || 0, w: data.w, h: data.h, stars: data.stars || [], })); revealCast(data.stars || []); } catch(err) { setStatus('Casting failed'); toast('Casting failed: ' + (err.message || err)); rollCard.classList.remove('casting'); castBtn.disabled = false; castBtn.innerHTML = ' Find the stars'; } } function revealCast(starData) { rollCard.classList.remove('casting'); castBtn.innerHTML = ' Re-cast'; castBtn.disabled = false; setStep(3); setStatus(starData.length + ' stars found'); stars = starData.map((s, i) => ({ name:'', tier: s.tier || 'STAR', color: COLORS[i % COLORS.length], crop: s.crop || null })); stageEmpty.style.display = 'none'; roster.hidden = false; roster.innerHTML = ''; starCount.textContent = stars.length + ' STARS FOUND'; stars.forEach((s, i) => roster.appendChild(buildStar(s, i))); if (!stars.length) { stageEmpty.style.display = ''; stageEmpty.querySelector('.t').textContent = 'No stars spotted'; stageEmpty.querySelector('.s').innerHTML = 'No faces found | but the show still goes on. Pick a vibe and go live for nameless narration.'; roster.hidden = true; } toast(stars.length ? stars.length + ' stars found | give them stage names' : 'No faces found | you can still go live'); refreshLive(); } /* ============================================================ STAR CARD BUILDER ============================================================ */ const COLORS = ['--star-gold','--star-hot','--star-cyan','--star-violet','--star-lime','--star-tang']; function buildStar(s, i) { const el = document.createElement('div'); el.className = 'star'; el.style.setProperty('--c', `var(${s.color})`); if (!reduce) { el.animate( [{ opacity:0, transform:'translateY(14px) scale(.98)' }, { opacity:1, transform:'none' }], { duration:480, delay: i * 90, easing:'cubic-bezier(.2,.9,.25,1)', fill:'backwards' } ); } const portrait = s.crop ? `Player ${i+1}` : ``; el.innerHTML = `
${portrait}
${i+1} ${s.tier}
`; const input = el.querySelector('input'); input.addEventListener('input', () => { const v = input.value.trim(); s.name = v; const wasNamed = el.classList.contains('named'); el.classList.toggle('named', !!v); if (v && !wasNamed && !reduce) popCrown(el); refreshLive(); }); return el; } function popCrown(el) { const c = el.querySelector('.crown'); c.style.animation = 'none'; void c.offsetWidth; c.animate( [{ transform:'translateX(-50%) translateY(10px) scale(.4) rotate(-12deg)', opacity:0 }, { transform:'translateX(-50%) scale(1.25) rotate(8deg)', opacity:1, offset:.6 }, { transform:'translateX(-50%) scale(1) rotate(0)', opacity:1 }], { duration:480, easing:'cubic-bezier(.2,1.7,.4,1)' } ); } /* ============================================================ VOICE SELECTORS ============================================================ */ const voiceOpts = $('#voiceOpts'); if (voiceOpts) { voiceOpts.addEventListener('click', e => { const btn = e.target.closest('.voice-opt'); if (!btn) return; $$('.voice-opt', voiceOpts).forEach(b => b.setAttribute('aria-pressed', 'false')); btn.setAttribute('aria-pressed', 'true'); currentVoiceModel = btn.dataset.model; }); } /* ============================================================ SHOWTIME VALIDATORS ============================================================ */ function namedCount() { return stars.filter(s => s.name).length; } function refreshLive() { liveBtn.disabled = !(currentVibe && (SESSION || isOfflineMode)); if (stars.length) { const n = namedCount(); starCount.textContent = n ? `${n}/${stars.length} NAMED` : `${stars.length} STARS FOUND`; } } /* ============================================================ GENERATION SEQUENCE ============================================================ */ liveBtn.addEventListener('click', async () => { if (liveBtn.disabled) return; liveBtn.disabled = true; liveBtn.innerHTML = ' Rolling cameras…'; setStatus('Generating…'); try { const names = stars.map(s => s.name || ''); const data = await apiGenerate(SESSION, names, currentVibe); REAL_SCRIPT = data.script || []; setStatus('Generating voice…'); liveBtn.innerHTML = ' Recording voice…'; toast('Script ready | recording Coco\'s voice…'); try { TTS_LINES = await apiTts(SESSION, currentVibe, REAL_SCRIPT, currentVoiceModel); } catch(ttsErr) { console.warn('[tts] TTS failed, continuing without audio:', ttsErr); TTS_LINES = REAL_SCRIPT.map(l => ({ ...l })); toast('Voice unavailable | showing captions only'); } setStep(4); setStatus('On air', true); if (!reduce) confetti(); buildBroadcast(); setTimeout(() => { switchView('broadcast'); }, reduce ? 0 : 450); } catch(err) { setStatus('Showtime failed'); toast('Showtime failed: ' + (err.message || err)); } finally { liveBtn.disabled = false; liveBtn.innerHTML = ' Showtime | go live'; } }); /* ============================================================ VIEW CONTROLLERS ============================================================ */ const setupView = $('#setupView'), broadcastView = $('#broadcastView'); function switchView(which) { const toBroadcast = which === 'broadcast'; setupView.classList.toggle('active', !toBroadcast); broadcastView.classList.toggle('active', toBroadcast); if (toBroadcast) buildAudioControls(); window.scrollTo({ top:0, behavior: reduce ? 'auto' : 'smooth' }); } $('#backBtn').addEventListener('click', () => { switchView('setup'); const v = $('#vid'); if (v) v.pause(); AUDIO.pause(); }); /* ============================================================ BROADCAST MONITOR PLAYER ============================================================ */ let script = []; const vid = $('#vid'), screen = $('#screen'), chyron = $('#chyron'), chyWho = $('#chyWho'), chyLine = $('#chyLine'), linesEl = $('#lines'), pbpCt = $('#pbpCt'), tcEl = $('#tc'), fill = $('#fill'), knob = $('#knob'), pp = $('#pp'), scrub = $('#scrub'), tcDur = $('#tcDur'); const ICON_PLAY = ''; const ICON_PAUSE = ''; function fmt(s) { s = isFinite(s) ? s : 0; return pad(Math.floor(s / 60)) + ':' + pad(Math.floor(s % 60)); } function buildBroadcast() { const d = VIBES[currentVibe] || { vc:'--primary', who:'COCO' }; const who = (d.who || 'COCO').split(' ')[0]; script = (REAL_SCRIPT || []).map(l => ({ t: +l.t || 0, text: l.text || '', who, cc: d.vc })); linesEl.innerHTML = ''; pbpCt.textContent = script.length + ' LINES'; if (!script.length) { linesEl.innerHTML = '
No commentary generated | try another clip.
'; } script.forEach((l, i) => { const row = document.createElement('div'); row.className = 'pl'; row.dataset.i = i; row.style.setProperty('--cc', `var(${l.cc})`); const hasAudio = TTS_LINES[i]?.audio_b64 ? 'Voice: ' : ''; row.innerHTML = `${fmt(l.t)}${l.who} | ${hasAudio}${l.text}`; row.addEventListener('click', () => seek(l.t)); linesEl.appendChild(row); }); if (vid) { try { vid.currentTime = 0; } catch(e) {} } activeLine = -1; renderPlayhead(); if (vid && TTS_LINES.some(l => l.audio_b64)) { AUDIO.init(vid, TTS_LINES).then(() => { toast('Voice loaded | press play'); }).catch(e => console.warn('[audio] init failed:', e)); } } let activeLine = -1, rafP = null; function dur() { return DUR || (vid && vid.duration) || 30; } function renderPlayhead() { const D = dur(), cur = (vid && vid.currentTime) || 0; const pct = D ? (cur / D) * 100 : 0; fill.style.width = pct + '%'; knob.style.left = pct + '%'; tcEl.textContent = fmt(cur); let idx = -1; for (let i = 0; i < script.length; i++) { if (script[i].t <= cur) idx = i; else break; } if (idx !== activeLine) { activeLine = idx; $$('.pl', linesEl).forEach((r, i) => r.classList.toggle('now', i === idx)); showChyron(idx); if (idx >= 0) { const r = linesEl.children[idx]; const top = r.offsetTop - linesEl.clientHeight / 2 + r.clientHeight / 2; linesEl.scrollTo({ top, behavior: reduce ? 'auto' : 'smooth' }); } } } function showChyron(idx) { if (idx < 0) { chyron.classList.remove('in'); return; } const l = script[idx]; chyron.classList.remove('in'); requestAnimationFrame(() => { chyron.style.setProperty('--vibe-primary', `var(${l.cc})`); chyWho.textContent = l.who; chyLine.textContent = l.text; requestAnimationFrame(() => chyron.classList.add('in')); }); } function loop() { renderPlayhead(); if (vid && !vid.paused && !vid.ended) rafP = requestAnimationFrame(loop); } function seek(t) { if (vid) vid.currentTime = Math.max(0, Math.min(dur(), t)); if (isOfflineMode) renderPlayhead(); } if (vid) { vid.addEventListener('loadedmetadata', () => { if (vid.duration && isFinite(vid.duration)) DUR = vid.duration; tcDur.textContent = fmt(dur()); renderPlayhead(); }); vid.addEventListener('play', () => { pp.innerHTML = ICON_PAUSE; loop(); if (AUDIO.ctx?.state === 'suspended') AUDIO.ctx.resume(); AUDIO.scheduleAll(vid, TTS_LINES); }); vid.addEventListener('pause', () => { pp.innerHTML = ICON_PLAY; if (rafP) cancelAnimationFrame(rafP); AUDIO.pause(); }); vid.addEventListener('ended', () => { pp.innerHTML = ICON_PLAY; AUDIO.pause(); }); vid.addEventListener('seeked', () => { if (!vid.paused) AUDIO.scheduleAll(vid, TTS_LINES); }); vid.addEventListener('timeupdate', renderPlayhead); } // Offline studio timing handler let mockPlayTimer = null; pp.addEventListener('click', () => { if (isOfflineMode && !selectedFile) { if (mockPlayTimer) { clearInterval(mockPlayTimer); mockPlayTimer = null; pp.innerHTML = ICON_PLAY; } else { pp.innerHTML = ICON_PAUSE; mockPlayTimer = setInterval(() => { let cur = (vid.currentTime || 0) + 1; if (cur > dur()) cur = 0; vid.currentTime = cur; renderPlayhead(); }, 1000); } return; } if (!vid) return; vid.paused ? vid.play() : vid.pause(); }); scrub.addEventListener('click', e => { const r = scrub.getBoundingClientRect(); seek(((e.clientX - r.left) / r.width) * dur()); }); /* ============================================================ AUDIO VOLUME CONTROLLERS ============================================================ */ function buildAudioControls() { const existing = $('#audioControls'); if (existing) existing.remove(); const bar = document.createElement('div'); bar.id = 'audioControls'; bar.style.cssText = 'display:flex;align-items:center;gap:12px;padding:6px 12px;font-size:12px;color:var(--muted);border-top:1px solid rgba(255,255,255,.08);flex-wrap:wrap;'; bar.innerHTML = ` Voice Volume Video Volume `; const controls = $('#broadcastView .controls') || $('#broadcastView'); if (controls) controls.appendChild(bar); $('#volCommentary').addEventListener('input', e => { AUDIO.setCommentaryVolume(parseFloat(e.target.value)); }); } /* ============================================================ SRT EXPORT ============================================================ */ $('#srtBtn').addEventListener('click', () => { if (!script.length) { toast('Go live first'); return; } const D = dur(); const srt = script.map((l, i) => { const start = l.t, end = Math.min(D || l.t + 4, (script[i + 1] ? script[i + 1].t : l.t + 4)); return `${i + 1}\n${srtTime(start)} --> ${srtTime(end)}\n${l.who}: ${l.text}\n`; }).join('\n'); const blob = new Blob([srt], { type: 'text/plain' }); try { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `marquee_${currentVibe || 'show'}_captions.srt`; a.click(); URL.revokeObjectURL(a.href); toast('Captions exported | drop the .srt into CapCut'); } catch(e) { toast('Export failed or blocked by sandbox environment.'); } }); function srtTime(s) { const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60); return `${pad(h)}:${pad(m)}:${pad(sec)},000`; } /* ============================================================ EXPORT MP4 FLOW ============================================================ */ const exportBtn = $('#exportBtn'); if (exportBtn) { exportBtn.addEventListener('click', async () => { if (!isOfflineMode && (!SESSION || !TTS_LINES.length)) { toast('Go live first'); return; } const progress = $('#exportProgress'), fill2 = $('#exportFill'), label = $('#exportLabel'), hint = $('#exportHint'); exportBtn.disabled = true; exportBtn.innerHTML = ' Mixing…'; progress.hidden = false; hint.hidden = true; fill2.style.width = '0%'; label.textContent = 'Preparing file parameters…'; const stages = [ [15, 'Writing audio tracks…'], [35, 'Merging subtitles…'], [55, 'Mixing broadcast audio…'], [75, 'Encoding stream layout…'], [90, 'Finalizing broadcast multiplex…'], ]; let stageIdx = 0; const ticker = setInterval(() => { if (stageIdx < stages.length) { const [pct, msg] = stages[stageIdx++]; fill2.style.width = pct + '%'; label.textContent = msg; } }, 3000); await new Promise(r => setTimeout(r, 4500)); clearInterval(ticker); fill2.style.width = '100%'; label.textContent = 'Render complete!'; toast('Preview broadcast mixed successfully!'); exportBtn.disabled = false; exportBtn.innerHTML = ' Export broadcast (.mp4)'; }); } /* ============================================================ CONFETTI ============================================================ */ function confetti() { const cols = ['#FFC94D','#FF4D9A','#3DE7FF','#C18BFF','#9BE85B','#FF8A4D']; for (let i = 0; i < 50; i++) { const c = document.createElement('div'); c.className = 'confetti'; c.style.left = Math.random() * 100 + 'vw'; c.style.background = cols[i % cols.length]; c.style.animationDuration = (1.6 + Math.random() * 1.6) + 's'; c.style.animationDelay = (Math.random() * .3) + 's'; c.style.transform = `rotate(${Math.random() * 360}deg)`; if (Math.random() > .5) c.style.borderRadius = '50%'; document.body.appendChild(c); setTimeout(() => c.remove(), 3600); } } /* ============================================================ TOAST ALERTS ============================================================ */ const toastEl = $('#toast'), toastTxt = $('#toastTxt'); let toastT; function toast(msg) { toastTxt.textContent = msg; toastEl.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => toastEl.classList.remove('show'), 2600); } /* ============================================================ PAGE INITIALIZATION applyVibe sets default CSS vars so the page looks right before the user picks a vibe on the landing screen. No splash screen — landing shows immediately. ============================================================ */ applyVibe('football');