| |
| |
| |
| |
|
|
| |
| let isOfflineMode = false; |
|
|
| |
| 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 = []; |
|
|
| |
| |
| |
| 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 <b>Scout the pitch</b> | every face gets a squad card.", |
| backBtnLabel: "Back to dugout", |
| pbpTitle: "Match commentary", |
| noteText: "Your clip stays on <b>your device</b>. 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 <b>Who's arriving?</b> | 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 <b>Locate specimens</b> | 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 <b>Find the fighters</b> | 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 <b>Assess the contestants</b> | 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.", |
| }, |
| }; |
|
|
| |
| |
| |
| 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" |
| ]; |
|
|
| |
| |
| |
| |
| 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); |
| }); |
|
|
| |
| |
| |
| 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 = `<svg class="i" style="width:20px; height:20px;"><use href="${vibeData.acc}"/></svg>`; |
| 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'})`); |
| } |
|
|
| |
| |
| |
| 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, |
| 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; |
| } |
|
|
| |
| |
| |
| 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(); |
|
|
| |
| |
| |
| 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); |
| } |
| } |
|
|
| |
| |
| |
| 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())); |
|
|
| |
| |
| |
| const status = $('#status'), statusTxt = $('#statusTxt'); |
| function setStatus(txt, live) { statusTxt.textContent = txt; status.classList.toggle('live', !!live); } |
|
|
| |
| |
| |
| 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 = '<svg class="i" style="width:18px;height:18px"><use href="#i-check"/></svg>'; |
| else c.querySelector('.node').textContent = '0' + s; |
| }); |
| } |
|
|
| |
| |
| |
| 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]); |
| })); |
|
|
| |
| |
| |
| 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 = '<svg class="i"><use href="#i-spot"/></svg> 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 = '<svg class="i"><use href="#i-spot"/></svg> Find the stars'; |
| } |
| } |
|
|
| function revealCast(starData) { |
| rollCard.classList.remove('casting'); |
| castBtn.innerHTML = '<svg class="i"><use href="#i-spot"/></svg> 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(); |
| } |
|
|
| |
| |
| |
| 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 |
| ? `<img class="head-img" src="${s.crop}" alt="Player ${i+1}">` |
| : `<svg class="i head-ph" style="width:64px;height:68px"><use href="#i-portrait"/></svg>`; |
| el.innerHTML = ` |
| <div class="portrait"> |
| <div class="beam"></div> |
| ${portrait} |
| <div class="frame"></div> |
| <span class="idx"><span class="st">★</span>${i+1}</span> |
| <span class="tier">${s.tier}</span> |
| <svg class="i crown"><use href="#i-crown"/></svg> |
| </div> |
| <div class="star-body"> |
| <label for="star${i}">Stage name</label> |
| <input id="star${i}" type="text" placeholder="e.g. The Maestro" value="${s.name}" maxlength="22" autocomplete="off"> |
| </div>`; |
| 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)' } |
| ); |
| } |
|
|
| |
| |
| |
| 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; |
| }); |
| } |
|
|
| |
| |
| |
| 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`; |
| } |
| } |
|
|
| |
| |
| |
| liveBtn.addEventListener('click', async () => { |
| if (liveBtn.disabled) return; |
| liveBtn.disabled = true; |
| liveBtn.innerHTML = '<svg class="i"><use href="#i-star"/></svg> 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 = '<svg class="i"><use href="#i-star"/></svg> 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 = '<svg class="i"><use href="#i-star"/></svg> Showtime | go live'; |
| } |
| }); |
|
|
| |
| |
| |
| 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(); |
| }); |
|
|
| |
| |
| |
| 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 = '<svg class="i"><use href="#i-play"/></svg>'; |
| const ICON_PAUSE = '<svg class="i"><use href="#i-pause"/></svg>'; |
|
|
| 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 = '<div class="pl"><span class="tx">No commentary generated | try another clip.</span></div>'; |
| } |
| 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 = `<span class="t">${fmt(l.t)}</span><span class="tx"><b>${l.who}</b> | ${hasAudio}${l.text}</span>`; |
| 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); |
| } |
|
|
| |
| 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()); |
| }); |
|
|
| |
| |
| |
| 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 = ` |
| <span style="opacity:.6">Voice Volume</span> |
| <input id="volCommentary" type="range" min="0" max="2" step="0.05" value="1" |
| style="width:80px;accent-color:var(--accent)" title="Commentary volume"> |
| <span style="opacity:.6">Video Volume</span> |
| <input id="volDuck" type="range" min="0" max="1" step="0.05" value="0.2" |
| style="width:80px;accent-color:var(--secondary)" title="Video volume while speaking"> |
| <span id="audioStatus" style="opacity:.5;margin-left:4px"></span>`; |
| const controls = $('#broadcastView .controls') || $('#broadcastView'); |
| if (controls) controls.appendChild(bar); |
| $('#volCommentary').addEventListener('input', e => { AUDIO.setCommentaryVolume(parseFloat(e.target.value)); }); |
| } |
|
|
| |
| |
| |
| $('#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`; |
| } |
|
|
| |
| |
| |
| 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 = '<svg class="i ic"><use href="#i-download"/></svg> 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 = '<svg class="i ic"><use href="#i-download"/></svg> Export broadcast (.mp4)'; |
| }); |
| } |
|
|
| |
| |
| |
| 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); |
| } |
| } |
|
|
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| applyVibe('football'); |