marquee / marquee.js
mamuflih13's picture
Refactor code structure for improved readability and maintainability
2592fb6
Raw
History Blame Contribute Delete
43.1 kB
/* ============================================================
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 <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.",
},
};
/* ============================================================
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 = `<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'})`);
}
/* ============================================================
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 = '<svg class="i" style="width:18px;height:18px"><use href="#i-check"/></svg>';
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 = '<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();
}
/* ============================================================
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
? `<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)' }
);
}
/* ============================================================
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 = '<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';
}
});
/* ============================================================
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 = '<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);
}
// 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 = `
<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)); });
}
/* ============================================================
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 = '<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)';
});
}
/* ============================================================
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');