/* ============================================================
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
? ``
: ``;
el.innerHTML = `