add tts
#1
by mamuflih13 - opened
- Marquee.html +181 -245
- README.md +61 -78
- app.py +45 -298
- commentary.py +36 -106
- faces.py +123 -291
- marquee.css +453 -331
- marquee.js +307 -870
- marquee_inlined_preview.html +1093 -0
- ov_models.py +39 -26
- requirements.txt +4 -24
- tts.py +0 -233
Marquee.html
CHANGED
|
@@ -1,284 +1,220 @@
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;500;600;700;800&family=Bebas+Neue&family=Nunito:wght@600;700;800;900&family=Playfair+Display:ital,wght@1,700&family=Special+Elite&display=swap" rel="stylesheet">
|
| 12 |
-
|
| 13 |
-
<link rel="stylesheet" href="marquee.css">
|
| 14 |
</head>
|
| 15 |
<body>
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
<!-- ============================
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
<div class="
|
| 53 |
-
<div class="bulbs t"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
| 54 |
-
<h1 class="disp">MARQUEE</h1>
|
| 55 |
-
<div class="bulbs b"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
| 56 |
-
</div>
|
| 57 |
-
<p class="landing-tag">Drop a clip. Pick your show. Everyone becomes a headliner.</p>
|
| 58 |
-
</div>
|
| 59 |
-
|
| 60 |
-
<div class="landing-pick">
|
| 61 |
-
<p class="landing-prompt">Choose your broadcast style</p>
|
| 62 |
-
<div class="landing-vibes" id="landingVibes">
|
| 63 |
-
|
| 64 |
-
<button class="lv-card" data-vibe="football">
|
| 65 |
-
<div class="lv-icon"><svg class="i"><use href="#i-whistle"/></svg></div>
|
| 66 |
-
<div class="lv-body">
|
| 67 |
-
<span class="lv-name">Football</span>
|
| 68 |
-
<span class="lv-desc">World Cup energy. Roaring play-by-play commentary for the beautiful game.</span>
|
| 69 |
-
</div>
|
| 70 |
-
<svg class="lv-arrow i"><use href="#i-arrow-r"/></svg>
|
| 71 |
-
</button>
|
| 72 |
-
|
| 73 |
-
<button class="lv-card" data-vibe="diva">
|
| 74 |
-
<div class="lv-icon"><svg class="i"><use href="#i-glam"/></svg></div>
|
| 75 |
-
<div class="lv-body">
|
| 76 |
-
<span class="lv-name">Diva Hour</span>
|
| 77 |
-
<span class="lv-desc">Red carpet glamour. Fashion feedback, high drama, and unsolicited feedback.</span>
|
| 78 |
-
</div>
|
| 79 |
-
<svg class="lv-arrow i"><use href="#i-arrow-r"/></svg>
|
| 80 |
-
</button>
|
| 81 |
-
|
| 82 |
-
<button class="lv-card" data-vibe="wildlife">
|
| 83 |
-
<div class="lv-icon"><svg class="i"><use href="#i-paw"/></svg></div>
|
| 84 |
-
<div class="lv-body">
|
| 85 |
-
<span class="lv-name">Wildlife</span>
|
| 86 |
-
<span class="lv-desc">Hushed. Awestruck. Narrating your friends like rare creatures in the wild.</span>
|
| 87 |
-
</div>
|
| 88 |
-
<svg class="lv-arrow i"><use href="#i-arrow-r"/></svg>
|
| 89 |
-
</button>
|
| 90 |
-
|
| 91 |
-
<button class="lv-card" data-vibe="boxing">
|
| 92 |
-
<div class="lv-icon"><svg class="i"><use href="#i-glove"/></svg></div>
|
| 93 |
-
<div class="lv-body">
|
| 94 |
-
<span class="lv-name">Boxing</span>
|
| 95 |
-
<span class="lv-desc">Ringside. Every move is championship-defining. Nobody is safe from the hype.</span>
|
| 96 |
-
</div>
|
| 97 |
-
<svg class="lv-arrow i"><use href="#i-arrow-r"/></svg>
|
| 98 |
-
</button>
|
| 99 |
-
|
| 100 |
-
<button class="lv-card" data-vibe="masterchef">
|
| 101 |
-
<div class="lv-icon"><svg class="i"><use href="#i-chef"/></svg></div>
|
| 102 |
-
<div class="lv-body">
|
| 103 |
-
<span class="lv-name">MasterChef</span>
|
| 104 |
-
<span class="lv-desc">High stakes. Brutal precision. A cooking-show judge who takes everything personally.</span>
|
| 105 |
-
</div>
|
| 106 |
-
<svg class="lv-arrow i"><use href="#i-arrow-r"/></svg>
|
| 107 |
-
</button>
|
| 108 |
-
|
| 109 |
-
</div>
|
| 110 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
</div>
|
| 112 |
-
</
|
| 113 |
|
| 114 |
<!-- ============================================================
|
| 115 |
-
|
| 116 |
============================================================ -->
|
| 117 |
-
<
|
| 118 |
-
|
| 119 |
-
<!--
|
| 120 |
-
<
|
| 121 |
-
<div class="
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
<
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
<
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
<!-- Dynamic Vibe Strip Banner -->
|
| 147 |
-
<div class="vibe-banner" id="vibeBanner">
|
| 148 |
-
<div class="vibe-banner-icon" id="vibeBannerIcon"></div>
|
| 149 |
-
<div class="vibe-banner-text" style="flex: 1;">
|
| 150 |
-
<span class="vibe-banner-name skew-header" id="vibeBannerName">Football</span>
|
| 151 |
-
<span class="vibe-banner-desc" id="vibeBannerDesc">Turn any clip into a live sports broadcast.</span>
|
| 152 |
-
</div>
|
| 153 |
-
<div class="banner-wave" id="bannerWave" aria-hidden="true">
|
| 154 |
-
<span></span><span></span><span></span><span></span><span></span>
|
| 155 |
-
</div>
|
| 156 |
-
</div>
|
| 157 |
-
|
| 158 |
-
<div class="console">
|
| 159 |
-
|
| 160 |
-
<div class="desk">
|
| 161 |
-
|
| 162 |
-
<!-- Roll Tape -->
|
| 163 |
-
<div class="card" id="rollCard">
|
| 164 |
-
<div class="card-h"><svg class="i ic"><use href="#i-clap"/></svg><h2 id="rollCardTitle">Roll Tape</h2><span class="meta">SRC 01</span></div>
|
| 165 |
-
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Drop a video clip">
|
| 166 |
-
<div class="ask">
|
| 167 |
-
<span class="clap"><svg class="i"><use href="#i-clap"/></svg></span>
|
| 168 |
-
<div class="t" id="dropTitle">Drop a clip on the desk</div>
|
| 169 |
-
<div class="s">MP4 | MOV | up to 60s | portrait, landscape or square</div>
|
| 170 |
-
</div>
|
| 171 |
-
<div class="loaded">
|
| 172 |
-
<div class="strip"><span class="play"><svg class="i"><use href="#i-play"/></svg></span></div>
|
| 173 |
-
<div>
|
| 174 |
-
<div class="fn" id="fileName">clip.mp4</div>
|
| 175 |
-
<div class="fd"><span id="fileDur">00:42</span> | ready to cast</div>
|
| 176 |
-
</div>
|
| 177 |
</div>
|
| 178 |
</div>
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
</button>
|
| 184 |
-
<div class="sweep"></div>
|
| 185 |
</div>
|
| 186 |
|
| 187 |
-
<!--
|
| 188 |
-
<div class="
|
| 189 |
-
<div class="
|
| 190 |
-
|
| 191 |
-
<
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
</
|
| 195 |
-
<
|
| 196 |
-
<span class="vt">Orpheus</span>
|
| 197 |
-
<span class="vd">Richer | slower | 3B</span>
|
| 198 |
-
</button>
|
| 199 |
</div>
|
| 200 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
-
|
| 203 |
-
<button class="btn show" id="liveBtn" disabled>
|
| 204 |
-
<svg class="i"><use href="#i-star"/></svg>
|
| 205 |
-
<span id="liveBtnLabel">Showtime | go live</span>
|
| 206 |
-
</button>
|
| 207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
</div>
|
| 209 |
|
| 210 |
-
<!--
|
| 211 |
-
<div class="
|
| 212 |
-
<div class="
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
<span class="count" id="starCount">NO CAST YET</span>
|
| 216 |
-
</div>
|
| 217 |
-
<div class="empty" id="stageEmpty">
|
| 218 |
-
<div class="spot"><svg class="i"><use href="#i-spot"/></svg></div>
|
| 219 |
-
<div class="t" id="stageEmptyTitle">The stage is dark</div>
|
| 220 |
-
<div class="s" id="stageEmptySub">Roll a clip and hit <b id="stageEmptyCta">Find the stars</b> | everyone we spot gets a card under the spotlight.</div>
|
| 221 |
-
</div>
|
| 222 |
-
<div class="roster" id="roster" hidden></div>
|
| 223 |
</div>
|
| 224 |
|
|
|
|
|
|
|
| 225 |
</div>
|
| 226 |
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
</
|
| 231 |
-
|
|
|
|
|
|
|
| 232 |
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
</div>
|
|
|
|
| 238 |
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
</div>
|
| 252 |
</div>
|
| 253 |
</div>
|
| 254 |
-
<div class="transport">
|
| 255 |
-
<button class="pp" id="pp" aria-label="Play / pause"><svg class="i"><use href="#i-play"/></svg></button>
|
| 256 |
-
<div class="scrub" id="scrub"><div class="fill" id="fill"></div><div class="knob" id="knob"></div></div>
|
| 257 |
-
<span class="clock" id="tcDur" style="font-size:16px">00:42</span>
|
| 258 |
-
</div>
|
| 259 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
<div class="export-progress" id="exportProgress" hidden>
|
| 269 |
-
<div class="export-bar"><div class="export-fill" id="exportFill"></div></div>
|
| 270 |
-
<span class="export-label" id="exportLabel">Mixing audio…</span>
|
| 271 |
-
</div>
|
| 272 |
-
<div class="hint" id="exportHint" hidden>Video + Coco's voice + captions | ready to post.</div>
|
| 273 |
-
</div>
|
| 274 |
</div>
|
| 275 |
</div>
|
| 276 |
-
</
|
|
|
|
| 277 |
|
| 278 |
-
|
| 279 |
|
| 280 |
-
|
| 281 |
|
| 282 |
-
|
| 283 |
</body>
|
| 284 |
-
</html>
|
|
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Marquee — Setup Console & Broadcast</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;500;600;700;800&family=Bebas+Neue&family=Nunito:wght@600;700;800;900&display=swap" rel="stylesheet">
|
| 10 |
+
<link rel="stylesheet" href="marquee.css">
|
|
|
|
|
|
|
|
|
|
| 11 |
</head>
|
| 12 |
<body>
|
| 13 |
|
| 14 |
+
<!-- =======================================================
|
| 15 |
+
ICON SPRITE — line icons, broadcast vernacular
|
| 16 |
+
======================================================= -->
|
| 17 |
+
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
| 18 |
+
<defs>
|
| 19 |
+
<symbol id="i-clap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><path d="M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Z"/><path d="m3 8 1.6-3.4 3.8 1L6.7 9M9.4 5.6l3.8 1L11.5 10M15 7l3.8 1-1.5 3.4M21 8l-.8-2.2-3.4-.9"/></symbol>
|
| 20 |
+
<symbol id="i-play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></symbol>
|
| 21 |
+
<symbol id="i-pause" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></symbol>
|
| 22 |
+
<symbol id="i-star" viewBox="0 0 24 24" fill="currentColor"><path d="m12 2 2.9 6.3 6.9.8-5.1 4.7 1.4 6.8L12 17.8 5.9 20.6l1.4-6.8L2.2 9.1l6.9-.8z"/></symbol>
|
| 23 |
+
<symbol id="i-spot" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 3v3M5 6l2 2M19 6l-2 2"/><path d="M9 9h6l3 11H6z" fill="currentColor" opacity=".15"/><path d="M9 9h6l3 11H6z"/></symbol>
|
| 24 |
+
<symbol id="i-crown" viewBox="0 0 24 24" fill="currentColor"><path d="M3 7l4 4 5-7 5 7 4-4-2 12H5z"/></symbol>
|
| 25 |
+
<symbol id="i-bird" viewBox="0 0 24 24" fill="currentColor"><path d="M14 4a3 3 0 0 0-3 3c0 .5-.4 1-1 1.5-1.8 1.3-3 3.4-3 5.8C7 17.9 9.6 20 13 20c.6 0 1-.6.8-1.2C13.3 17 13 16 14 15c2.2-.4 4-2.3 4-4.8V7a3 3 0 0 0-3-3zm.6 3.2a.9.9 0 1 1 0-.1z"/></symbol>
|
| 26 |
+
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></symbol>
|
| 27 |
+
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v10m0 0 4-4m-4 4-4-4M5 20h14"/></symbol>
|
| 28 |
+
<symbol id="i-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M6 11a6 6 0 0 0 12 0M12 17v4"/></symbol>
|
| 29 |
+
<symbol id="i-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01"/></symbol>
|
| 30 |
+
<symbol id="i-whistle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M3 11a5 5 0 0 0 5 5h4l5 3v-8a3 3 0 0 0-3-3H8a5 5 0 0 0-5 3Z"/><path d="M14 4v3"/></symbol>
|
| 31 |
+
<symbol id="i-glam" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M3 14c2-1 4-1 5 0M16 14c2-1 4-1 5 0M3 11h18M7 11l1 5M17 11l-1 5"/><circle cx="6.5" cy="8.5" r="2"/><circle cx="17.5" cy="8.5" r="2"/></symbol>
|
| 32 |
+
<symbol id="i-paw" viewBox="0 0 24 24" fill="currentColor"><circle cx="6" cy="9" r="2"/><circle cx="11" cy="6" r="2"/><circle cx="16" cy="7" r="2"/><circle cx="19" cy="11" r="2"/><path d="M12 12c-3 0-6 2.5-6 5 0 2 2 3 6 3s6-1 6-3c0-2.5-3-5-6-5Z"/></symbol>
|
| 33 |
+
<symbol id="i-glove" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><path d="M7 10V6a2 2 0 0 1 4 0v3M11 9V5a2 2 0 0 1 4 0v5l2 1a3 3 0 0 1 1.4 3.4l-.8 3A2 2 0 0 1 15.7 21H10a3 3 0 0 1-2.8-2L5.5 14A2.5 2.5 0 0 1 7 10Z"/></symbol>
|
| 34 |
+
<symbol id="i-cam" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3z"/></symbol>
|
| 35 |
+
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m4 12 5 5L20 6"/></symbol>
|
| 36 |
+
<symbol id="i-back" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></symbol>
|
| 37 |
+
<symbol id="i-portrait" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="8" r="4.4"/><path d="M3.5 22c0-4.7 3.8-8 8.5-8s8.5 3.3 8.5 8z"/></symbol>
|
| 38 |
+
</defs>
|
| 39 |
+
</svg>
|
| 40 |
+
|
| 41 |
+
<div class="shell">
|
| 42 |
+
|
| 43 |
+
<!-- ============== MASTHEAD ============== -->
|
| 44 |
+
<header class="masthead">
|
| 45 |
+
<div class="brand">
|
| 46 |
+
<div class="sign">
|
| 47 |
+
<div class="bulbs t"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
| 48 |
+
<h1 class="disp">MARQUEE</h1>
|
| 49 |
+
<div class="bulbs b"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
</div>
|
| 51 |
+
<div class="tag">Everyone's a headliner.<small>Turn any clip into a live broadcast.</small></div>
|
| 52 |
+
</div>
|
| 53 |
+
<div class="broadcast-meta">
|
| 54 |
+
<span class="clock" id="clock">--:--:--</span>
|
| 55 |
+
<span class="status" id="status"><span class="led"></span><span id="statusTxt">Standby</span></span>
|
| 56 |
</div>
|
| 57 |
+
</header>
|
| 58 |
|
| 59 |
<!-- ============================================================
|
| 60 |
+
VIEW 1 — SETUP CONSOLE
|
| 61 |
============================================================ -->
|
| 62 |
+
<section class="view active" id="setupView">
|
| 63 |
+
|
| 64 |
+
<!-- rundown / running order -->
|
| 65 |
+
<nav class="rundown" id="rundown" aria-label="Show rundown">
|
| 66 |
+
<div class="cue on" data-step="1"><div class="node">01</div><div class="ttl">Roll Tape</div><div class="sub">Drop a clip</div></div>
|
| 67 |
+
<div class="cue" data-step="2"><div class="node">02</div><div class="ttl">Casting</div><div class="sub">Find the faces</div></div>
|
| 68 |
+
<div class="cue" data-step="3"><div class="node">03</div><div class="ttl">Star Select</div><div class="sub">Name the stars</div></div>
|
| 69 |
+
<div class="cue" data-step="4"><div class="node">04</div><div class="ttl">Showtime</div><div class="sub">Go live</div></div>
|
| 70 |
+
</nav>
|
| 71 |
+
|
| 72 |
+
<div class="console">
|
| 73 |
+
|
| 74 |
+
<!-- ---------- LEFT: the control desk ---------- -->
|
| 75 |
+
<div class="desk">
|
| 76 |
+
|
| 77 |
+
<!-- Roll Tape -->
|
| 78 |
+
<div class="card" id="rollCard">
|
| 79 |
+
<div class="card-h"><svg class="i ic"><use href="#i-clap"/></svg><h2>Roll Tape</h2><span class="meta">SRC 01</span></div>
|
| 80 |
+
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Drop a video clip to roll tape">
|
| 81 |
+
<div class="ask">
|
| 82 |
+
<span class="clap"><svg class="i"><use href="#i-clap"/></svg></span>
|
| 83 |
+
<div class="t">Drop a clip on the desk</div>
|
| 84 |
+
<div class="s">MP4 · MOV · up to 60s · portrait, landscape or square</div>
|
| 85 |
+
</div>
|
| 86 |
+
<div class="loaded">
|
| 87 |
+
<div class="strip"><span class="play"><svg class="i"><use href="#i-play"/></svg></span></div>
|
| 88 |
+
<div>
|
| 89 |
+
<div class="fn" id="fileName">rooftop_freekick.mp4</div>
|
| 90 |
+
<div class="fd"><span id="fileDur">00:42</span> · 1080×1920 · ready to cast</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
</div>
|
| 92 |
</div>
|
| 93 |
+
</div>
|
| 94 |
+
<input type="file" id="fileInput" accept="video/*" hidden>
|
| 95 |
+
<button class="btn cyan mt" id="castBtn" disabled><svg class="i"><use href="#i-spot"/></svg> Find the stars</button>
|
| 96 |
+
<div class="sweep"></div>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<!-- Pick the show / vibes -->
|
| 100 |
+
<div class="card" id="vibeCard">
|
| 101 |
+
<div class="card-h"><svg class="i ic"><use href="#i-mic"/></svg><h2>Pick the show</h2><span class="meta">TONE</span></div>
|
| 102 |
+
<div class="vibes" id="vibes" role="group" aria-label="Narration vibe">
|
| 103 |
+
<button class="vibe" data-vibe="football" style="--vc:var(--star-lime)" aria-pressed="false">
|
| 104 |
+
<span class="ic"><svg class="i"><use href="#i-whistle"/></svg></span>
|
| 105 |
+
<span><span class="vt">Football</span><span class="vd">Roaring play-by-play</span></span>
|
| 106 |
+
</button>
|
| 107 |
+
<button class="vibe" data-vibe="diva" style="--vc:var(--star-hot)" aria-pressed="false">
|
| 108 |
+
<span class="ic"><svg class="i"><use href="#i-glam"/></svg></span>
|
| 109 |
+
<span><span class="vt">Diva Hour</span><span class="vd">Red-carpet shade</span></span>
|
| 110 |
+
</button>
|
| 111 |
+
<button class="vibe" data-vibe="wildlife" style="--vc:var(--star-cyan)" aria-pressed="false">
|
| 112 |
+
<span class="ic"><svg class="i"><use href="#i-paw"/></svg></span>
|
| 113 |
+
<span><span class="vt">Wildlife</span><span class="vd">Hushed nature doc</span></span>
|
| 114 |
+
</button>
|
| 115 |
+
<button class="vibe" data-vibe="boxing" style="--vc:var(--star-tang)" aria-pressed="false">
|
| 116 |
+
<span class="ic"><svg class="i"><use href="#i-glove"/></svg></span>
|
| 117 |
+
<span><span class="vt">Boxing</span><span class="vd">Ringside hype</span></span>
|
| 118 |
</button>
|
|
|
|
| 119 |
</div>
|
| 120 |
|
| 121 |
+
<!-- Coco the host — re-dresses per vibe -->
|
| 122 |
+
<div class="coco" id="coco">
|
| 123 |
+
<div class="av">
|
| 124 |
+
<svg class="i bird"><use href="#i-bird"/></svg>
|
| 125 |
+
<span class="acc" id="cocoAcc"><svg><use href="#i-star"/></svg></span>
|
| 126 |
+
</div>
|
| 127 |
+
<div class="say">
|
| 128 |
+
<span class="who">Coco · your host</span>
|
| 129 |
+
<span id="cocoLine">Pick a show and I'll set the mood. I dress for the occasion.</span>
|
|
|
|
|
|
|
|
|
|
| 130 |
</div>
|
| 131 |
</div>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
<!-- Showtime -->
|
| 135 |
+
<button class="btn show" id="liveBtn" disabled><svg class="i"><use href="#i-star"/></svg> Showtime — go live</button>
|
| 136 |
|
| 137 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
+
<!-- ---------- RIGHT: the stage ---------- -->
|
| 140 |
+
<div class="stage-panel" id="stagePanel">
|
| 141 |
+
<div class="stage-h">
|
| 142 |
+
<svg class="i ic"><use href="#i-spot"/></svg>
|
| 143 |
+
<h2>Star Select</h2>
|
| 144 |
+
<span class="count" id="starCount">NO CAST YET</span>
|
| 145 |
</div>
|
| 146 |
|
| 147 |
+
<!-- empty -->
|
| 148 |
+
<div class="empty" id="stageEmpty">
|
| 149 |
+
<div class="spot"><svg class="i"><use href="#i-spot"/></svg></div>
|
| 150 |
+
<div class="t">The stage is dark</div>
|
| 151 |
+
<div class="s">Roll a clip and hit <b>Find the stars</b> — everyone we spot gets a card under the spotlight.</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
</div>
|
| 153 |
|
| 154 |
+
<!-- roster (filled by JS) -->
|
| 155 |
+
<div class="roster" id="roster" hidden></div>
|
| 156 |
</div>
|
| 157 |
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
<p class="note">
|
| 161 |
+
<svg class="i lock"><use href="#i-lock"/></svg>
|
| 162 |
+
Your clip stays on <b>your machine</b>. Faces are detected locally and never uploaded — Marquee only writes the captions you see.
|
| 163 |
+
</p>
|
| 164 |
+
</section>
|
| 165 |
|
| 166 |
+
<!-- ============================================================
|
| 167 |
+
VIEW 2 — BROADCAST PLAYER
|
| 168 |
+
============================================================ -->
|
| 169 |
+
<section class="view" id="broadcastView">
|
| 170 |
+
<div class="booth-top">
|
| 171 |
+
<button class="back" id="backBtn"><svg class="i"><use href="#i-back"/></svg> Back to console</button>
|
| 172 |
+
<div class="orient" role="group" aria-label="Clip orientation">
|
| 173 |
+
<button data-orient="landscape" aria-pressed="true">16:9</button>
|
| 174 |
+
<button data-orient="square" aria-pressed="false">1:1</button>
|
| 175 |
+
<button data-orient="portrait" aria-pressed="false">9:16</button>
|
| 176 |
</div>
|
| 177 |
+
</div>
|
| 178 |
|
| 179 |
+
<div class="booth" id="booth">
|
| 180 |
+
<!-- the screen -->
|
| 181 |
+
<div>
|
| 182 |
+
<div class="screen-wrap">
|
| 183 |
+
<div class="screen" id="screen">
|
| 184 |
+
<video id="vid" class="screen-vid" playsinline preload="metadata"></video>
|
| 185 |
+
<div class="ph"><svg class="i"><use href="#i-cam"/></svg>YOUR CLIP PLAYS HERE</div>
|
| 186 |
+
<div class="bug"><span class="dot"></span> ON AIR</div>
|
| 187 |
+
<div class="tc" id="tc">00:00</div>
|
| 188 |
+
<!-- lower-third chyron -->
|
| 189 |
+
<div class="chyron" id="chyron">
|
| 190 |
+
<div class="bar"></div>
|
| 191 |
+
<div class="row"><span class="who" id="chyWho">COCO</span><span class="line" id="chyLine">Standing by…</span></div>
|
| 192 |
</div>
|
| 193 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
</div>
|
| 195 |
+
<div class="transport">
|
| 196 |
+
<button class="pp" id="pp" aria-label="Play / pause"><svg class="i"><use href="#i-play"/></svg></button>
|
| 197 |
+
<div class="scrub" id="scrub"><div class="fill" id="fill"></div><div class="knob" id="knob"></div></div>
|
| 198 |
+
<span class="clock" id="tcDur" style="font-size:16px">00:42</span>
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
|
| 202 |
+
<!-- play-by-play rail -->
|
| 203 |
+
<div class="pbp">
|
| 204 |
+
<div class="pbp-h"><svg class="i ic"><use href="#i-list"/></svg><h3>Play-by-play</h3><span class="ct" id="pbpCt">— lines</span></div>
|
| 205 |
+
<div class="lines" id="lines"></div>
|
| 206 |
+
<div class="export">
|
| 207 |
+
<button class="btn-srt" id="srtBtn"><svg class="i ic"><use href="#i-download"/></svg> Download captions (.srt)</button>
|
| 208 |
+
<div class="hint">Drops straight into <b>CapCut</b>, Premiere & DaVinci — timed to your clip.</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
</div>
|
| 210 |
</div>
|
| 211 |
+
</div>
|
| 212 |
+
</section>
|
| 213 |
|
| 214 |
+
</div>
|
| 215 |
|
| 216 |
+
<div class="toast" id="toast"><svg class="i ic"><use href="#i-check"/></svg><span id="toastTxt">Done</span></div>
|
| 217 |
|
| 218 |
+
<script src="marquee.js"></script>
|
| 219 |
</body>
|
| 220 |
+
</html>
|
README.md
CHANGED
|
@@ -10,95 +10,78 @@ license: apache-2.0
|
|
| 10 |
short_description: Turn any clip into a broadcast starring your friends
|
| 11 |
tags:
|
| 12 |
- build-small-hackathon
|
| 13 |
-
- track:backyard
|
| 14 |
-
- achievement:offgrid
|
| 15 |
-
- achievement:offbrand
|
| 16 |
---
|
| 17 |
|
| 18 |
-
# Marquee 🎙️
|
| 19 |
|
| 20 |
-
Drop a clip of friends doing anything. Marquee
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
##
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
##
|
| 30 |
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
- Value: a token from https://huggingface.co/settings/tokens with at least **Read** scope
|
| 45 |
-
|
| 46 |
-
Once the secret is set, restart the Space. The model will download on first use (~7GB). After that it's cached for the session.
|
| 47 |
-
|
| 48 |
-
Chatterbox (the default) needs no token and no extra setup.
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
|
|
|
| 52 |
|
| 53 |
-
|
|
|
|
|
|
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
```bash
|
| 56 |
pip install -r requirements.txt
|
| 57 |
python -c "from ov_models import download_models; download_models()"
|
| 58 |
-
python app.py
|
| 59 |
-
|
| 60 |
-
```
|
| 61 |
-
|
| 62 |
-
Qwen lazy-loads on the first generate request. Orpheus lazy-loads on first TTS request with that model selected. Expect a slow first run.
|
| 63 |
-
|
| 64 |
-
For local Orpheus use you'll need to be logged in: `huggingface-cli login`.
|
| 65 |
-
|
| 66 |
-
## How it's wired
|
| 67 |
-
|
| 68 |
-
The backend is a `gradio.Server` (a FastAPI subclass). This gives us ZeroGPU + the Gradio queue while letting custom routes take priority. The UI is a static HTML page served at `/`. The scan is a plain `fetch()` POST; commentary goes through the Gradio JS client so it hits the queue and ZeroGPU sees the `@spaces.GPU` decorator.
|
| 69 |
-
|
| 70 |
-
```
|
| 71 |
-
Marquee.html + marquee.css + marquee.js → served at "/"
|
| 72 |
-
│
|
| 73 |
-
├─ fetch POST /api/scan upload → normalize → detect+cluster → session + roster
|
| 74 |
-
├─ fetch GET /video/{sid} streams the normalized mp4
|
| 75 |
-
├─ gradio client /generate key events → annotated frames → Qwen (ZeroGPU) → script
|
| 76 |
-
└─ fetch POST /api/tts script lines → Chatterbox or Orpheus (ZeroGPU) → WAV
|
| 77 |
-
└─ fetch POST /api/export WAVs + video → ffmpeg mix → MP4 download
|
| 78 |
-
```
|
| 79 |
-
|
| 80 |
-
Session state (video path, tracks, motion data) lives in a server-side dict keyed by `session_id`. Nothing heavy goes back and forth to the client between steps.
|
| 81 |
-
|
| 82 |
-
## Files
|
| 83 |
-
|
| 84 |
-
| File | What it does |
|
| 85 |
-
|------|-------------|
|
| 86 |
-
| `app.py` | FastAPI routes, session management, UI assembly |
|
| 87 |
-
| `Marquee.html` / `marquee.css` / `marquee.js` | The UI |
|
| 88 |
-
| `commentary.py` | Qwen2.5-VL personas, prompt, JSON parse |
|
| 89 |
-
| `tts.py` | Chatterbox + Orpheus generation, collision fix, WAV encoding |
|
| 90 |
-
| `faces.py` | OpenVINO detect → cluster, frame annotation |
|
| 91 |
-
| `events.py` | Motion-based key event selection |
|
| 92 |
-
| `video.py` | Rotation-aware ffmpeg normalize to 720p |
|
| 93 |
-
| `ov_models.py` | OpenVINO IR download + runtime wrappers |
|
| 94 |
-
|
| 95 |
-
## Models
|
| 96 |
-
|
| 97 |
-
- **Qwen2.5-VL-7B-Instruct** — commentary generation (ZeroGPU)
|
| 98 |
-
- **Chatterbox 0.5B** — default TTS, MIT license, CPU inference
|
| 99 |
-
- **Orpheus 3B** (`canopylabs/orpheus-3b-0.1-ft`) — optional TTS, more expressive, requires HF token + ZeroGPU
|
| 100 |
-
- **face-detection-retail-0004** + **person-detection-retail-0013** — OpenVINO IR, CPU, Intel OMZ
|
| 101 |
-
|
| 102 |
-
## Social Media Post
|
| 103 |
-
- Youtube: https://www.youtube.com/@muflihma
|
| 104 |
-
- Instagram: https://www.instagram.com/0xcure
|
|
|
|
| 10 |
short_description: Turn any clip into a broadcast starring your friends
|
| 11 |
tags:
|
| 12 |
- build-small-hackathon
|
|
|
|
|
|
|
|
|
|
| 13 |
---
|
| 14 |
|
| 15 |
+
# Marquee 🎙️ — *Everyone's a headliner.*
|
| 16 |
|
| 17 |
+
Drop a clip of friends doing anything. Marquee recognizes who's in it, you give
|
| 18 |
+
each person a stage name, pick a show style, and an AI commentator calls the
|
| 19 |
+
action play-by-play, by name, in sync — as a broadcast you can play and export.
|
| 20 |
|
| 21 |
+
## ⚙️ Running it on a Space (read this first)
|
| 22 |
|
| 23 |
+
- **Hardware must be ZeroGPU.** The commentary model runs inside `@spaces.GPU`,
|
| 24 |
+
which only has a GPU on **Nvidia ZeroGPU** hardware. On a plain CPU Space the
|
| 25 |
+
7B VLM has no GPU and is effectively unusable. Set it in **Settings →
|
| 26 |
+
Hardware → ZeroGPU**.
|
| 27 |
+
- **No secrets needed at runtime.** Everything downloads from public sources
|
| 28 |
+
(Qwen from the HF Hub, the face models from Intel's OMZ storage). Do **not**
|
| 29 |
+
commit any `HF_TOKEN`/`.env` — if you need one for `gradio deploy`, keep it
|
| 30 |
+
local and add it as a **Space secret**, never a file in the repo.
|
| 31 |
+
- **Nothing to pre-download.** `download_models()` fetches the three tiny
|
| 32 |
+
OpenVINO IR files at first boot (~5 MB total). First boot also warms up Qwen.
|
| 33 |
|
| 34 |
+
## How it's wired (the important part)
|
| 35 |
|
| 36 |
+
The backend is a **`gradio.Server`** (a FastAPI subclass — Gradio 6). It gives us
|
| 37 |
+
ZeroGPU + the gradio runtime while letting our own routes take priority. The
|
| 38 |
+
custom UI is a **static HTML page** at `/`. The CPU-only scan is a plain
|
| 39 |
+
`fetch()` route; the GPU commentary is an `@app.api()` endpoint called through
|
| 40 |
+
the **Gradio JS client**, which is what wires it into the queue and lets ZeroGPU
|
| 41 |
+
detect the `@spaces.GPU` function. No iframe, no SSR fighting us for `/`.
|
| 42 |
|
| 43 |
+
```
|
| 44 |
+
Static UI (Marquee.html + marquee.css + marquee.js) ──► served at "/"
|
| 45 |
+
│ fetch() for scan · Gradio JS client for generate
|
| 46 |
+
▼
|
| 47 |
+
FastAPI (app.py)
|
| 48 |
+
├─ POST /api/scan (file) -> normalize 720p -> detect+cluster faces
|
| 49 |
+
│ -> session + roster (face crops as data URLs)
|
| 50 |
+
├─ GET /video/{sid} -> streams the normalized mp4
|
| 51 |
+
└─ @app.api /generate (session, names, vibe)-> key events -> name-grounded frames
|
| 52 |
+
-> Qwen2.5-VL (ZeroGPU) -> {t,text} script
|
| 53 |
+
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
+
Heavy per-session state (identities, motion, normalized video path) lives in a
|
| 56 |
+
server-side `SESSIONS` dict keyed by the `session_id` the scan call returns, so
|
| 57 |
+
the two stateless client calls never round-trip embeddings.
|
| 58 |
|
| 59 |
+
Face recognition is used **only** to feed names to the VLM — boxes are never
|
| 60 |
+
shown. The broadcast player reads the real normalized `<video>` and syncs the
|
| 61 |
+
chyron + play-by-play rail to `video.currentTime`.
|
| 62 |
|
| 63 |
+
## Files
|
| 64 |
+
- `app.py` — FastAPI backend + the two API endpoints + UI assembly
|
| 65 |
+
- `Marquee.html` / `marquee.css` / `marquee.js` — the custom UI (design + flow)
|
| 66 |
+
- `video.py` — rotation-aware 720p normalize (one upright source of truth)
|
| 67 |
+
- `faces.py` — OpenVINO detect → align → embed → cluster (CPU)
|
| 68 |
+
- `events.py` — motion-based key events
|
| 69 |
+
- `commentary.py` — Qwen2.5-VL personas (incl. Diva Hour) + strict JSON parse
|
| 70 |
+
- `ov_models.py` — OpenVINO IR auto-download + thin runtime wrappers
|
| 71 |
+
|
| 72 |
+
## Models (all open)
|
| 73 |
+
- face-detection-retail-0004, landmarks-regression-retail-0009,
|
| 74 |
+
face-reidentification-retail-0095 (OpenVINO IR, CPU)
|
| 75 |
+
- Qwen2.5-VL-7B-Instruct (ZeroGPU)
|
| 76 |
+
|
| 77 |
+
## North star (next iteration)
|
| 78 |
+
- TTS per line (hyped delivery) mixed under the clip's own audio.
|
| 79 |
+
- Rendered MP4 export with captions (and voice) burned in.
|
| 80 |
+
- Canvas hype FX on key moments (data's already there).
|
| 81 |
+
|
| 82 |
+
## Local dev
|
| 83 |
```bash
|
| 84 |
pip install -r requirements.txt
|
| 85 |
python -c "from ov_models import download_models; download_models()"
|
| 86 |
+
python app.py # http://localhost:7860 (Qwen lazy-loads on first generate)
|
| 87 |
+
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
CHANGED
|
@@ -1,81 +1,57 @@
|
|
| 1 |
import base64
|
| 2 |
import io
|
| 3 |
-
import logging
|
| 4 |
-
import os
|
| 5 |
-
import shutil
|
| 6 |
-
import subprocess
|
| 7 |
-
import sys
|
| 8 |
import tempfile
|
| 9 |
-
import time
|
| 10 |
import uuid
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
-
logging.basicConfig(
|
| 14 |
-
level=logging.INFO,
|
| 15 |
-
format="%(asctime)s %(levelname)s %(message)s",
|
| 16 |
-
datefmt="%H:%M:%S",
|
| 17 |
-
)
|
| 18 |
-
log = logging.getLogger(__name__)
|
| 19 |
-
|
| 20 |
-
# ---------------------------------------------------------------------------
|
| 21 |
-
# Install chatterbox-tts with --no-deps BEFORE any other import.
|
| 22 |
-
# ---------------------------------------------------------------------------
|
| 23 |
-
try:
|
| 24 |
-
import chatterbox # noqa: F401
|
| 25 |
-
except ImportError:
|
| 26 |
-
log.info("[boot] Installing chatterbox-tts --no-deps …")
|
| 27 |
-
subprocess.run(
|
| 28 |
-
[sys.executable, "-m", "pip", "install", "--no-deps", "--quiet", "chatterbox-tts"],
|
| 29 |
-
check=True,
|
| 30 |
-
)
|
| 31 |
-
log.info("[boot] chatterbox-tts installed.")
|
| 32 |
-
|
| 33 |
from gradio import Server
|
| 34 |
-
from fastapi import Request
|
| 35 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 36 |
|
| 37 |
-
from commentary import STYLES,
|
| 38 |
from events import events_to_frames, pick_key_events
|
| 39 |
-
from faces import annotate_event_frame,
|
| 40 |
from ov_models import download_models
|
| 41 |
-
from tts import generate_script_audio
|
| 42 |
from video import get_duration, normalize_video
|
| 43 |
|
| 44 |
-
MAX_SECONDS = 60.0
|
| 45 |
-
HERE
|
| 46 |
-
SESSIONS
|
| 47 |
|
| 48 |
TIERS = ["HEADLINER", "RISING", "DIVA", "WILDCARD", "BREAKOUT", "CAMEO"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
SUPPORTED_VIDEO = {".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"}
|
| 50 |
|
| 51 |
-
log.info("[boot] Downloading OpenVINO models if needed…")
|
| 52 |
try:
|
| 53 |
download_models()
|
| 54 |
-
log.info("[boot] OpenVINO models ready.")
|
| 55 |
except Exception as e:
|
| 56 |
-
|
| 57 |
-
|
| 58 |
|
| 59 |
def _crop_b64(pil, size=160):
|
| 60 |
-
if pil is None:
|
| 61 |
-
return None
|
| 62 |
img = pil.copy(); img.thumbnail((size, size))
|
| 63 |
buf = io.BytesIO(); img.save(buf, format="PNG")
|
| 64 |
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
| 65 |
|
| 66 |
-
|
| 67 |
def _build_ui() -> str:
|
|
|
|
| 68 |
doc = (HERE / "Marquee.html").read_text()
|
| 69 |
css = (HERE / "marquee.css").read_text()
|
| 70 |
js = (HERE / "marquee.js").read_text()
|
| 71 |
-
doc = doc.replace('<link rel="stylesheet" href="marquee.css">', f"<style>{css}</style>")
|
| 72 |
-
doc = doc.replace('<script src="marquee.js"></script>', f"<script type='module'>{js}</script>")
|
| 73 |
-
return doc
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
app = Server()
|
|
|
|
| 77 |
_UI_HTML = _build_ui()
|
| 78 |
-
log.info("[boot] UI built and ready.")
|
| 79 |
|
| 80 |
|
| 81 |
@app.get("/", response_class=HTMLResponse)
|
|
@@ -85,56 +61,37 @@ async def root():
|
|
| 85 |
|
| 86 |
@app.post("/api/scan")
|
| 87 |
async def api_scan(request: Request):
|
| 88 |
-
|
| 89 |
-
log.info("[scan] Request received")
|
| 90 |
try:
|
| 91 |
-
form
|
| 92 |
upload = form.get("file")
|
| 93 |
if upload is None:
|
| 94 |
return JSONResponse({"ok": False, "error": "no file"}, status_code=400)
|
| 95 |
|
| 96 |
suffix = Path(upload.filename).suffix.lower()
|
| 97 |
-
log.info(f"[scan] File: {upload.filename} ({suffix})")
|
| 98 |
if suffix not in SUPPORTED_VIDEO:
|
| 99 |
return JSONResponse({"ok": False,
|
| 100 |
"error": f"Unsupported format '{suffix}'. Supported: {', '.join(sorted(SUPPORTED_VIDEO))}"})
|
| 101 |
|
| 102 |
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
| 103 |
-
|
| 104 |
-
tmp.write(data); tmp.flush(); tmp.close()
|
| 105 |
-
log.info(f"[scan] Upload saved: {len(data)//1024}KB → {tmp.name}")
|
| 106 |
|
| 107 |
dur = get_duration(tmp.name)
|
| 108 |
-
log.info(f"[scan] Duration: {dur:.1f}s")
|
| 109 |
if dur > MAX_SECONDS + 1:
|
| 110 |
return JSONResponse({"ok": False, "error": "Clip longer than 60 seconds"})
|
| 111 |
|
| 112 |
-
log.info("[scan] Normalising video (ffmpeg)…")
|
| 113 |
-
t1 = time.time()
|
| 114 |
norm_path, w, h = normalize_video(tmp.name)
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
log.info("[scan] Running person tracking scan…")
|
| 118 |
-
tracks, motion, meta, jsonl_path = scan_video(norm_path, MAX_SECONDS)
|
| 119 |
-
log.info(f"[scan] Tracking done — {len(tracks)} person(s) found")
|
| 120 |
|
| 121 |
sid = uuid.uuid4().hex
|
| 122 |
-
SESSIONS[sid] = {
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
log.info(f"[scan] Session created: {sid}")
|
| 130 |
-
|
| 131 |
-
stars = [
|
| 132 |
-
{"idx": trk["id"], "tier": TIERS[i % len(TIERS)],
|
| 133 |
-
"crop": _crop_b64(trk["crop"])}
|
| 134 |
-
for i, trk in enumerate(tracks)
|
| 135 |
-
]
|
| 136 |
-
|
| 137 |
-
log.info(f"[scan] Completed in {time.time()-t0:.1f}s total")
|
| 138 |
return JSONResponse({"ok": True, "session_id": sid,
|
| 139 |
"video_url": f"/video/{sid}",
|
| 140 |
"duration": get_duration(norm_path),
|
|
@@ -142,259 +99,49 @@ async def api_scan(request: Request):
|
|
| 142 |
|
| 143 |
except Exception as e:
|
| 144 |
import traceback; traceback.print_exc()
|
| 145 |
-
log.error(f"[scan] FAILED: {e}")
|
| 146 |
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
| 147 |
|
| 148 |
|
| 149 |
@app.get("/video/{sid}")
|
| 150 |
async def serve_video(sid: str):
|
| 151 |
-
|
| 152 |
sess = SESSIONS.get(sid)
|
| 153 |
if not sess:
|
| 154 |
-
log.warning(f"[video] Session not found: {sid}")
|
| 155 |
return JSONResponse({"error": "session not found"}, status_code=404)
|
| 156 |
return FileResponse(sess["norm"], media_type="video/mp4",
|
| 157 |
headers={"Accept-Ranges": "bytes"})
|
| 158 |
|
| 159 |
|
| 160 |
-
@app.post("/api/tts")
|
| 161 |
-
async def api_tts(request: Request):
|
| 162 |
-
t0 = time.time()
|
| 163 |
-
log.info("[tts] Request received")
|
| 164 |
-
try:
|
| 165 |
-
body = await request.json()
|
| 166 |
-
sid = body.get("session_id", "")
|
| 167 |
-
vibe = body.get("vibe", "football")
|
| 168 |
-
script = body.get("script", [])
|
| 169 |
-
voice_model = body.get("voice_model", "chatterbox")
|
| 170 |
-
|
| 171 |
-
log.info(f"[tts] sid={sid} vibe={vibe} voice={voice_model} lines={len(script)}")
|
| 172 |
-
|
| 173 |
-
if not script:
|
| 174 |
-
return JSONResponse({"ok": False, "error": "empty script"}, status_code=400)
|
| 175 |
-
if sid and sid not in SESSIONS:
|
| 176 |
-
return JSONResponse({"ok": False, "error": "session not found"}, status_code=404)
|
| 177 |
-
|
| 178 |
-
log.info(f"[tts] Generating {len(script)} lines with {voice_model}…")
|
| 179 |
-
lines = generate_script_audio(script, vibe, voice_model=voice_model)
|
| 180 |
-
audio_count = sum(1 for l in lines if l.get("audio_b64"))
|
| 181 |
-
log.info(f"[tts] Done in {time.time()-t0:.1f}s — "
|
| 182 |
-
f"{audio_count}/{len(lines)} lines have audio")
|
| 183 |
-
return JSONResponse({"ok": True, "lines": lines})
|
| 184 |
-
|
| 185 |
-
except Exception as e:
|
| 186 |
-
import traceback; traceback.print_exc()
|
| 187 |
-
log.error(f"[tts] FAILED: {e}")
|
| 188 |
-
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
@app.post("/api/export")
|
| 192 |
-
async def api_export(request: Request, background_tasks: BackgroundTasks):
|
| 193 |
-
"""Burn video + TTS audio + soft subtitle captions into one MP4.
|
| 194 |
-
|
| 195 |
-
FIX: tmpdir is cleaned up via BackgroundTasks AFTER the response is
|
| 196 |
-
fully streamed — prevents the race condition where FileResponse tries
|
| 197 |
-
to read a file that Python GC already deleted.
|
| 198 |
-
"""
|
| 199 |
-
import wave as _wave
|
| 200 |
-
|
| 201 |
-
t0 = time.time()
|
| 202 |
-
log.info("[export] Request received")
|
| 203 |
-
tmpdir = None
|
| 204 |
-
|
| 205 |
-
try:
|
| 206 |
-
body = await request.json()
|
| 207 |
-
sid = body.get("session_id", "")
|
| 208 |
-
vibe = body.get("vibe", "")
|
| 209 |
-
lines = body.get("lines", [])
|
| 210 |
-
|
| 211 |
-
log.info(f"[export] sid={sid} vibe={vibe} lines={len(lines)}")
|
| 212 |
-
|
| 213 |
-
sess = SESSIONS.get(sid)
|
| 214 |
-
if not sess:
|
| 215 |
-
return JSONResponse({"ok": False, "error": "session not found"}, status_code=404)
|
| 216 |
-
if not lines:
|
| 217 |
-
return JSONResponse({"ok": False, "error": "no lines"}, status_code=400)
|
| 218 |
-
|
| 219 |
-
video_path = sess["norm"]
|
| 220 |
-
tmpdir = tempfile.mkdtemp(prefix="mq_export_")
|
| 221 |
-
log.info(f"[export] Working dir: {tmpdir}")
|
| 222 |
-
|
| 223 |
-
# 1. Write TTS WAV files
|
| 224 |
-
log.info("[export] Writing TTS WAV files…")
|
| 225 |
-
audio_clips = []
|
| 226 |
-
for i, line in enumerate(lines):
|
| 227 |
-
b64 = line.get("audio_b64")
|
| 228 |
-
if not b64:
|
| 229 |
-
continue
|
| 230 |
-
wav_bytes = base64.b64decode(b64)
|
| 231 |
-
wav_path = os.path.join(tmpdir, f"tts_{i:03d}.wav")
|
| 232 |
-
with open(wav_path, "wb") as f:
|
| 233 |
-
f.write(wav_bytes)
|
| 234 |
-
with _wave.open(io.BytesIO(wav_bytes)) as wf:
|
| 235 |
-
dur = wf.getnframes() / wf.getframerate()
|
| 236 |
-
audio_clips.append({"path": wav_path, "t": float(line["t"]), "duration": dur})
|
| 237 |
-
log.info(f"[export] {len(audio_clips)} audio clips written")
|
| 238 |
-
|
| 239 |
-
# 2. Build SRT subtitle file
|
| 240 |
-
log.info("[export] Building subtitle file…")
|
| 241 |
-
total_dur = sess["meta"]["n_frames"] / sess["meta"]["fps"]
|
| 242 |
-
vibe_label = {
|
| 243 |
-
"football": "COCO", "diva": "COCO", "wildlife": "COCO",
|
| 244 |
-
"boxing": "COCO", "masterchef": "COCO",
|
| 245 |
-
}.get(vibe, "COCO")
|
| 246 |
-
|
| 247 |
-
def _srt_time(s):
|
| 248 |
-
h=int(s//3600); m=int((s%3600)//60); sec=s%60
|
| 249 |
-
ms=int((sec%1)*1000); sec=int(sec)
|
| 250 |
-
return f"{h:02d}:{m:02d}:{sec:02d},{ms:03d}"
|
| 251 |
-
|
| 252 |
-
srt_rows = []
|
| 253 |
-
for i, line in enumerate(lines):
|
| 254 |
-
t_start = float(line["t"])
|
| 255 |
-
t_end = float(lines[i+1]["t"]) if i+1<len(lines) else min(t_start+4, total_dur)
|
| 256 |
-
srt_rows += [
|
| 257 |
-
str(i+1),
|
| 258 |
-
f"{_srt_time(t_start)} --> {_srt_time(t_end)}",
|
| 259 |
-
f"{vibe_label} {line['text']}",
|
| 260 |
-
"",
|
| 261 |
-
]
|
| 262 |
-
srt_path = os.path.join(tmpdir, "subs.srt")
|
| 263 |
-
with open(srt_path, "w", encoding="utf-8") as f:
|
| 264 |
-
f.write("\n".join(srt_rows))
|
| 265 |
-
log.info(f"[export] SRT written: {len(lines)} cues")
|
| 266 |
-
|
| 267 |
-
# 3. Build ffmpeg command
|
| 268 |
-
out_path = os.path.join(tmpdir, "broadcast.mp4")
|
| 269 |
-
n = len(audio_clips)
|
| 270 |
-
|
| 271 |
-
cmd = ["ffmpeg", "-y", "-i", video_path, "-i", srt_path]
|
| 272 |
-
for clip in audio_clips:
|
| 273 |
-
cmd += ["-i", clip["path"]]
|
| 274 |
-
|
| 275 |
-
if n > 0:
|
| 276 |
-
parts, delayed = [], []
|
| 277 |
-
for idx, clip in enumerate(audio_clips):
|
| 278 |
-
delay_ms = int(clip["t"] * 1000)
|
| 279 |
-
lbl = f"d{idx}"
|
| 280 |
-
cmd_idx = idx + 2 # 0=video, 1=srt, 2..=wavs
|
| 281 |
-
parts.append(f"[{cmd_idx}:a]adelay={delay_ms}|{delay_ms},apad[{lbl}]")
|
| 282 |
-
delayed.append(f"[{lbl}]")
|
| 283 |
-
parts.append(f"{''.join(delayed)}amix=inputs={n}:normalize=0[commentary]")
|
| 284 |
-
parts.append("[0:a]volume=0.25[orig];[orig][commentary]amix=inputs=2:normalize=0[mixed]")
|
| 285 |
-
cmd += [
|
| 286 |
-
"-filter_complex", ";".join(parts),
|
| 287 |
-
"-map", "0:v",
|
| 288 |
-
"-map", "[mixed]",
|
| 289 |
-
"-map", "1:s",
|
| 290 |
-
"-c:v", "copy", # stream-copy video — no re-encode
|
| 291 |
-
"-c:a", "aac", "-b:a", "128k",
|
| 292 |
-
"-c:s", "mov_text",
|
| 293 |
-
"-movflags", "+faststart",
|
| 294 |
-
out_path,
|
| 295 |
-
]
|
| 296 |
-
else:
|
| 297 |
-
cmd += [
|
| 298 |
-
"-map", "0:v", "-map", "0:a", "-map", "1:s",
|
| 299 |
-
"-c:v", "copy", "-c:a", "copy",
|
| 300 |
-
"-c:s", "mov_text",
|
| 301 |
-
"-movflags", "+faststart",
|
| 302 |
-
out_path,
|
| 303 |
-
]
|
| 304 |
-
|
| 305 |
-
log.info(f"[export] Running ffmpeg (n_audio={n})…")
|
| 306 |
-
log.info(f"[export] CMD: {' '.join(cmd)}")
|
| 307 |
-
t1 = time.time()
|
| 308 |
-
res = subprocess.run(cmd, capture_output=True)
|
| 309 |
-
log.info(f"[export] ffmpeg finished in {time.time()-t1:.1f}s, "
|
| 310 |
-
f"returncode={res.returncode}")
|
| 311 |
-
|
| 312 |
-
if res.returncode != 0:
|
| 313 |
-
err = res.stderr.decode(errors="replace")[-1200:]
|
| 314 |
-
log.error(f"[export] ffmpeg STDERR:\n{err}")
|
| 315 |
-
return JSONResponse({"ok": False, "error": f"ffmpeg: {err}"}, status_code=500)
|
| 316 |
-
|
| 317 |
-
out_size_mb = os.path.getsize(out_path) / 1024 / 1024
|
| 318 |
-
log.info(f"[export] Output: {out_path} ({out_size_mb:.1f}MB)")
|
| 319 |
-
log.info(f"[export] Total time: {time.time()-t0:.1f}s")
|
| 320 |
-
|
| 321 |
-
vibe_slug = vibe or "broadcast"
|
| 322 |
-
fname = f"marquee_{vibe_slug}.mp4"
|
| 323 |
-
|
| 324 |
-
# KEY FIX: schedule tmpdir cleanup as a background task so it runs
|
| 325 |
-
# AFTER the file has been fully streamed to the client.
|
| 326 |
-
# Previously tmpdir could be cleaned up mid-stream causing download to hang.
|
| 327 |
-
def _cleanup(d: str):
|
| 328 |
-
try:
|
| 329 |
-
shutil.rmtree(d, ignore_errors=True)
|
| 330 |
-
log.info(f"[export] Cleaned up tmpdir: {d}")
|
| 331 |
-
except Exception as ex:
|
| 332 |
-
log.warning(f"[export] Cleanup failed: {ex}")
|
| 333 |
-
|
| 334 |
-
background_tasks.add_task(_cleanup, tmpdir)
|
| 335 |
-
|
| 336 |
-
return FileResponse(
|
| 337 |
-
out_path,
|
| 338 |
-
media_type="video/mp4",
|
| 339 |
-
filename=fname,
|
| 340 |
-
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
| 341 |
-
)
|
| 342 |
-
|
| 343 |
-
except Exception as e:
|
| 344 |
-
import traceback; traceback.print_exc()
|
| 345 |
-
log.error(f"[export] FAILED: {e}")
|
| 346 |
-
if tmpdir:
|
| 347 |
-
shutil.rmtree(tmpdir, ignore_errors=True)
|
| 348 |
-
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
| 349 |
-
|
| 350 |
-
|
| 351 |
@app.api(name="generate")
|
| 352 |
def generate(session_id: str, names: list, vibe: str) -> dict:
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
|
|
|
| 356 |
sess = SESSIONS.get(session_id)
|
| 357 |
if not sess:
|
| 358 |
-
|
| 359 |
-
return {"ok": False, "error": "Session expired — re-cast"}
|
| 360 |
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
for i, trk in enumerate(tracks):
|
| 364 |
-
raw = str(names[i]).strip() if i < len(names) else ""
|
| 365 |
-
if raw:
|
| 366 |
-
id_to_name[trk["id"]] = raw
|
| 367 |
|
| 368 |
-
log.info(f"[generate] Named tracks: {id_to_name}")
|
| 369 |
-
if id_to_name:
|
| 370 |
-
write_names_to_jsonl(sess["jsonl_path"], id_to_name)
|
| 371 |
-
|
| 372 |
-
style = VIBE_TO_STYLE.get(vibe, next(iter(STYLES)))
|
| 373 |
fps = sess["meta"]["fps"]
|
| 374 |
event_times = pick_key_events(sess["motion"], fps)
|
| 375 |
event_idxs = events_to_frames(event_times, fps)
|
| 376 |
-
log.info(f"[generate] Key events: {event_times}")
|
| 377 |
|
| 378 |
-
log.info("[generate] Annotating keyframes…")
|
| 379 |
frames = []
|
| 380 |
for ts, fidx in zip(event_times, event_idxs):
|
| 381 |
bgr = frame_at(sess["norm"], fidx)
|
| 382 |
if bgr is None:
|
| 383 |
-
log.warning(f"[generate] frame_at({fidx}) returned None, skipping")
|
| 384 |
continue
|
| 385 |
-
frames.append((ts, annotate_event_frame(bgr, sess["
|
| 386 |
-
log.info(f"[generate] {len(frames)} annotated frames ready for VLM")
|
| 387 |
-
|
| 388 |
-
log.info(f"[generate] Calling Qwen VLM (style={style})…")
|
| 389 |
-
t1 = time.time()
|
| 390 |
-
roster = list(id_to_name.values())
|
| 391 |
-
script = generate_commentary(frames, roster, style, vibe=vibe)
|
| 392 |
-
log.info(f"[generate] VLM done in {time.time()-t1:.1f}s — {len(script)} lines")
|
| 393 |
-
log.info(f"[generate] Total: {time.time()-t0:.1f}s")
|
| 394 |
|
|
|
|
| 395 |
return {"ok": True,
|
| 396 |
-
"script": [{"t": round(float(l["time"]),2), "text": l["text"]}
|
| 397 |
for l in script]}
|
| 398 |
|
| 399 |
|
|
|
|
|
|
|
| 400 |
app.launch(ssr_mode=False, show_error=True)
|
|
|
|
| 1 |
import base64
|
| 2 |
import io
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import tempfile
|
|
|
|
| 4 |
import uuid
|
| 5 |
from pathlib import Path
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from gradio import Server
|
| 8 |
+
from fastapi import Request
|
| 9 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 10 |
|
| 11 |
+
from commentary import STYLES, generate_commentary # eager-loads Qwen on import
|
| 12 |
from events import events_to_frames, pick_key_events
|
| 13 |
+
from faces import annotate_event_frame, cluster_faces, frame_at, scan_video
|
| 14 |
from ov_models import download_models
|
|
|
|
| 15 |
from video import get_duration, normalize_video
|
| 16 |
|
| 17 |
+
MAX_SECONDS = 60.0 # keep the demo snappy; matches the UI copy
|
| 18 |
+
HERE = Path(__file__).parent
|
| 19 |
+
SESSIONS = {}
|
| 20 |
|
| 21 |
TIERS = ["HEADLINER", "RISING", "DIVA", "WILDCARD", "BREAKOUT", "CAMEO"]
|
| 22 |
+
VIBE_TO_STYLE = {
|
| 23 |
+
"football": "Football (Premier League hype)",
|
| 24 |
+
"diva": "Diva Hour",
|
| 25 |
+
"wildlife": "Nature documentary",
|
| 26 |
+
"boxing": "Boxing announcer",
|
| 27 |
+
}
|
| 28 |
SUPPORTED_VIDEO = {".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"}
|
| 29 |
|
|
|
|
| 30 |
try:
|
| 31 |
download_models()
|
|
|
|
| 32 |
except Exception as e:
|
| 33 |
+
print(f"[warn] OV model download failed at boot: {e}")
|
|
|
|
| 34 |
|
| 35 |
def _crop_b64(pil, size=160):
|
|
|
|
|
|
|
| 36 |
img = pil.copy(); img.thumbnail((size, size))
|
| 37 |
buf = io.BytesIO(); img.save(buf, format="PNG")
|
| 38 |
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
| 39 |
|
|
|
|
| 40 |
def _build_ui() -> str:
|
| 41 |
+
"""Inline CSS + JS into Marquee.html so the page is fully self-contained."""
|
| 42 |
doc = (HERE / "Marquee.html").read_text()
|
| 43 |
css = (HERE / "marquee.css").read_text()
|
| 44 |
js = (HERE / "marquee.js").read_text()
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
+
doc = doc.replace('<link rel="stylesheet" href="marquee.css">',
|
| 47 |
+
f"<style>{css}</style>")
|
| 48 |
+
doc = doc.replace('<script src="marquee.js"></script>',
|
| 49 |
+
f"<script type='module'>{js}</script>")
|
| 50 |
+
return doc
|
| 51 |
|
| 52 |
app = Server()
|
| 53 |
+
|
| 54 |
_UI_HTML = _build_ui()
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
@app.get("/", response_class=HTMLResponse)
|
|
|
|
| 61 |
|
| 62 |
@app.post("/api/scan")
|
| 63 |
async def api_scan(request: Request):
|
| 64 |
+
"""CPU-only (OpenVINO + ffmpeg). Plain route + multipart upload via fetch."""
|
|
|
|
| 65 |
try:
|
| 66 |
+
form = await request.form()
|
| 67 |
upload = form.get("file")
|
| 68 |
if upload is None:
|
| 69 |
return JSONResponse({"ok": False, "error": "no file"}, status_code=400)
|
| 70 |
|
| 71 |
suffix = Path(upload.filename).suffix.lower()
|
|
|
|
| 72 |
if suffix not in SUPPORTED_VIDEO:
|
| 73 |
return JSONResponse({"ok": False,
|
| 74 |
"error": f"Unsupported format '{suffix}'. Supported: {', '.join(sorted(SUPPORTED_VIDEO))}"})
|
| 75 |
|
| 76 |
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
| 77 |
+
tmp.write(await upload.read()); tmp.flush(); tmp.close()
|
|
|
|
|
|
|
| 78 |
|
| 79 |
dur = get_duration(tmp.name)
|
|
|
|
| 80 |
if dur > MAX_SECONDS + 1:
|
| 81 |
return JSONResponse({"ok": False, "error": "Clip longer than 60 seconds"})
|
| 82 |
|
|
|
|
|
|
|
| 83 |
norm_path, w, h = normalize_video(tmp.name)
|
| 84 |
+
embeds, crops, motion, meta = scan_video(norm_path, MAX_SECONDS)
|
| 85 |
+
identities = cluster_faces(embeds, crops)
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
sid = uuid.uuid4().hex
|
| 88 |
+
SESSIONS[sid] = {"norm": norm_path, "identities": identities,
|
| 89 |
+
"motion": motion, "meta": meta}
|
| 90 |
+
|
| 91 |
+
stars = [{"idx": i, "tier": TIERS[i % len(TIERS)],
|
| 92 |
+
"crop": _crop_b64(idn["crop"])}
|
| 93 |
+
for i, idn in enumerate(identities)]
|
| 94 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
return JSONResponse({"ok": True, "session_id": sid,
|
| 96 |
"video_url": f"/video/{sid}",
|
| 97 |
"duration": get_duration(norm_path),
|
|
|
|
| 99 |
|
| 100 |
except Exception as e:
|
| 101 |
import traceback; traceback.print_exc()
|
|
|
|
| 102 |
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
| 103 |
|
| 104 |
|
| 105 |
@app.get("/video/{sid}")
|
| 106 |
async def serve_video(sid: str):
|
| 107 |
+
"""Stream the normalized video file."""
|
| 108 |
sess = SESSIONS.get(sid)
|
| 109 |
if not sess:
|
|
|
|
| 110 |
return JSONResponse({"error": "session not found"}, status_code=404)
|
| 111 |
return FileResponse(sess["norm"], media_type="video/mp4",
|
| 112 |
headers={"Accept-Ranges": "bytes"})
|
| 113 |
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
@app.api(name="generate")
|
| 116 |
def generate(session_id: str, names: list, vibe: str) -> dict:
|
| 117 |
+
"""GPU path. Exposed via @app.api() so ZeroGPU detects the @spaces.GPU
|
| 118 |
+
function and gradio queues the calls. Called from the frontend through the
|
| 119 |
+
Gradio JS client.
|
| 120 |
+
"""
|
| 121 |
sess = SESSIONS.get(session_id)
|
| 122 |
if not sess:
|
| 123 |
+
return {"ok": False, "error": "Session expired -- re-cast"}
|
|
|
|
| 124 |
|
| 125 |
+
name_map = {i: str(n).strip() for i, n in enumerate(names) if str(n).strip()}
|
| 126 |
+
style = VIBE_TO_STYLE.get(vibe, next(iter(STYLES)))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
fps = sess["meta"]["fps"]
|
| 129 |
event_times = pick_key_events(sess["motion"], fps)
|
| 130 |
event_idxs = events_to_frames(event_times, fps)
|
|
|
|
| 131 |
|
|
|
|
| 132 |
frames = []
|
| 133 |
for ts, fidx in zip(event_times, event_idxs):
|
| 134 |
bgr = frame_at(sess["norm"], fidx)
|
| 135 |
if bgr is None:
|
|
|
|
| 136 |
continue
|
| 137 |
+
frames.append((ts, annotate_event_frame(bgr, sess["identities"], name_map)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
+
script = generate_commentary(frames, list(name_map.values()), style)
|
| 140 |
return {"ok": True,
|
| 141 |
+
"script": [{"t": round(float(l["time"]), 2), "text": l["text"]}
|
| 142 |
for l in script]}
|
| 143 |
|
| 144 |
|
| 145 |
+
# Custom "/" already takes priority over Gradio's default page; no gradio UI
|
| 146 |
+
# components, so SSR is pointless -> off. On Spaces, launch() binds 0.0.0.0:7860.
|
| 147 |
app.launch(ssr_mode=False, show_error=True)
|
commentary.py
CHANGED
|
@@ -6,33 +6,23 @@ import torch
|
|
| 6 |
from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration
|
| 7 |
|
| 8 |
MODEL_ID = "Qwen/Qwen2.5-VL-7B-Instruct"
|
| 9 |
-
DEVICE
|
| 10 |
|
| 11 |
STYLES = {
|
| 12 |
"Football (Premier League hype)":
|
| 13 |
"an over-the-top English football commentator, dramatic and poetic",
|
| 14 |
"MasterChef judge":
|
| 15 |
-
"a brutally dramatic cooking-show
|
| 16 |
-
"high-stakes elimination
|
| 17 |
"Nature documentary":
|
| 18 |
"a hushed, awestruck nature narrator observing these humans like rare "
|
| 19 |
-
"wildlife
|
| 20 |
"Boxing announcer":
|
| 21 |
"a booming boxing announcer treating every move as championship-defining",
|
| 22 |
"Diva Hour":
|
| 23 |
"a fabulous, shady red-carpet diva commentator narrating like everyone "
|
| 24 |
"is a celebrity arriving at a star-studded gala, full of glamour and sass",
|
| 25 |
}
|
| 26 |
-
|
| 27 |
-
# Vibe → frontend key mapping (used by app.py)
|
| 28 |
-
VIBE_TO_STYLE = {
|
| 29 |
-
"football": "Football (Premier League hype)",
|
| 30 |
-
"masterchef":"MasterChef judge",
|
| 31 |
-
"wildlife": "Nature documentary",
|
| 32 |
-
"boxing": "Boxing announcer",
|
| 33 |
-
"diva": "Diva Hour",
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
SYSTEM_PROMPT = """You are {persona}, calling live play-by-play over a short clip.
|
| 37 |
|
| 38 |
You'll receive {n_frames} keyframes in time order, each tagged "t=<seconds>".
|
|
@@ -40,41 +30,27 @@ Some people have their NAME burned in a box above their head — use those exact
|
|
| 40 |
names. Refer to anyone unnamed by what you see ("the one in the red shirt").
|
| 41 |
People we already know in this clip: {roster}.
|
| 42 |
|
| 43 |
-
NARRATIVE ARC — mandatory:
|
| 44 |
-
Frame 1-2 : establish the scene and mood (who, where, what's at stake).
|
| 45 |
-
Middle : build tension — notice details, call the action, raise stakes.
|
| 46 |
-
Final frame: pay off the arc with a punchy closing line.
|
| 47 |
-
Each line must feel like it follows the one before. No generic filler.
|
| 48 |
-
|
| 49 |
Write EXACTLY one line per keyframe — {n_frames} lines, in the same order.
|
| 50 |
|
| 51 |
-
|
| 52 |
-
- React to what is ACTUALLY in each frame
|
| 53 |
-
|
| 54 |
-
-
|
|
|
|
|
|
|
|
|
|
| 55 |
- Stay fully in character as {persona} from first line to last.
|
| 56 |
-
- CAPS and exclamation marks
|
| 57 |
-
|
| 58 |
|
| 59 |
Output rules (strict):
|
| 60 |
- Output ONLY a JSON array. No markdown, no text outside it.
|
| 61 |
- Schema: [{{"time": <float seconds>, "text": "<one line>"}}]
|
| 62 |
-
- Use the EXACT "t=" value of each keyframe as its "time".
|
| 63 |
-
|
| 64 |
-
# Max words per line — tuned per vibe so audio fits in the gap
|
| 65 |
-
MAX_WORDS = {
|
| 66 |
-
"football": 14,
|
| 67 |
-
"masterchef": 13,
|
| 68 |
-
"wildlife": 15,
|
| 69 |
-
"boxing": 12,
|
| 70 |
-
"diva": 14,
|
| 71 |
-
}
|
| 72 |
-
_DEFAULT_MAX_WORDS = 14
|
| 73 |
-
|
| 74 |
-
# Minimum gap (seconds) between events — events closer than this get merged
|
| 75 |
-
MIN_GAP_SEC = 2.5
|
| 76 |
|
| 77 |
|
|
|
|
| 78 |
print(f"[info] Loading Qwen2.5-VL-7B (first boot: download ~16GB + load) -> {DEVICE}")
|
| 79 |
_model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
|
| 80 |
MODEL_ID, torch_dtype=torch.bfloat16)
|
|
@@ -83,94 +59,49 @@ _processor = AutoProcessor.from_pretrained(MODEL_ID)
|
|
| 83 |
print("[info] Qwen ready.")
|
| 84 |
|
| 85 |
|
| 86 |
-
def _merge_close_events(event_frames, min_gap=MIN_GAP_SEC):
|
| 87 |
-
"""Merge keyframes that are too close together.
|
| 88 |
-
|
| 89 |
-
When two events are < min_gap seconds apart the commentary lines would
|
| 90 |
-
collide in audio. We keep the one with higher motion score (second of
|
| 91 |
-
the pair, which usually shows the peak) and drop the earlier one.
|
| 92 |
-
Returns a filtered list of (timestamp, image) tuples.
|
| 93 |
-
"""
|
| 94 |
-
if len(event_frames) <= 1:
|
| 95 |
-
return event_frames
|
| 96 |
-
merged = [event_frames[0]]
|
| 97 |
-
for ts, img in event_frames[1:]:
|
| 98 |
-
prev_ts, _ = merged[-1]
|
| 99 |
-
if ts - prev_ts < min_gap:
|
| 100 |
-
# keep the later frame (usually the action peak)
|
| 101 |
-
merged[-1] = (ts, img)
|
| 102 |
-
else:
|
| 103 |
-
merged.append((ts, img))
|
| 104 |
-
return merged
|
| 105 |
-
|
| 106 |
-
|
| 107 |
@spaces.GPU(duration=120)
|
| 108 |
-
def generate_commentary(event_frames, roster, style_key
|
| 109 |
-
"""event_frames: [(timestamp_sec, PIL.Image), ...] (annotated key events)
|
| 110 |
-
roster:
|
| 111 |
-
|
| 112 |
-
vibe: optional vibe slug for per-vibe word limit
|
| 113 |
-
|
| 114 |
-
Returns [{"time": float, "text": str}, ...] sorted by time.
|
| 115 |
"""
|
| 116 |
from qwen_vl_utils import process_vision_info
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
n = len(event_frames)
|
| 122 |
-
max_words = MAX_WORDS.get(vibe or "", _DEFAULT_MAX_WORDS)
|
| 123 |
-
content = []
|
| 124 |
-
|
| 125 |
for i, (ts, img) in enumerate(event_frames):
|
| 126 |
-
content.append({"type": "text",
|
| 127 |
-
"text": f"Keyframe {i+1}/{n} — t={ts:.2f}s:"})
|
| 128 |
content.append({"type": "image", "image": img})
|
| 129 |
content.append({"type": "text",
|
| 130 |
"text": f"Now write the {n}-line commentary JSON."})
|
| 131 |
|
| 132 |
-
persona
|
| 133 |
roster_str = ", ".join(roster) if roster else "nobody named yet"
|
| 134 |
-
|
| 135 |
messages = [
|
| 136 |
{"role": "system",
|
| 137 |
-
"content": SYSTEM_PROMPT.format(
|
| 138 |
-
|
| 139 |
-
n_frames=n, max_words=max_words)},
|
| 140 |
{"role": "user", "content": content},
|
| 141 |
]
|
| 142 |
|
| 143 |
-
text
|
| 144 |
-
|
| 145 |
image_inputs, _ = process_vision_info(messages)
|
| 146 |
-
inputs
|
| 147 |
-
|
| 148 |
|
| 149 |
-
out = _model.generate(**inputs, max_new_tokens=
|
| 150 |
temperature=0.8, top_p=0.95)
|
| 151 |
new = out[:, inputs.input_ids.shape[1]:]
|
| 152 |
raw = _processor.batch_decode(new, skip_special_tokens=True)[0]
|
| 153 |
|
| 154 |
valid_times = [round(ts, 2) for ts, _ in event_frames]
|
| 155 |
-
return _parse(raw, valid_times
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
def _trim_to_words(text: str, max_words: int) -> str:
|
| 159 |
-
words = text.split()
|
| 160 |
-
if len(words) <= max_words:
|
| 161 |
-
return text
|
| 162 |
-
# trim at sentence boundary if possible
|
| 163 |
-
trimmed = " ".join(words[:max_words])
|
| 164 |
-
for punct in (".", "!", "?", "—", ","):
|
| 165 |
-
idx = trimmed.rfind(punct)
|
| 166 |
-
if idx > len(trimmed) // 2:
|
| 167 |
-
return trimmed[:idx + 1]
|
| 168 |
-
return trimmed + "…"
|
| 169 |
|
| 170 |
|
| 171 |
-
def _parse(raw: str, valid_times: list[float]
|
| 172 |
raw = re.sub(r"```(?:json)?", "", raw).strip()
|
| 173 |
-
m
|
| 174 |
try:
|
| 175 |
items = json.loads(m.group(0) if m else raw)
|
| 176 |
except (json.JSONDecodeError, AttributeError):
|
|
@@ -180,11 +111,10 @@ def _parse(raw: str, valid_times: list[float], max_words: int = 14) -> list[dict
|
|
| 180 |
for i, it in enumerate(items):
|
| 181 |
try:
|
| 182 |
txt = str(it["text"]).strip()
|
| 183 |
-
t
|
| 184 |
except (KeyError, TypeError, ValueError):
|
| 185 |
continue
|
| 186 |
-
t
|
| 187 |
-
txt = _trim_to_words(txt, max_words)
|
| 188 |
if txt:
|
| 189 |
script.append({"time": t, "text": txt})
|
| 190 |
|
|
|
|
| 6 |
from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration
|
| 7 |
|
| 8 |
MODEL_ID = "Qwen/Qwen2.5-VL-7B-Instruct"
|
| 9 |
+
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 10 |
|
| 11 |
STYLES = {
|
| 12 |
"Football (Premier League hype)":
|
| 13 |
"an over-the-top English football commentator, dramatic and poetic",
|
| 14 |
"MasterChef judge":
|
| 15 |
+
"a brutally dramatic cooking-show commentator treating everything like "
|
| 16 |
+
"a high-stakes elimination",
|
| 17 |
"Nature documentary":
|
| 18 |
"a hushed, awestruck nature narrator observing these humans like rare "
|
| 19 |
+
"wildlife",
|
| 20 |
"Boxing announcer":
|
| 21 |
"a booming boxing announcer treating every move as championship-defining",
|
| 22 |
"Diva Hour":
|
| 23 |
"a fabulous, shady red-carpet diva commentator narrating like everyone "
|
| 24 |
"is a celebrity arriving at a star-studded gala, full of glamour and sass",
|
| 25 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
SYSTEM_PROMPT = """You are {persona}, calling live play-by-play over a short clip.
|
| 27 |
|
| 28 |
You'll receive {n_frames} keyframes in time order, each tagged "t=<seconds>".
|
|
|
|
| 30 |
names. Refer to anyone unnamed by what you see ("the one in the red shirt").
|
| 31 |
People we already know in this clip: {roster}.
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
Write EXACTLY one line per keyframe — {n_frames} lines, in the same order.
|
| 34 |
|
| 35 |
+
Make it land:
|
| 36 |
+
- React to what is ACTUALLY in each frame (a pose, a movement, who's present) —
|
| 37 |
+
never generic filler that could fit any clip.
|
| 38 |
+
- Build an arc across the lines: set the scene, let the tension rise, pay off
|
| 39 |
+
the final beat. Each line should feel like it follows the last.
|
| 40 |
+
- Vary your openings and rhythm. Never reuse a phrase or a sentence shape twice.
|
| 41 |
+
- Drop names naturally, only when it hits — not in every single line.
|
| 42 |
- Stay fully in character as {persona} from first line to last.
|
| 43 |
+
- Save CAPS and exclamation marks for the BIG beats only; overusing them kills
|
| 44 |
+
the punch (the voice reads punctuation as emotion).
|
| 45 |
|
| 46 |
Output rules (strict):
|
| 47 |
- Output ONLY a JSON array. No markdown, no text outside it.
|
| 48 |
- Schema: [{{"time": <float seconds>, "text": "<one line>"}}]
|
| 49 |
+
- Use the EXACT "t=" value of each keyframe as its "time".
|
| 50 |
+
- Keep every line under 16 words — it must be spoken before the next beat."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
+
# -- eager module-level load (ZeroGPU pattern) --------------------------------
|
| 54 |
print(f"[info] Loading Qwen2.5-VL-7B (first boot: download ~16GB + load) -> {DEVICE}")
|
| 55 |
_model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
|
| 56 |
MODEL_ID, torch_dtype=torch.bfloat16)
|
|
|
|
| 59 |
print("[info] Qwen ready.")
|
| 60 |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
@spaces.GPU(duration=120)
|
| 63 |
+
def generate_commentary(event_frames, roster, style_key):
|
| 64 |
+
"""event_frames: [(timestamp_sec, PIL.Image), ...] (annotated, key events)
|
| 65 |
+
roster: list of names the user assigned
|
| 66 |
+
-> [{"time": float, "text": str}, ...] aligned to the event times.
|
|
|
|
|
|
|
|
|
|
| 67 |
"""
|
| 68 |
from qwen_vl_utils import process_vision_info
|
| 69 |
|
| 70 |
+
n = len(event_frames)
|
| 71 |
+
content = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
for i, (ts, img) in enumerate(event_frames):
|
| 73 |
+
content.append({"type": "text", "text": f"Keyframe {i+1}/{n} — t={ts:.2f}s:"})
|
|
|
|
| 74 |
content.append({"type": "image", "image": img})
|
| 75 |
content.append({"type": "text",
|
| 76 |
"text": f"Now write the {n}-line commentary JSON."})
|
| 77 |
|
| 78 |
+
persona = STYLES.get(style_key, list(STYLES.values())[0])
|
| 79 |
roster_str = ", ".join(roster) if roster else "nobody named yet"
|
|
|
|
| 80 |
messages = [
|
| 81 |
{"role": "system",
|
| 82 |
+
"content": SYSTEM_PROMPT.format(persona=persona, roster=roster_str,
|
| 83 |
+
n_frames=n)},
|
|
|
|
| 84 |
{"role": "user", "content": content},
|
| 85 |
]
|
| 86 |
|
| 87 |
+
text = _processor.apply_chat_template(messages, tokenize=False,
|
| 88 |
+
add_generation_prompt=True)
|
| 89 |
image_inputs, _ = process_vision_info(messages)
|
| 90 |
+
inputs = _processor(text=[text], images=image_inputs,
|
| 91 |
+
return_tensors="pt").to(_model.device)
|
| 92 |
|
| 93 |
+
out = _model.generate(**inputs, max_new_tokens=500, do_sample=True,
|
| 94 |
temperature=0.8, top_p=0.95)
|
| 95 |
new = out[:, inputs.input_ids.shape[1]:]
|
| 96 |
raw = _processor.batch_decode(new, skip_special_tokens=True)[0]
|
| 97 |
|
| 98 |
valid_times = [round(ts, 2) for ts, _ in event_frames]
|
| 99 |
+
return _parse(raw, valid_times)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
+
def _parse(raw: str, valid_times: list[float]) -> list[dict]:
|
| 103 |
raw = re.sub(r"```(?:json)?", "", raw).strip()
|
| 104 |
+
m = re.search(r"\[.*\]", raw, re.DOTALL)
|
| 105 |
try:
|
| 106 |
items = json.loads(m.group(0) if m else raw)
|
| 107 |
except (json.JSONDecodeError, AttributeError):
|
|
|
|
| 111 |
for i, it in enumerate(items):
|
| 112 |
try:
|
| 113 |
txt = str(it["text"]).strip()
|
| 114 |
+
t = float(it.get("time", valid_times[min(i, len(valid_times) - 1)]))
|
| 115 |
except (KeyError, TypeError, ValueError):
|
| 116 |
continue
|
| 117 |
+
t = min(valid_times, key=lambda v: abs(v - t)) if valid_times else t
|
|
|
|
| 118 |
if txt:
|
| 119 |
script.append({"time": t, "text": txt})
|
| 120 |
|
faces.py
CHANGED
|
@@ -1,334 +1,168 @@
|
|
| 1 |
-
"""faces.py — Person tracking + face cropping for Marquee.
|
| 2 |
-
|
| 3 |
-
Performance fixes vs previous version:
|
| 4 |
-
- Face detection runs ONCE per sampled frame (not once per person box)
|
| 5 |
-
- Track lookup uses dict instead of linear scan — O(1) vs O(n)
|
| 6 |
-
- Sightings stored only in JSONL, not accumulated in memory per track
|
| 7 |
-
- Logging added throughout for visibility
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
import json
|
| 11 |
-
import logging
|
| 12 |
-
import time
|
| 13 |
-
from pathlib import Path
|
| 14 |
-
|
| 15 |
import cv2
|
| 16 |
import numpy as np
|
| 17 |
from PIL import Image
|
| 18 |
|
| 19 |
from ov_models import OVModel
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
FACE_CONF = 0.55
|
| 25 |
-
SAMPLE_FPS = 2.0
|
| 26 |
-
IOU_THRESHOLD = 0.35
|
| 27 |
-
MAX_MISS = 4
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
| 31 |
-
_face_det = None
|
| 32 |
|
| 33 |
|
| 34 |
def _load():
|
| 35 |
-
global
|
| 36 |
-
if
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
log.info(f"[faces] Models loaded in {time.time()-t0:.1f}s")
|
| 42 |
-
return _person_det, _face_det
|
| 43 |
|
| 44 |
|
| 45 |
-
def
|
|
|
|
| 46 |
h, w = frame_bgr.shape[:2]
|
| 47 |
-
out
|
| 48 |
boxes = []
|
| 49 |
for _, _, conf, x1, y1, x2, y2 in out:
|
| 50 |
-
if conf <
|
| 51 |
continue
|
| 52 |
-
boxes.append([max(0,int(x1*w)), max(0,int(y1*h)),
|
| 53 |
-
min(w,int(x2*w)), min(h,int(y2*h))])
|
| 54 |
return boxes
|
| 55 |
|
| 56 |
|
| 57 |
-
def
|
| 58 |
-
"""
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
return boxes
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
def _best_face_for_person(person_box, all_faces) -> list[int] | None:
|
| 71 |
-
"""Match a person box to the face with highest IoU from the pre-detected list."""
|
| 72 |
-
px1, py1, px2, py2 = person_box
|
| 73 |
-
best_iou, best_box = 0.0, None
|
| 74 |
-
for fx1, fy1, fx2, fy2 in all_faces:
|
| 75 |
-
ix1=max(px1,fx1); iy1=max(py1,fy1)
|
| 76 |
-
ix2=min(px2,fx2); iy2=min(py2,fy2)
|
| 77 |
-
inter = max(0,ix2-ix1)*max(0,iy2-iy1)
|
| 78 |
-
if inter == 0:
|
| 79 |
-
continue
|
| 80 |
-
union = (px2-px1)*(py2-py1) + (fx2-fx1)*(fy2-fy1) - inter
|
| 81 |
-
iou = inter / (union + 1e-6)
|
| 82 |
-
if iou > best_iou:
|
| 83 |
-
best_iou, best_box = iou, [fx1,fy1,fx2,fy2]
|
| 84 |
-
return best_box
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def _iou(a, b) -> float:
|
| 88 |
-
ax1,ay1,ax2,ay2 = a
|
| 89 |
-
bx1,by1,bx2,by2 = b
|
| 90 |
-
ix1=max(ax1,bx1); iy1=max(ay1,by1)
|
| 91 |
-
ix2=min(ax2,bx2); iy2=min(ay2,by2)
|
| 92 |
-
inter = max(0,ix2-ix1)*max(0,iy2-iy1)
|
| 93 |
-
if inter == 0: return 0.0
|
| 94 |
-
return inter/((ax2-ax1)*(ay2-ay1)+(bx2-bx1)*(by2-by1)-inter+1e-6)
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
class _Track:
|
| 98 |
-
_next_id = 0
|
| 99 |
-
|
| 100 |
-
def __init__(self, box):
|
| 101 |
-
self.id = _Track._next_id; _Track._next_id += 1
|
| 102 |
-
self.box = box
|
| 103 |
-
self.miss = 0
|
| 104 |
-
self.best_crop: Image.Image | None = None
|
| 105 |
-
self.best_area = 0
|
| 106 |
-
self.count = 0
|
| 107 |
-
|
| 108 |
-
def update(self, box, crop: Image.Image | None):
|
| 109 |
-
self.box = box
|
| 110 |
-
self.miss = 0
|
| 111 |
-
self.count += 1
|
| 112 |
-
if crop is not None:
|
| 113 |
-
area = crop.size[0] * crop.size[1]
|
| 114 |
-
if area > self.best_area:
|
| 115 |
-
self.best_crop = crop
|
| 116 |
-
self.best_area = area
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
class _IoUTracker:
|
| 120 |
-
def __init__(self):
|
| 121 |
-
self.tracks: list[_Track] = []
|
| 122 |
-
self._id_map: dict[int, _Track] = {} # O(1) lookup by track id
|
| 123 |
-
|
| 124 |
-
def step(self, boxes, crops) -> list[tuple[int, list[int], _Track]]:
|
| 125 |
-
matched_t = set()
|
| 126 |
-
matched_d = set()
|
| 127 |
-
pairs = []
|
| 128 |
-
|
| 129 |
-
for di, dbox in enumerate(boxes):
|
| 130 |
-
best_iou, best_ti = 0.0, -1
|
| 131 |
-
for ti, t in enumerate(self.tracks):
|
| 132 |
-
iou = _iou(t.box, dbox)
|
| 133 |
-
if iou > best_iou:
|
| 134 |
-
best_iou, best_ti = iou, ti
|
| 135 |
-
if best_iou >= IOU_THRESHOLD and best_ti not in matched_t:
|
| 136 |
-
pairs.append((best_ti, di))
|
| 137 |
-
matched_t.add(best_ti); matched_d.add(di)
|
| 138 |
-
|
| 139 |
-
for ti, di in pairs:
|
| 140 |
-
self.tracks[ti].update(boxes[di], crops[di])
|
| 141 |
-
|
| 142 |
-
for ti, t in enumerate(self.tracks):
|
| 143 |
-
if ti not in matched_t:
|
| 144 |
-
t.miss += 1
|
| 145 |
-
|
| 146 |
-
for di, (dbox, crop) in enumerate(zip(boxes, crops)):
|
| 147 |
-
if di not in matched_d:
|
| 148 |
-
t = _Track(dbox); t.update(dbox, crop)
|
| 149 |
-
self.tracks.append(t)
|
| 150 |
-
self._id_map[t.id] = t
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
| 156 |
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|
| 161 |
|
| 162 |
|
| 163 |
def scan_video(video_path: str, max_seconds: float = 600.0):
|
| 164 |
-
"""
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
meta: {"fps", "n_frames"}
|
| 170 |
-
jsonl_path: str
|
| 171 |
"""
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
pdet, fdet = _load()
|
| 176 |
-
_Track._next_id = 0
|
| 177 |
-
|
| 178 |
-
cap = cv2.VideoCapture(video_path)
|
| 179 |
-
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
| 180 |
-
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 0
|
| 181 |
stride = max(1, int(round(fps / SAMPLE_FPS)))
|
| 182 |
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
tracker = _IoUTracker()
|
| 187 |
-
all_tracks: dict[int, dict] = {}
|
| 188 |
-
motion: dict[int, float] = {}
|
| 189 |
-
prev_small = None
|
| 190 |
-
idx = 0
|
| 191 |
-
sampled = 0
|
| 192 |
-
persons_seen = 0
|
| 193 |
-
|
| 194 |
while True:
|
| 195 |
ok, frame = cap.read()
|
| 196 |
if not ok or idx / fps > max_seconds:
|
| 197 |
break
|
| 198 |
-
|
| 199 |
if idx % stride == 0:
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
# motion
|
| 204 |
-
small = cv2.cvtColor(cv2.resize(frame,(160,90)), cv2.COLOR_BGR2GRAY)
|
| 205 |
if prev_small is not None:
|
| 206 |
motion[idx] = float(np.mean(cv2.absdiff(small, prev_small)))
|
| 207 |
prev_small = small
|
| 208 |
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
fb = _best_face_for_person(pb, f_boxes)
|
| 218 |
-
if fb is not None:
|
| 219 |
-
fx1,fy1,fx2,fy2 = fb
|
| 220 |
-
face_img = Image.fromarray(
|
| 221 |
-
cv2.cvtColor(frame[fy1:fy2,fx1:fx2], cv2.COLOR_BGR2RGB))
|
| 222 |
-
else:
|
| 223 |
-
px1,py1,px2,py2 = pb
|
| 224 |
-
fh = max(1,(py2-py1)//3)
|
| 225 |
-
face_img = Image.fromarray(
|
| 226 |
-
cv2.cvtColor(frame[py1:py1+fh,px1:px2], cv2.COLOR_BGR2RGB))
|
| 227 |
-
crops.append(face_img)
|
| 228 |
-
|
| 229 |
-
# track
|
| 230 |
-
active = tracker.step(p_boxes, crops)
|
| 231 |
-
for tid, bbox, trk in active:
|
| 232 |
-
if tid not in all_tracks:
|
| 233 |
-
all_tracks[tid] = {"id": tid, "crop": None,
|
| 234 |
-
"sightings": [], "count": 0}
|
| 235 |
-
entry = all_tracks[tid]
|
| 236 |
-
entry["count"] += 1
|
| 237 |
-
entry["sightings"].append({"t": t_sec, "bbox": bbox})
|
| 238 |
-
if trk.best_crop is not None:
|
| 239 |
-
area = trk.best_crop.size[0] * trk.best_crop.size[1]
|
| 240 |
-
if entry["crop"] is None or area > entry.get("_area", 0):
|
| 241 |
-
entry["crop"] = trk.best_crop
|
| 242 |
-
entry["_area"] = area
|
| 243 |
-
|
| 244 |
-
# progress log every ~5 seconds of video
|
| 245 |
-
if sampled % max(1, int(5 * SAMPLE_FPS)) == 0:
|
| 246 |
-
pct = int(100 * idx / max(total, 1))
|
| 247 |
-
log.info(f"[scan] {pct}% — t={t_sec:.1f}s, "
|
| 248 |
-
f"active tracks={len(tracker.tracks)}, "
|
| 249 |
-
f"persons this frame={len(p_boxes)}")
|
| 250 |
-
|
| 251 |
idx += 1
|
| 252 |
-
|
| 253 |
cap.release()
|
| 254 |
-
|
| 255 |
|
| 256 |
-
strong = {tid: v for tid, v in all_tracks.items() if v["count"] >= 2}
|
| 257 |
-
if not strong:
|
| 258 |
-
strong = all_tracks
|
| 259 |
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
tid = row["track_id"]
|
| 295 |
-
if tid in id_to_name:
|
| 296 |
-
row["name"] = id_to_name[tid]
|
| 297 |
-
rows.append(row)
|
| 298 |
-
with open(jsonl_path, "w") as f:
|
| 299 |
-
for row in rows:
|
| 300 |
-
f.write(json.dumps(row) + "\n")
|
| 301 |
-
log.info(f"[faces] Names written for {len(id_to_name)} tracks")
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
def annotate_event_frame(frame_bgr, jsonl_path: str, t_sec: float,
|
| 305 |
-
tol: float = 1.5) -> Image.Image:
|
| 306 |
img = frame_bgr.copy()
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
if
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
x1,
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
(
|
| 327 |
-
|
| 328 |
-
cv2.rectangle(img,(tx-4,ty-th-6),(tx+tw+4,ty+4),(0,0,0),-1)
|
| 329 |
-
cv2.putText(img,name,(tx,ty),cv2.FONT_HERSHEY_DUPLEX,
|
| 330 |
-
scale,(0,255,255),thick,cv2.LINE_AA)
|
| 331 |
-
|
| 332 |
return Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
|
| 333 |
|
| 334 |
|
|
@@ -337,6 +171,4 @@ def frame_at(video_path: str, frame_idx: int):
|
|
| 337 |
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
|
| 338 |
ok, frame = cap.read()
|
| 339 |
cap.release()
|
| 340 |
-
|
| 341 |
-
log.warning(f"[faces] frame_at({frame_idx}) failed")
|
| 342 |
-
return frame if ok else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import cv2
|
| 2 |
import numpy as np
|
| 3 |
from PIL import Image
|
| 4 |
|
| 5 |
from ov_models import OVModel
|
| 6 |
|
| 7 |
+
DET_CONF = 0.6
|
| 8 |
+
SAME_PERSON = 0.55 # cosine sim above this = same identity (tune 0.4-0.6)
|
| 9 |
+
SAMPLE_FPS = 2.0 # detection sampling rate (identity discovery)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
# ArcFace-style reference 5 points, scaled to a 128x128 aligned crop.
|
| 12 |
+
_REF_5PTS = np.array([
|
| 13 |
+
[38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366],
|
| 14 |
+
[41.5493, 92.3655], [70.7299, 92.2041],
|
| 15 |
+
], dtype=np.float32) * (128.0 / 112.0)
|
| 16 |
|
| 17 |
+
_det = _lm = _reid = None
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
def _load():
|
| 21 |
+
global _det, _lm, _reid
|
| 22 |
+
if _det is None:
|
| 23 |
+
_det = OVModel("face-detection-retail-0004")
|
| 24 |
+
_lm = OVModel("landmarks-regression-retail-0009")
|
| 25 |
+
_reid = OVModel("face-reidentification-retail-0095")
|
| 26 |
+
return _det, _lm, _reid
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
+
def _detect(frame_bgr) -> list[list[int]]:
|
| 30 |
+
det, _, _ = _load()
|
| 31 |
h, w = frame_bgr.shape[:2]
|
| 32 |
+
out = det.infer(frame_bgr).reshape(-1, 7) # [_,_,conf,x1,y1,x2,y2] norm
|
| 33 |
boxes = []
|
| 34 |
for _, _, conf, x1, y1, x2, y2 in out:
|
| 35 |
+
if conf < DET_CONF:
|
| 36 |
continue
|
| 37 |
+
boxes.append([max(0, int(x1 * w)), max(0, int(y1 * h)),
|
| 38 |
+
min(w, int(x2 * w)), min(h, int(y2 * h))])
|
| 39 |
return boxes
|
| 40 |
|
| 41 |
|
| 42 |
+
def _aligned_embed(frame_bgr, box) -> np.ndarray | None:
|
| 43 |
+
"""Crop -> landmark-align to 128x128 -> re-id embedding (L2-normalized).
|
| 44 |
+
Alignment matters: re-id-0095 expects an aligned face; raw crops cluster
|
| 45 |
+
badly on tilted/profile shots, which would split or merge people.
|
| 46 |
+
"""
|
| 47 |
+
_, lm, reid = _load()
|
| 48 |
+
x1, y1, x2, y2 = box
|
| 49 |
+
crop = frame_bgr[y1:y2, x1:x2]
|
| 50 |
+
if crop.size == 0:
|
| 51 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
+
# landmarks-regression-retail-0009 -> 10 values = 5 (x,y) in crop-relative
|
| 54 |
+
pts = lm.infer(crop).reshape(5, 2)
|
| 55 |
+
pts[:, 0] *= (x2 - x1)
|
| 56 |
+
pts[:, 1] *= (y2 - y1)
|
| 57 |
+
pts[:, 0] += x1
|
| 58 |
+
pts[:, 1] += y1
|
| 59 |
|
| 60 |
+
M, _ = cv2.estimateAffinePartial2D(pts.astype(np.float32), _REF_5PTS)
|
| 61 |
+
if M is None:
|
| 62 |
+
aligned = cv2.resize(crop, (128, 128)) # fallback: no alignment
|
| 63 |
+
else:
|
| 64 |
+
aligned = cv2.warpAffine(frame_bgr, M, (128, 128))
|
| 65 |
|
| 66 |
+
v = reid.infer(aligned).reshape(-1)
|
| 67 |
+
return v / (np.linalg.norm(v) + 1e-9)
|
| 68 |
|
| 69 |
|
| 70 |
def scan_video(video_path: str, max_seconds: float = 600.0):
|
| 71 |
+
"""Sparse pass. Returns:
|
| 72 |
+
embeds: list[np.ndarray] # one per detected face
|
| 73 |
+
crops: list[PIL.Image] # matching crops
|
| 74 |
+
motion: {frame_idx: float} # for key-event picking
|
| 75 |
+
meta: {"fps", "n_frames"}
|
|
|
|
|
|
|
| 76 |
"""
|
| 77 |
+
_load()
|
| 78 |
+
cap = cv2.VideoCapture(video_path)
|
| 79 |
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
stride = max(1, int(round(fps / SAMPLE_FPS)))
|
| 81 |
|
| 82 |
+
embeds, crops, motion = [], [], {}
|
| 83 |
+
prev_small = None
|
| 84 |
+
idx = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
while True:
|
| 86 |
ok, frame = cap.read()
|
| 87 |
if not ok or idx / fps > max_seconds:
|
| 88 |
break
|
|
|
|
| 89 |
if idx % stride == 0:
|
| 90 |
+
# motion vs previous SAMPLED frame (coarse but fine for events)
|
| 91 |
+
small = cv2.cvtColor(cv2.resize(frame, (160, 90)), cv2.COLOR_BGR2GRAY)
|
|
|
|
|
|
|
|
|
|
| 92 |
if prev_small is not None:
|
| 93 |
motion[idx] = float(np.mean(cv2.absdiff(small, prev_small)))
|
| 94 |
prev_small = small
|
| 95 |
|
| 96 |
+
for box in _detect(frame):
|
| 97 |
+
emb = _aligned_embed(frame, box)
|
| 98 |
+
if emb is None:
|
| 99 |
+
continue
|
| 100 |
+
embeds.append(emb)
|
| 101 |
+
x1, y1, x2, y2 = box
|
| 102 |
+
crops.append(Image.fromarray(
|
| 103 |
+
cv2.cvtColor(frame[y1:y2, x1:x2], cv2.COLOR_BGR2RGB)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
idx += 1
|
| 105 |
+
n_frames = idx
|
| 106 |
cap.release()
|
| 107 |
+
return embeds, crops, motion, {"fps": fps, "n_frames": n_frames}
|
| 108 |
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
+
def cluster_faces(embeds, crops):
|
| 111 |
+
"""Greedy-cluster all detected faces into unique identities. Returns:
|
| 112 |
+
identities: [{"centroid": emb, "crop": best PIL, "count": n}, ...]
|
| 113 |
+
Index in this list == identity id used everywhere else.
|
| 114 |
+
"""
|
| 115 |
+
identities = []
|
| 116 |
+
for emb, crop in zip(embeds, crops):
|
| 117 |
+
best, best_sim = None, SAME_PERSON
|
| 118 |
+
for ident in identities:
|
| 119 |
+
sim = float(np.dot(emb, ident["centroid"]))
|
| 120 |
+
if sim >= best_sim:
|
| 121 |
+
best, best_sim = ident, sim
|
| 122 |
+
if best is None:
|
| 123 |
+
identities.append({"centroid": emb, "crop": crop,
|
| 124 |
+
"crop_area": crop.size[0] * crop.size[1],
|
| 125 |
+
"count": 1})
|
| 126 |
+
else:
|
| 127 |
+
n = best["count"]
|
| 128 |
+
best["centroid"] = (best["centroid"] * n + emb) / (n + 1)
|
| 129 |
+
best["centroid"] /= (np.linalg.norm(best["centroid"]) + 1e-9)
|
| 130 |
+
best["count"] = n + 1
|
| 131 |
+
if crop.size[0] * crop.size[1] > best["crop_area"]:
|
| 132 |
+
best["crop"] = crop
|
| 133 |
+
best["crop_area"] = crop.size[0] * crop.size[1]
|
| 134 |
+
# drop singletons (likely false detections) unless that leaves nothing
|
| 135 |
+
strong = [i for i in identities if i["count"] >= 2]
|
| 136 |
+
return strong or identities
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def annotate_event_frame(frame_bgr, identities, names) -> Image.Image:
|
| 140 |
+
"""For ONE key-event frame: detect faces, match each to a named identity,
|
| 141 |
+
burn the name above the box. This image is VLM-input only — the user never
|
| 142 |
+
sees it. This is how the VLM grounds names to the right person.
|
| 143 |
+
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
img = frame_bgr.copy()
|
| 145 |
+
centroids = [i["centroid"] for i in identities]
|
| 146 |
+
for box in _detect(frame_bgr):
|
| 147 |
+
emb = _aligned_embed(frame_bgr, box)
|
| 148 |
+
if emb is None or not centroids:
|
| 149 |
+
continue
|
| 150 |
+
sims = [float(np.dot(emb, c)) for c in centroids]
|
| 151 |
+
iid = int(np.argmax(sims))
|
| 152 |
+
if sims[iid] < SAME_PERSON:
|
| 153 |
+
continue
|
| 154 |
+
name = names.get(iid, "").strip()
|
| 155 |
+
if not name:
|
| 156 |
+
continue
|
| 157 |
+
x1, y1, x2, y2 = box
|
| 158 |
+
scale = max(0.5, (x2 - x1) / 200.0)
|
| 159 |
+
thick = max(1, int(scale * 2))
|
| 160 |
+
(tw, th), _ = cv2.getTextSize(name, cv2.FONT_HERSHEY_DUPLEX, scale, thick)
|
| 161 |
+
tx, ty = x1, max(th + 6, y1 - 8)
|
| 162 |
+
cv2.rectangle(img, (tx - 4, ty - th - 6), (tx + tw + 4, ty + 4),
|
| 163 |
+
(0, 0, 0), -1)
|
| 164 |
+
cv2.putText(img, name, (tx, ty), cv2.FONT_HERSHEY_DUPLEX,
|
| 165 |
+
scale, (0, 255, 255), thick, cv2.LINE_AA)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
return Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
|
| 167 |
|
| 168 |
|
|
|
|
| 171 |
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
|
| 172 |
ok, frame = cap.read()
|
| 173 |
cap.release()
|
| 174 |
+
return frame if ok else None
|
|
|
|
|
|
marquee.css
CHANGED
|
@@ -1,346 +1,468 @@
|
|
| 1 |
/* ============================================================
|
| 2 |
-
MARQUEE
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
============================================================ */
|
| 5 |
|
| 6 |
-
:root
|
| 7 |
-
|
| 8 |
-
--
|
| 9 |
-
--
|
| 10 |
-
--
|
| 11 |
-
--
|
| 12 |
-
--
|
| 13 |
-
--
|
| 14 |
-
--
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
--
|
| 18 |
-
--
|
| 19 |
-
--
|
| 20 |
-
--
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
--
|
| 24 |
-
--
|
| 25 |
-
--
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
--
|
| 41 |
-
-
|
| 42 |
}
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
}
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
}
|
| 55 |
-
|
| 56 |
-
-
|
| 57 |
-
-
|
| 58 |
-
|
| 59 |
-
-
|
| 60 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
/*
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
color:
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
}
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
}
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
.
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
.
|
| 85 |
-
.
|
| 86 |
-
.
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
}
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
.
|
| 98 |
-
.
|
| 99 |
-
|
| 100 |
-
.
|
| 101 |
-
.
|
| 102 |
-
.broadcast-meta { display: flex; align-items: center; gap: 16px; }
|
| 103 |
-
.clock { font-family: 'Bebas Neue', sans-serif; letter-spacing: .12em; font-size: 22px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
| 104 |
-
.status { display: flex; align-items: center; gap: 9px; white-space: nowrap; font-family: 'Bebas Neue', sans-serif; letter-spacing: .14em; font-size: 15px; color: var(--muted); border: 1px solid var(--line); padding: 8px 16px; border-radius: 999px; background: rgba(11,11,20,0.6); }
|
| 105 |
-
.status .led { width: 8px; height: 8px; border-radius: 50%; background: var(--muted-2); transition: .3s; }
|
| 106 |
-
.status.live { color: var(--secondary); border-color: rgba(255,61,139,0.4); background: rgba(255,61,139,0.06); }
|
| 107 |
-
.status.live .led { background: var(--secondary); box-shadow: 0 0 10px var(--secondary); animation: tw 1.1s infinite; }
|
| 108 |
-
|
| 109 |
-
/* ============================================================ RUNDOWN ============================================================ */
|
| 110 |
-
.rundown { position: relative; display: grid; grid-template-columns: repeat(4,1fr); margin: 12px 0 34px; padding: 0 8px; }
|
| 111 |
-
.rundown::before { content: ""; position: absolute; top: 19px; left: 12%; right: 12%; height: 2px; background: var(--line-soft); }
|
| 112 |
-
.cue { position: relative; display: flex; flex-direction: column; align-items: center; gap: 10px; text-align: center; }
|
| 113 |
-
.cue .node { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; background: var(--night-2); border: 1.5px solid var(--line); font-family: 'Bebas Neue', sans-serif; font-size: 20px; color: var(--muted-2); position: relative; z-index: 1; transition: .35s var(--fx); }
|
| 114 |
-
.cue .ttl { font-family: 'Unbounded', sans-serif; font-weight: 600; font-size: 13px; color: var(--muted); }
|
| 115 |
-
.cue .sub { font-size: 11px; color: var(--muted-2); letter-spacing: .01em; }
|
| 116 |
-
.cue.done .node { border-color: var(--accent); color: var(--accent); background: rgba(0,240,255,0.06); }
|
| 117 |
-
.cue.done .ttl { color: var(--cream); }
|
| 118 |
-
.cue.on .node { border-color: var(--primary); color: var(--primary); background: rgba(185,139,255,0.1); box-shadow: 0 0 0 4px rgba(185,139,255,0.06), 0 0 20px rgba(185,139,255,0.25); }
|
| 119 |
-
.cue.on .ttl { color: var(--primary); }
|
| 120 |
-
|
| 121 |
-
/* ============================================================ VIBE BANNER ============================================================ */
|
| 122 |
-
.vibe-banner { display: flex; align-items: center; gap: 14px; padding: 16px 20px; border-radius: 16px; margin-bottom: 24px; background: color-mix(in srgb, var(--primary) 8%, var(--stage)); border: 1.5px solid color-mix(in srgb, var(--primary) 25%, transparent); box-shadow: inset 0 1px 0 rgba(255,255,255,0.03), 0 10px 30px rgba(0,0,0,0.4); backdrop-filter: blur(12px); transition: all 0.5s var(--fx); position: relative; overflow: hidden; }
|
| 123 |
-
.vibe-banner::before { content: ""; position: absolute; inset: 0; background: radial-gradient(circle at 10% 30%, color-mix(in srgb, var(--vc, var(--primary)) 8%, transparent), transparent 50%); pointer-events: none; }
|
| 124 |
-
.vibe-banner-icon { width: 44px; height: 44px; border-radius: 12px; flex: none; display: grid; place-items: center; font-size: 20px; background: color-mix(in srgb, var(--primary) 15%, transparent); color: var(--primary); transition: all 0.5s var(--fx); box-shadow: 0 0 15px color-mix(in srgb,var(--vc,var(--primary)) 40%,transparent); }
|
| 125 |
-
.vibe-banner-name { display: block; font-family: 'Bebas Neue', sans-serif; font-size: 16px; font-weight: bold; color: var(--primary); letter-spacing: .08em; }
|
| 126 |
-
.vibe-banner-desc { display: block; font-size: 12.5px; color: var(--cream); line-height: 1.45; }
|
| 127 |
-
.banner-wave { display: flex; align-items: center; gap: 3px; height: 16px; margin-left: auto; flex-shrink: 0; padding-left: 14px; border-left: 1px solid var(--line); }
|
| 128 |
-
.banner-wave span { width: 2.5px; height: 4px; border-radius: 1px; background: var(--vc, var(--primary)); animation: wav 1.1s ease-in-out infinite alternate; opacity: 0.85; }
|
| 129 |
-
.banner-wave span:nth-child(2) { animation-delay: 0.15s; height: 8px; }
|
| 130 |
-
.banner-wave span:nth-child(3) { animation-delay: 0.3s; height: 14px; }
|
| 131 |
-
.banner-wave span:nth-child(4) { animation-delay: 0.45s; height: 9px; }
|
| 132 |
-
.banner-wave span:nth-child(5) { animation-delay: 0.6s; height: 5px; }
|
| 133 |
-
@keyframes wav { 0% { transform: scaleY(0.4); opacity: 0.3; } 100% { transform: scaleY(1.5); opacity: 1; filter: brightness(1.2); } }
|
| 134 |
-
|
| 135 |
-
/* ============================================================ CONSOLE ============================================================ */
|
| 136 |
-
.console { display: grid; grid-template-columns: 380px 1fr; gap: 24px; align-items: start; }
|
| 137 |
-
@media(max-width:960px){ .console { grid-template-columns: 1fr; } }
|
| 138 |
-
.desk { display: flex; flex-direction: column; gap: 20px; }
|
| 139 |
-
.card { background: var(--stage); border: 1px solid var(--line); border-radius: 16px; padding: 22px; box-shadow: 0 8px 32px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.02); backdrop-filter: blur(12px); position: relative; overflow: hidden; transition: background-color 0.5s var(--fx), border-color 0.5s var(--fx); }
|
| 140 |
-
.card-h { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
| 141 |
-
.card-h .ic { color: var(--primary); font-size: 18px; transition: color 0.5s; }
|
| 142 |
-
.card-h h2 { font-family: 'Unbounded', sans-serif; font-weight: 600; font-size: 14px; letter-spacing: .01em; }
|
| 143 |
-
.card-h .meta { margin-left: auto; font-family: 'Bebas Neue', sans-serif; letter-spacing: .07em; font-size: 12px; color: var(--muted-2); }
|
| 144 |
-
.skew-header { transform: skewX(-12deg); }
|
| 145 |
-
|
| 146 |
-
/* Drop zone */
|
| 147 |
-
.drop { position: relative; border: 1.5px dashed rgba(255,255,255,0.15); border-radius: 14px; padding: 28px 20px; text-align: center; cursor: pointer; transition: .25s var(--fx); overflow: hidden; background: radial-gradient(180px 100px at 50% -20%, rgba(185,139,255,0.03), transparent 75%); }
|
| 148 |
-
.drop:hover { border-color: var(--primary); background: radial-gradient(180px 100px at 50% -10%, rgba(185,139,255,0.06), transparent 75%); }
|
| 149 |
-
.drop:focus-visible { border-color: var(--accent); }
|
| 150 |
-
.drop .clap { font-size: 32px; color: var(--primary); display: inline-flex; margin-bottom: 10px; transition: color 0.5s; }
|
| 151 |
-
.drop .t { font-family: 'Unbounded', sans-serif; font-weight: 600; font-size: 14px; }
|
| 152 |
-
.drop .s { font-size: 11px; color: var(--muted); margin-top: 5px; }
|
| 153 |
-
.drop .loaded { display: none; align-items: center; gap: 14px; text-align: left; }
|
| 154 |
-
.drop.loaded { border-style: solid; border-color: rgba(185,139,255,0.35); background: rgba(185,139,255,0.03); cursor: default; }
|
| 155 |
-
.drop.loaded .ask { display: none; }
|
| 156 |
-
.drop.loaded .loaded { display: flex; }
|
| 157 |
-
.strip { width: 52px; height: 52px; border-radius: 10px; flex: none; position: relative; overflow: hidden; background: linear-gradient(135deg,var(--night-2),var(--stage)); border: 1.5px solid var(--line); }
|
| 158 |
-
.strip::before, .strip::after { content: ""; position: absolute; top: 4px; bottom: 4px; width: 4px; background: repeating-linear-gradient(var(--night) 0 3px,transparent 3px 6px); }
|
| 159 |
-
.strip::before { left: 3px; }
|
| 160 |
-
.strip::after { right: 3px; }
|
| 161 |
-
.strip .play { position: absolute; inset: 0; display: grid; place-items: center; color: var(--accent); font-size: 18px; }
|
| 162 |
-
.loaded .fn { font-family: 'Unbounded', sans-serif; font-weight: 600; font-size: 13px; color: var(--cream); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 190px; }
|
| 163 |
-
.loaded .fd { font-family: 'Bebas Neue', sans-serif; letter-spacing: .06em; font-size: 14px; color: var(--accent); margin-top: 2px; }
|
| 164 |
-
|
| 165 |
-
/* Buttons */
|
| 166 |
-
.btn { width: 100%; border: 0; cursor: pointer; font-family: 'Unbounded', sans-serif; font-weight: 600; font-size: 13px; color: var(--night); padding: 15px; border-radius: 12px; display: flex; align-items: center; justify-content: center; gap: 10px; transition: .2s var(--fx); }
|
| 167 |
-
.btn:disabled { opacity: .3; cursor: not-allowed; background: var(--night-2) !important; color: var(--muted-2) !important; border: 1px solid var(--line-soft); }
|
| 168 |
-
.btn.cyan { background: linear-gradient(135deg,var(--accent),#00a8e8); box-shadow: 0 4px 14px rgba(0,240,255,0.2); }
|
| 169 |
-
.btn.cyan:not(:disabled):hover { transform: translateY(-2px); box-shadow: 0 10px 25px rgba(0,240,255,0.4); }
|
| 170 |
-
.btn.show { font-size: 15px; padding: 18px; background: linear-gradient(110deg,var(--primary),var(--secondary)); position: relative; overflow: hidden; color: #fff; box-shadow: 0 6px 20px rgba(255,0,102,0.15); }
|
| 171 |
-
.btn.show:not(:disabled):hover { transform: translateY(-2px); box-shadow: 0 12px 32px rgba(255,0,102,0.35); }
|
| 172 |
-
.btn.show:not(:disabled)::after { content: ""; position: absolute; top: 0; bottom: 0; width: 30%; background: linear-gradient(90deg,transparent,rgba(255,255,255,0.25),transparent); transform: translateX(-220%); }
|
| 173 |
-
@media(prefers-reduced-motion:no-preference){ .btn.show:not(:disabled)::after { animation: shine 4s ease-in-out infinite; } }
|
| 174 |
-
@keyframes shine { 0% { transform: translateX(-220%); } 35%,100% { transform: translateX(360%); } }
|
| 175 |
-
.mt { margin-top: 14px; }
|
| 176 |
-
.sweep { height: 4px; border-radius: 2px; margin-top: 14px; background: var(--night-2); position: relative; overflow: hidden; opacity: 0; transition: .25s; }
|
| 177 |
-
.casting .sweep { opacity: 1; }
|
| 178 |
-
.sweep::after { content: ""; position: absolute; inset: 0; width: 40%; background: linear-gradient(90deg,transparent,var(--primary),transparent); }
|
| 179 |
-
@media(prefers-reduced-motion:no-preference){ .sweep::after { animation: sw 1.2s linear infinite; } }
|
| 180 |
-
@keyframes sw { from { transform: translateX(-120%); } to { transform: translateX(300%); } }
|
| 181 |
-
|
| 182 |
-
/* ============================================================ STAGE / STAR CARDS ============================================================ */
|
| 183 |
-
.stage-panel { background: radial-gradient(80% 60% at 50% -8%, rgba(185,139,255,0.03), transparent 60%), linear-gradient(180deg,var(--stage),var(--night-2)); border: 1px solid var(--line); border-radius: 24px; padding: 24px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.02), 0 24px 60px rgba(0,0,0,0.5); backdrop-filter: blur(12px); transition: background-color 0.5s var(--fx); }
|
| 184 |
-
.stage-h { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
| 185 |
-
.stage-h .ic { color: var(--primary); font-size: 20px; transition: color 0.5s; }
|
| 186 |
-
.stage-h h2 { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 16px; }
|
| 187 |
-
.stage-h .count { margin-left: auto; white-space: nowrap; font-family: 'Bebas Neue', sans-serif; letter-spacing: .08em; font-size: 14px; color: var(--primary); background: rgba(185,139,255,0.06); padding: 5px 12px; border-radius: 8px; border: 1px solid rgba(185,139,255,0.15); transition: color 0.5s, border-color 0.5s; }
|
| 188 |
-
.empty { display: grid; place-items: center; min-height: 330px; text-align: center; padding: 20px; }
|
| 189 |
-
.empty .spot { width: 90px; height: 90px; border-radius: 50%; display: grid; place-items: center; margin-bottom: 16px; color: var(--muted-2); font-size: 44px; background: radial-gradient(circle at 50% 30%,rgba(255,255,255,0.03),transparent 75%); border: 1px solid var(--line-soft); }
|
| 190 |
-
.empty .t { font-family: 'Unbounded', sans-serif; font-weight: 600; font-size: 16px; color: var(--cream); margin-bottom: 6px; }
|
| 191 |
-
.empty .s { font-size: 13px; color: var(--muted); max-width: 320px; }
|
| 192 |
-
.roster { display: grid; grid-template-columns: repeat(auto-fill,minmax(160px,1fr)); gap: 18px; }
|
| 193 |
-
.star { --c: var(--primary); position: relative; border-radius: 16px; overflow: hidden; background: linear-gradient(180deg,var(--stage-2),var(--stage)); border: 1px solid var(--line); transition: .25s var(--fx); }
|
| 194 |
-
.star:hover { transform: translateY(-4px); border-color: color-mix(in srgb,var(--c) 60%,transparent); }
|
| 195 |
-
.portrait { position: relative; height: 138px; overflow: hidden; background: radial-gradient(90% 80% at 50% 120%,color-mix(in srgb,var(--c) 15%,var(--night-2)),var(--night-2)); }
|
| 196 |
-
.portrait .beam { position: absolute; top: -30px; left: 50%; width: 140px; height: 170px; transform: translateX(-50%); pointer-events: none; clip-path: polygon(40% 0,60% 0,100% 100%,0 100%); background: linear-gradient(180deg,color-mix(in srgb,var(--c) 40%,transparent),transparent 75%); opacity: .5; }
|
| 197 |
-
.portrait .head-ph { position: absolute; left: 50%; bottom: -6px; transform: translateX(-50%); color: color-mix(in srgb,var(--c) 30%,#ffffff); opacity: .3; }
|
| 198 |
-
.portrait .frame { position: absolute; inset: 8px; border: 1.5px solid color-mix(in srgb,var(--c) 25%,transparent); border-radius: 10px; pointer-events: none; }
|
| 199 |
-
.portrait .idx { position: absolute; top: 10px; left: 10px; font-family: 'Bebas Neue', sans-serif; font-size: 14px; color: var(--cream); background: rgba(11,11,20,0.8); padding: 2px 8px; border-radius: 6px; display: flex; align-items: center; gap: 4px; }
|
| 200 |
-
.portrait .idx .st { color: var(--c); }
|
| 201 |
-
.tier { position: absolute; top: 10px; right: 10px; font-family: 'Bebas Neue', sans-serif; font-size: 11px; letter-spacing: .02em; white-space: nowrap; color: var(--night); background: var(--c); padding: 3px 8px; border-radius: 6px; box-shadow: 0 2px 10px color-mix(in srgb,var(--c) 40%,transparent); font-weight: bold; }
|
| 202 |
-
.crown { position: absolute; top: 50px; left: 50%; transform: translateX(-50%) translateY(12px) scale(.4) rotate(-12deg); opacity: 0; width: 30px; height: 30px; color: var(--primary); filter: drop-shadow(0 2px 6px color-mix(in srgb,var(--primary) 60%,transparent)); transition: .4s var(--pop); }
|
| 203 |
-
.star.named .crown { opacity: 1; transform: translateX(-50%) scale(1) rotate(0); }
|
| 204 |
-
.star-body { padding: 12px; }
|
| 205 |
-
.star-body label { font-family: 'Bebas Neue', sans-serif; letter-spacing: .08em; font-size: 12px; color: var(--muted); display: block; margin-bottom: 6px; }
|
| 206 |
-
.star input { width: 100%; background: var(--night-2); border: 1px solid var(--line); color: var(--cream); border-radius: 10px; padding: 10px 12px; font-family: 'Nunito', sans-serif; font-weight: 800; font-size: 14px; outline: none; transition: .16s; }
|
| 207 |
-
.star input::placeholder { color: var(--muted-2); font-weight: 600; }
|
| 208 |
-
.star input:focus { border-color: var(--c); box-shadow: 0 0 0 3px color-mix(in srgb,var(--c) 20%,transparent); }
|
| 209 |
-
.star.named { border-color: color-mix(in srgb,var(--c) 55%,transparent); box-shadow: 0 8px 24px color-mix(in srgb,var(--c) 12%,transparent); }
|
| 210 |
-
.star.named .portrait { background: radial-gradient(90% 90% at 50% 120%,color-mix(in srgb,var(--c) 30%,var(--night-2)),var(--night-2)); }
|
| 211 |
-
.star.named .portrait .beam { opacity: 1; }
|
| 212 |
-
.star.named .head-ph { opacity: .6; }
|
| 213 |
-
|
| 214 |
-
/* ============================================================ VIEWS ============================================================ */
|
| 215 |
-
.view { display: none; }
|
| 216 |
-
.view.active { display: block; animation: viewIn .45s var(--fx); }
|
| 217 |
-
@keyframes viewIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
| 218 |
-
|
| 219 |
-
/* ============================================================ BROADCAST PLAYER ============================================================ */
|
| 220 |
-
.booth-top { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
|
| 221 |
-
.back { display: inline-flex; align-items: center; gap: 8px; background: var(--stage); border: 1px solid var(--line); color: var(--cream); font-family: 'Nunito', sans-serif; font-weight: 700; font-size: 13px; padding: 10px 16px; border-radius: 10px; cursor: pointer; transition: .16s; backdrop-filter: blur(12px); }
|
| 222 |
-
.back:hover { border-color: var(--primary); color: var(--cream); }
|
| 223 |
-
.booth { display: grid; grid-template-columns: 1fr 330px; gap: 20px; align-items: start; transition: grid-template-columns 0.4s var(--fx); }
|
| 224 |
-
@media(max-width:840px){ .booth { grid-template-columns: 1fr; } }
|
| 225 |
-
.screen-wrap { background: #000; border-radius: 20px; overflow: hidden; border: 1.5px solid var(--line); box-shadow: 0 30px 80px rgba(0,0,0,0.8); position: relative; }
|
| 226 |
-
/* HARDCODED 1:1 ASPECT RATIO — DO NOT CHANGE */
|
| 227 |
-
.screen { position: relative; width: 100%; aspect-ratio: 1/1 !important; background: repeating-linear-gradient(135deg,#0e0a1a 0 18px,#07050d 18px 36px); display: grid; place-items: center; overflow: hidden; }
|
| 228 |
-
.screen .ph { font-family: 'Bebas Neue', sans-serif; letter-spacing: .12em; color: var(--muted-2); font-size: 15px; text-align: center; padding: 0 20px; }
|
| 229 |
-
.screen .ph svg { width: 44px; height: 44px; margin: 0 auto 12px; color: var(--line); display: block; }
|
| 230 |
-
.screen-vid { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; display: none; }
|
| 231 |
-
.booth.portrait .screen-vid { object-fit: cover; }
|
| 232 |
-
.screen.has-video .ph { display: none; }
|
| 233 |
-
.screen.has-video .screen-vid { display: block; }
|
| 234 |
-
.portrait .head-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 2; }
|
| 235 |
-
.star.named .head-img { filter: saturate(1.05); }
|
| 236 |
-
.bug { position: absolute; top: 14px; left: 14px; display: flex; align-items: center; gap: 8px; white-space: nowrap; background: rgba(6,6,12,0.85); backdrop-filter: blur(8px); padding: 7px 14px; border-radius: 8px; font-family: 'Bebas Neue', sans-serif; letter-spacing: .12em; font-size: 13px; color: var(--cream); z-index: 5; border: 1px solid var(--line); }
|
| 237 |
-
.bug .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--secondary); box-shadow: 0 0 10px var(--secondary); }
|
| 238 |
-
@media(prefers-reduced-motion:no-preference){ .bug .dot { animation: tw 1s infinite; } }
|
| 239 |
-
.tc { position: absolute; top: 14px; right: 14px; background: rgba(6,6,12,0.85); backdrop-filter: blur(8px); padding: 7px 14px; border-radius: 8px; font-family: 'Bebas Neue', sans-serif; letter-spacing: .1em; font-size: 14px; color: var(--primary); font-variant-numeric: tabular-nums; z-index: 5; border: 1px solid var(--line); }
|
| 240 |
-
.chyron { position: absolute; left: 14px; right: 14px; bottom: 14px; border-radius: 12px; overflow: hidden; background: linear-gradient(100deg, rgba(10,10,18,0.96), rgba(18,18,30,0.94)); border: 1px solid rgba(255,255,255,0.08); box-shadow: 0 15px 40px rgba(0,0,0,0.6); transform: translateY(130%); opacity: 0; transition: .45s var(--fx); z-index: 10; }
|
| 241 |
-
.chyron.in { transform: none; opacity: 1; }
|
| 242 |
-
.chyron .bar { height: 4px; background: var(--vibe-primary, var(--secondary)); }
|
| 243 |
-
.chyron .row { display: flex; align-items: stretch; }
|
| 244 |
-
.chyron .who { display: flex; align-items: center; font-family: 'Bebas Neue', sans-serif; letter-spacing: .08em; font-size: 16px; color: var(--night); background: var(--vibe-primary, var(--secondary)); padding: 12px 16px; white-space: nowrap; font-weight: bold; }
|
| 245 |
-
.chyron .line { padding: 12px 18px; font-family: 'Nunito', sans-serif; font-weight: 700; font-size: 14px; color: var(--cream); display: flex; align-items: center; line-height: 1.35; }
|
| 246 |
-
@media(prefers-reduced-motion:no-preference){ .chyron.in .line { animation: sharpen .5s var(--fx); } }
|
| 247 |
-
@keyframes sharpen { from { filter: blur(6px); opacity: .3; } to { filter: blur(0); opacity: 1; } }
|
| 248 |
-
.transport { display: flex; align-items: center; gap: 14px; margin-top: 14px; }
|
| 249 |
-
.transport .pp { width: 48px; height: 48px; border-radius: 50%; border: 0; cursor: pointer; flex: none; display: grid; place-items: center; background: linear-gradient(135deg,var(--primary),var(--secondary)); color: var(--night); font-size: 20px; transition: .16s; box-shadow: 0 4px 12px rgba(255,0,102,0.2); }
|
| 250 |
-
.transport .pp:hover { transform: scale(1.06); box-shadow: 0 6px 18px rgba(255,0,102,0.4); }
|
| 251 |
-
.scrub { flex: 1; height: 6px; border-radius: 4px; background: var(--night-2); position: relative; cursor: pointer; border: 1px solid var(--line-soft); }
|
| 252 |
-
.scrub .fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 4px; width: 0; background: linear-gradient(90deg,var(--secondary),var(--primary)); }
|
| 253 |
-
.scrub .knob { position: absolute; top: 50%; width: 14px; height: 14px; border-radius: 50%; background: var(--cream); transform: translate(-50%,-50%); left: 0; box-shadow: 0 2px 8px rgba(0,0,0,0.6); border: 1.5px solid var(--primary); }
|
| 254 |
-
.pbp { background: var(--stage); border: 1px solid var(--line); border-radius: 20px; overflow: hidden; display: flex; flex-direction: column; max-height: 520px; backdrop-filter: blur(12px); }
|
| 255 |
-
.pbp .pbp-h { display: flex; align-items: center; gap: 10px; padding: 16px 18px; border-bottom: 1px solid var(--line-soft); }
|
| 256 |
-
.pbp .pbp-h .ic { color: var(--primary); font-size: 16px; }
|
| 257 |
-
.pbp .pbp-h h3 { font-family: 'Unbounded', sans-serif; font-weight: 600; font-size: 13px; }
|
| 258 |
-
.pbp .pbp-h .ct { margin-left: auto; font-family: 'Bebas Neue', sans-serif; letter-spacing: .06em; font-size: 12px; color: var(--muted-2); }
|
| 259 |
-
.pbp .lines { overflow-y: auto; padding: 6px; }
|
| 260 |
-
.pbp .pl { display: flex; gap: 12px; padding: 12px 14px; border-radius: 10px; cursor: pointer; transition: .15s; align-items: baseline; }
|
| 261 |
-
.pbp .pl:hover { background: rgba(255,255,255,0.02); }
|
| 262 |
-
.pbp .pl .t { font-family: 'Bebas Neue', sans-serif; letter-spacing: .04em; font-size: 14px; color: var(--muted-2); flex: none; width: 42px; font-variant-numeric: tabular-nums; padding-top: 1px; }
|
| 263 |
-
.pbp .pl .tx { font-size: 13px; line-height: 1.45; color: var(--muted); }
|
| 264 |
-
.pbp .pl .tx b { color: var(--cream); font-weight: 800; }
|
| 265 |
-
.pbp .pl.now { background: color-mix(in srgb,var(--vibe-primary,var(--primary)) 10%,rgba(255,255,255,0.01)); }
|
| 266 |
-
.pbp .pl.now .t { color: var(--vibe-primary,var(--primary)); }
|
| 267 |
-
.pbp .pl.now .tx { color: var(--cream); }
|
| 268 |
-
.pbp .pl.now .tx b { color: var(--vibe-primary,var(--primary)); }
|
| 269 |
-
.export { padding: 14px 18px; border-top: 1px solid var(--line-soft); background: rgba(6,6,12,0.4); }
|
| 270 |
-
.export .btn-srt { width: 100%; display: flex; align-items: center; justify-content: center; gap: 9px; cursor: pointer; background: var(--stage-2); border: 1px solid var(--line); color: var(--cream); font-family: 'Nunito', sans-serif; font-weight: 700; font-size: 13px; padding: 12px; border-radius: 11px; transition: .16s; }
|
| 271 |
-
.export .btn-srt:hover { border-color: var(--accent); color: var(--accent); box-shadow: 0 0 12px rgba(0,240,255,0.15); }
|
| 272 |
-
.export .btn-srt .ic { color: var(--accent); font-size: 16px; }
|
| 273 |
-
.export .hint { text-align: center; font-size: 11px; color: var(--muted-2); margin-top: 8px; }
|
| 274 |
-
.export .hint b { color: var(--muted); }
|
| 275 |
-
.voice-card { margin-top: 0; }
|
| 276 |
-
.voice-opts { display: flex; gap: 8px; padding: 4px 0; }
|
| 277 |
-
.voice-opt { flex: 1; display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line-soft); background: transparent; color: var(--muted); cursor: pointer; text-align: left; transition: .15s; }
|
| 278 |
-
.voice-opt:hover { border-color: rgba(255,255,255,0.1); background: rgba(255,255,255,0.02); }
|
| 279 |
-
.voice-opt[aria-pressed="true"] { border-color: var(--primary); background: rgba(185,139,255,0.06); }
|
| 280 |
-
.voice-opt .vt { font-family: 'Unbounded', sans-serif; font-size: 11px; font-weight: 600; color: var(--cream); }
|
| 281 |
-
.voice-opt[aria-pressed="true"] .vt { color: var(--primary); }
|
| 282 |
-
.voice-opt .vd { font-size: 10px; color: var(--muted-2); }
|
| 283 |
-
.export .btn-export { width: 100%; display: flex; align-items: center; justify-content: center; gap: 9px; cursor: pointer; padding: 11px 16px; border-radius: 10px; border: 1px solid var(--vibe-primary, var(--secondary)); background: color-mix(in srgb,var(--vibe-primary, var(--secondary)) 12%,transparent); color: var(--vibe-primary, var(--secondary)); font-family: 'Unbounded', sans-serif; font-size: 12px; font-weight: 600; letter-spacing: .04em; transition: .15s; margin-top: 10px; }
|
| 284 |
-
.export .btn-export:hover { background: color-mix(in srgb,var(--vibe-primary, var(--secondary)) 22%,transparent); }
|
| 285 |
-
.export .btn-export:disabled { opacity: .4; cursor: not-allowed; }
|
| 286 |
-
.export .btn-export .ic { font-size: 16px; }
|
| 287 |
-
.export-progress { margin-top: 10px; }
|
| 288 |
-
.export-bar { height: 4px; background: var(--line-soft); border-radius: 2px; overflow: hidden; margin-bottom: 6px; }
|
| 289 |
-
.export-fill { height: 100%; background: var(--vibe-primary, var(--secondary)); border-radius: 2px; width: 0%; transition: width .3s; }
|
| 290 |
-
.export-label { font-size: 11px; color: var(--muted-2); }
|
| 291 |
-
.confetti { position: fixed; top: -20px; width: 11px; height: 14px; z-index: 90; pointer-events: none; border-radius: 2px; }
|
| 292 |
-
@media(prefers-reduced-motion:no-preference){ .confetti { animation: fall linear forwards; } }
|
| 293 |
-
@keyframes fall { to { transform: translateY(105vh) rotate(540deg); opacity: .2; } }
|
| 294 |
-
.toast { position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%) translateY(20px); opacity: 0; background: var(--stage-2); border: 1px solid var(--primary); color: var(--cream); padding: 12px 20px; border-radius: 12px; font-weight: 700; font-size: 13px; z-index: 95; transition: .3s var(--fx); display: flex; align-items: center; gap: 9px; box-shadow: 0 14px 40px rgba(0,0,0,0.6); }
|
| 295 |
-
.toast.show { transform: translateX(-50%); opacity: 1; }
|
| 296 |
-
.toast .ic { color: var(--primary); }
|
| 297 |
-
.note { display: flex; align-items: flex-start; gap: 10px; font-size: 12px; color: var(--muted-2); margin-top: 28px; max-width: 560px; line-height: 1.55; }
|
| 298 |
-
.note .lock { font-size: 14px; margin-top: 1px; flex: none; }
|
| 299 |
-
.note b { color: var(--muted); }
|
| 300 |
-
@media(prefers-reduced-motion:reduce){ * { animation: none !important; transition: none !important; } }
|
| 301 |
-
@media(max-width:560px){ .masthead { gap: 14px; } .rundown .sub { display: none; } .roster { grid-template-columns: repeat(auto-fill,minmax(130px,1fr)); } }
|
| 302 |
-
|
| 303 |
-
/* ============================================================ LANDING ============================================================ */
|
| 304 |
-
.landing { position: fixed; inset: 0; z-index: 100; background: #040409; overflow-y: auto; display: flex; align-items: flex-start; justify-content: center; transition: opacity .5s ease, visibility .5s ease; }
|
| 305 |
-
.landing.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
|
| 306 |
-
.landing-inner { width: 100%; max-width: 700px; padding: clamp(32px,6vw,64px) clamp(16px,4vw,32px) 48px; display: flex; flex-direction: column; gap: 40px; }
|
| 307 |
-
.landing-brand { text-align: center; }
|
| 308 |
-
.landing-brand .sign { display: inline-block; position: relative; margin-bottom: 16px; }
|
| 309 |
-
.landing-tag { font-family: 'Nunito', sans-serif; font-size: 15px; color: var(--muted); max-width: 380px; margin: 0 auto; line-height: 1.6; }
|
| 310 |
-
.landing-prompt { font-family: 'Bebas Neue', sans-serif; letter-spacing: .12em; font-size: 13px; color: var(--muted-2); text-transform: uppercase; margin-bottom: 12px; text-align: center; }
|
| 311 |
-
.landing-vibes { display: flex; flex-direction: column; gap: 12px; }
|
| 312 |
-
.lv-card { display: flex; align-items: center; gap: 18px; padding: 18px 20px; background: var(--stage); border: 1px solid var(--line-soft); border-radius: 16px; cursor: pointer; text-align: left; width: 100%; transition: transform .22s var(--fx), border-color .22s var(--fx), background .22s var(--fx), box-shadow .22s var(--fx); position: relative; overflow: hidden; }
|
| 313 |
-
.lv-card::before { content: ''; position: absolute; inset: 0; background: var(--lv-glow, transparent); opacity: 0.15; transition: opacity .25s; pointer-events: none; }
|
| 314 |
-
.lv-card:hover { transform: translateY(-3px); }
|
| 315 |
-
.lv-card:hover::before { opacity: 0.35; }
|
| 316 |
-
.lv-card[data-vibe="football"] { background: linear-gradient(135deg, #09170d 0%, #080c14 100%); border-color: rgba(76,175,80,0.15); }
|
| 317 |
-
.lv-card[data-vibe="football"]::after { content: ""; position: absolute; right: 0; top: 0; bottom: 0; width: 150px; background: linear-gradient(115deg, transparent, rgba(76,175,80,0.04), transparent); transform: skewX(-20deg); pointer-events: none; }
|
| 318 |
-
.lv-card[data-vibe="football"]:hover { border-color: #39FF14; box-shadow: 0 8px 24px rgba(57,255,20,0.08); }
|
| 319 |
-
.lv-card[data-vibe="football"] .lv-icon { color: #39FF14; background: rgba(57,255,20,0.08); }
|
| 320 |
-
.lv-card[data-vibe="diva"] { background: linear-gradient(135deg, #1c0615 0%, #0a030c 100%); border-color: rgba(233,30,140,0.15); }
|
| 321 |
-
.lv-card[data-vibe="diva"]::after { content: ""; position: absolute; right: 20px; top: 50%; width: 12px; height: 12px; background: #E91E8C; border-radius: 50%; opacity: 0.12; filter: blur(5px); box-shadow: -40px -10px 0 #D4AF37, -20px 15px 0 #E91E8C; pointer-events: none; }
|
| 322 |
-
.lv-card[data-vibe="diva"]:hover { border-color: #E91E8C; box-shadow: 0 8px 24px rgba(233,30,140,0.08); }
|
| 323 |
-
.lv-card[data-vibe="diva"] .lv-icon { color: #F4C2E1; background: rgba(233,30,140,0.08); }
|
| 324 |
-
.lv-card[data-vibe="wildlife"] { background: linear-gradient(135deg, #110e08 0%, #050805 100%); border-color: rgba(200,168,75,0.15); }
|
| 325 |
-
.lv-card[data-vibe="wildlife"]::after { content: ""; position: absolute; inset: 0; background-image: radial-gradient(circle at 90% 50%, rgba(200,168,75,0.04), transparent 75%); pointer-events: none; }
|
| 326 |
-
.lv-card[data-vibe="wildlife"]:hover { border-color: #C8A84B; box-shadow: 0 8px 24px rgba(200,168,75,0.08); }
|
| 327 |
-
.lv-card[data-vibe="wildlife"] .lv-icon { color: #C8A84B; background: rgba(200,168,75,0.08); }
|
| 328 |
-
.lv-card[data-vibe="boxing"] { background: linear-gradient(135deg, #150404 0%, #080202 100%); border-color: rgba(229,57,53,0.15); }
|
| 329 |
-
.lv-card[data-vibe="boxing"]::after { content: ""; position: absolute; top: -30px; right: 20px; width: 80px; height: 160px; background: linear-gradient(to bottom, rgba(255,255,255,0.03), transparent); clip-path: polygon(30% 0, 70% 0, 100% 100%, 0 100%); pointer-events: none; }
|
| 330 |
-
.lv-card[data-vibe="boxing"]:hover { border-color: #E53935; box-shadow: 0 8px 24px rgba(229,57,53,0.08); }
|
| 331 |
-
.lv-card[data-vibe="boxing"] .lv-icon { color: #E53935; background: rgba(229,57,53,0.08); }
|
| 332 |
-
.lv-card[data-vibe="masterchef"] { background: linear-gradient(135deg, #141414 0%, #080808 100%); border-color: rgba(255,143,0,0.15); }
|
| 333 |
-
.lv-card[data-vibe="masterchef"]:hover { border-color: #FF8F00; box-shadow: 0 8px 24px rgba(255,143,0,0.05); }
|
| 334 |
-
.lv-card[data-vibe="masterchef"] .lv-icon { color: #FF8F00; background: rgba(255,143,0,0.05); }
|
| 335 |
-
.lv-icon { width: 48px; height: 48px; border-radius: 12px; flex: none; display: grid; place-items: center; font-size: 22px; transition: background .2s; }
|
| 336 |
-
.lv-body { flex: 1; min-width: 0; }
|
| 337 |
-
.lv-name { display: block; font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 15px; color: var(--cream); margin-bottom: 4px; }
|
| 338 |
-
.lv-desc { display: block; font-size: 12px; color: var(--muted); line-height: 1.45; }
|
| 339 |
-
.lv-arrow { color: var(--muted-2); font-size: 18px; flex: none; transition: transform .2s, color .2s; }
|
| 340 |
-
.lv-card:hover .lv-arrow { transform: translateX(4px); color: var(--cream); }
|
| 341 |
-
|
| 342 |
-
/* ============================================================ CHANGE VIBE BUTTON ============================================================ */
|
| 343 |
-
.change-vibe-btn { background: linear-gradient(180deg, var(--stage-2), var(--stage)); border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 12px rgba(0,0,0,0.3); border-radius: 8px; padding: 8px 16px; font-family: 'Bebas Neue', sans-serif; letter-spacing: .1em; font-size: 13px; color: var(--muted); cursor: pointer; transition: all 0.25s var(--fx); position: relative; overflow: hidden; }
|
| 344 |
-
.change-vibe-btn::before { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.05), transparent); transform: translateX(-100%); transition: transform 0.4s var(--fx); }
|
| 345 |
-
.change-vibe-btn:hover { border-color: var(--primary); color: #fff; box-shadow: 0 0 15px color-mix(in srgb, var(--primary) 25%, transparent); }
|
| 346 |
-
.change-vibe-btn:hover::before { transform: translateX(100%); }
|
|
|
|
| 1 |
/* ============================================================
|
| 2 |
+
MARQUEE — design system
|
| 3 |
+
Tokens straight from the brief so they lift into Gradio css=.
|
| 4 |
+
Sections: tokens · base · masthead · rundown · desk · stage
|
| 5 |
+
· star card · vibes + Coco · broadcast player · utilities
|
| 6 |
============================================================ */
|
| 7 |
|
| 8 |
+
:root{
|
| 9 |
+
/* night & surfaces */
|
| 10 |
+
--night:#190B2E; /* base */
|
| 11 |
+
--night-2:#120821; /* deeper, for wells */
|
| 12 |
+
--stage:#241241; /* panels */
|
| 13 |
+
--stage-2:#2E1A52; /* raised */
|
| 14 |
+
--raise:#382060; /* inputs-on-dark */
|
| 15 |
+
--line:#3A2A5E; /* borders */
|
| 16 |
+
--line-soft:#2C1E4C;
|
| 17 |
+
|
| 18 |
+
/* leads + helper */
|
| 19 |
+
--hot:#FF3D8B; /* primary / on-air / energy */
|
| 20 |
+
--gold:#FFC94D; /* stardom: lights, crowns */
|
| 21 |
+
--cyan:#3DE7FF; /* confirm / scan */
|
| 22 |
+
--violet:#B98BFF; /* playful / mascot */
|
| 23 |
+
|
| 24 |
+
/* text */
|
| 25 |
+
--cream:#FFF3E9;
|
| 26 |
+
--muted:#A98FC9;
|
| 27 |
+
--muted-2:#7C66A6;
|
| 28 |
+
|
| 29 |
+
/* the casting rainbow — each star gets their own character color.
|
| 30 |
+
all high-chroma, all in-family: the "agent select" punch. */
|
| 31 |
+
--star-gold:#FFC94D;
|
| 32 |
+
--star-hot:#FF4D9A;
|
| 33 |
+
--star-cyan:#3DE7FF;
|
| 34 |
+
--star-violet:#C18BFF;
|
| 35 |
+
--star-lime:#9BE85B;
|
| 36 |
+
--star-tang:#FF8A4D;
|
| 37 |
+
|
| 38 |
+
--fx:cubic-bezier(.2,.9,.25,1);
|
| 39 |
+
--pop:cubic-bezier(.2,1.7,.4,1);
|
| 40 |
}
|
| 41 |
|
| 42 |
+
*{box-sizing:border-box;margin:0;padding:0}
|
| 43 |
+
html{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
|
| 44 |
+
|
| 45 |
+
body{
|
| 46 |
+
font-family:Nunito,system-ui,sans-serif;
|
| 47 |
+
color:var(--cream);
|
| 48 |
+
background:var(--night);
|
| 49 |
+
min-height:100vh;
|
| 50 |
+
line-height:1.5;
|
| 51 |
+
/* one justified light: a stage spotlight from above + a faint floor wash.
|
| 52 |
+
no decorative blob gradients. */
|
| 53 |
+
background-image:
|
| 54 |
+
radial-gradient(120% 70% at 50% -25%, rgba(255,201,77,.10), transparent 55%),
|
| 55 |
+
radial-gradient(140% 90% at 50% 120%, rgba(61,231,255,.05), transparent 60%);
|
| 56 |
+
background-attachment:fixed;
|
| 57 |
}
|
| 58 |
+
/* film grain — the only decorative texture, very subtle */
|
| 59 |
+
body::after{
|
| 60 |
+
content:"";position:fixed;inset:0;pointer-events:none;z-index:80;opacity:.04;
|
| 61 |
+
mix-blend-mode:overlay;
|
| 62 |
+
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
| 63 |
}
|
| 64 |
+
|
| 65 |
+
.disp{font-family:Unbounded,sans-serif}
|
| 66 |
+
.marq{font-family:'Bebas Neue',sans-serif;letter-spacing:.06em}
|
| 67 |
+
svg.i{width:1em;height:1em;display:block;flex:none}
|
| 68 |
+
|
| 69 |
+
.shell{max-width:1180px;margin:0 auto;padding:clamp(16px,3vw,34px) clamp(16px,4vw,40px) 40px;position:relative;z-index:1}
|
| 70 |
+
|
| 71 |
+
/* ============================================================
|
| 72 |
+
MASTHEAD — the broadcast header bar. signature lives here.
|
| 73 |
+
============================================================ */
|
| 74 |
+
.masthead{
|
| 75 |
+
display:flex;align-items:center;justify-content:space-between;gap:20px;
|
| 76 |
+
padding-bottom:22px;margin-bottom:6px;flex-wrap:wrap;
|
| 77 |
}
|
| 78 |
+
.brand{display:flex;align-items:center;gap:18px}
|
| 79 |
+
|
| 80 |
+
/* the lit marquee sign — spend the boldness here */
|
| 81 |
+
.sign{
|
| 82 |
+
position:relative;padding:14px 22px 15px;border-radius:14px;
|
| 83 |
+
border:2px solid rgba(255,201,77,.55);
|
| 84 |
+
background:
|
| 85 |
+
radial-gradient(120% 140% at 50% 0%, rgba(255,201,77,.14), transparent 70%),
|
| 86 |
+
linear-gradient(180deg, rgba(255,201,77,.05), rgba(25,11,46,.2));
|
| 87 |
+
box-shadow:inset 0 0 22px rgba(255,201,77,.16), 0 10px 40px rgba(255,61,139,.10);
|
| 88 |
}
|
| 89 |
+
.sign h1{
|
| 90 |
+
font-family:Unbounded,sans-serif;font-weight:800;font-size:clamp(26px,3.4vw,38px);
|
| 91 |
+
letter-spacing:.04em;line-height:.9;
|
| 92 |
+
color:var(--cream);
|
| 93 |
+
text-shadow:0 0 18px rgba(255,201,77,.45), 0 0 2px rgba(255,201,77,.8);
|
| 94 |
}
|
| 95 |
+
/* perimeter bulbs, top + bottom rows */
|
| 96 |
+
.sign .bulbs{position:absolute;left:14px;right:14px;display:flex;justify-content:space-between}
|
| 97 |
+
.sign .bulbs.t{top:-5px}.sign .bulbs.b{bottom:-5px}
|
| 98 |
+
.sign .bulbs i{width:6px;height:6px;border-radius:50%;background:var(--gold);
|
| 99 |
+
box-shadow:0 0 8px var(--gold),0 0 2px #fff;animation:tw 2.6s infinite}
|
| 100 |
+
.sign .bulbs i:nth-child(2n){animation-delay:.7s}
|
| 101 |
+
.sign .bulbs i:nth-child(3n){animation-delay:1.5s;background:var(--hot);box-shadow:0 0 8px var(--hot),0 0 2px #fff}
|
| 102 |
+
@keyframes tw{0%,100%{opacity:1}48%{opacity:.28}}
|
| 103 |
+
|
| 104 |
+
.tag{font-family:Unbounded,sans-serif;font-weight:500;font-size:13px;color:var(--gold);letter-spacing:.01em}
|
| 105 |
+
.tag small{display:block;font-family:Nunito;font-weight:600;color:var(--muted);font-size:12px;margin-top:3px;letter-spacing:0}
|
| 106 |
|
| 107 |
+
/* right side: live clock + status */
|
| 108 |
+
.broadcast-meta{display:flex;align-items:center;gap:14px}
|
| 109 |
+
.clock{font-family:'Bebas Neue',sans-serif;letter-spacing:.12em;font-size:20px;color:var(--muted);
|
| 110 |
+
font-variant-numeric:tabular-nums}
|
| 111 |
+
.status{display:flex;align-items:center;gap:9px;white-space:nowrap;font-family:'Bebas Neue',sans-serif;letter-spacing:.14em;
|
| 112 |
+
font-size:16px;color:var(--muted);border:1px solid var(--line);padding:8px 15px;border-radius:999px;
|
| 113 |
+
background:rgba(46,26,82,.5)}
|
| 114 |
+
.status .led{width:9px;height:9px;border-radius:50%;background:var(--muted-2);transition:.3s}
|
| 115 |
+
.status.live{color:var(--hot);border-color:rgba(255,61,139,.5);background:rgba(255,61,139,.08)}
|
| 116 |
+
.status.live .led{background:var(--hot);box-shadow:0 0 12px var(--hot);animation:tw 1.1s infinite}
|
| 117 |
+
|
| 118 |
+
/* ============================================================
|
| 119 |
+
RUNDOWN — broadcast running order, not 4 identical cards.
|
| 120 |
+
nodes on a connecting track.
|
| 121 |
+
============================================================ */
|
| 122 |
+
.rundown{position:relative;display:grid;grid-template-columns:repeat(4,1fr);
|
| 123 |
+
margin:8px 0 30px;padding:0 6px}
|
| 124 |
+
.rundown::before{content:"";position:absolute;top:19px;left:11%;right:11%;height:2px;
|
| 125 |
+
background:var(--line-soft)}
|
| 126 |
+
.cue{position:relative;display:flex;flex-direction:column;align-items:center;gap:9px;text-align:center}
|
| 127 |
+
.cue .node{width:40px;height:40px;border-radius:50%;display:grid;place-items:center;
|
| 128 |
+
background:var(--stage);border:2px solid var(--line);
|
| 129 |
+
font-family:'Bebas Neue',sans-serif;font-size:20px;color:var(--muted-2);
|
| 130 |
+
position:relative;z-index:1;transition:.35s var(--fx)}
|
| 131 |
+
.cue .ttl{font-family:Unbounded,sans-serif;font-weight:600;font-size:13px;color:var(--muted)}
|
| 132 |
+
.cue .sub{font-size:11px;color:var(--muted-2);letter-spacing:.01em}
|
| 133 |
+
/* done */
|
| 134 |
+
.cue.done .node{border-color:var(--cyan);color:var(--cyan);background:rgba(61,231,255,.08)}
|
| 135 |
+
.cue.done .ttl{color:var(--cream)}
|
| 136 |
+
/* active */
|
| 137 |
+
.cue.on .node{border-color:var(--gold);color:var(--gold);background:rgba(255,201,77,.12);
|
| 138 |
+
box-shadow:0 0 0 5px rgba(255,201,77,.1),0 0 22px rgba(255,201,77,.3)}
|
| 139 |
+
.cue.on .ttl{color:var(--gold)}
|
| 140 |
+
@media(prefers-reduced-motion:no-preference){
|
| 141 |
+
.cue.on .node{animation:cuePulse 2s infinite}
|
| 142 |
}
|
| 143 |
+
@keyframes cuePulse{0%,100%{box-shadow:0 0 0 5px rgba(255,201,77,.1),0 0 22px rgba(255,201,77,.28)}
|
| 144 |
+
50%{box-shadow:0 0 0 8px rgba(255,201,77,.04),0 0 30px rgba(255,201,77,.45)}}
|
| 145 |
+
|
| 146 |
+
/* ============================================================
|
| 147 |
+
LAYOUT — control desk (left) vs the stage (right).
|
| 148 |
+
intentionally asymmetric; the two halves are treated differently.
|
| 149 |
+
============================================================ */
|
| 150 |
+
.console{display:grid;grid-template-columns:362px 1fr;gap:20px;align-items:start}
|
| 151 |
+
@media(max-width:900px){.console{grid-template-columns:1fr}}
|
| 152 |
+
|
| 153 |
+
/* the desk: flat, utilitarian — it's the control surface, not a showpiece */
|
| 154 |
+
.desk{display:flex;flex-direction:column;gap:16px}
|
| 155 |
+
.card{background:var(--stage);border:1px solid var(--line-soft);border-radius:16px;padding:18px}
|
| 156 |
+
.card-h{display:flex;align-items:center;gap:10px;margin-bottom:14px}
|
| 157 |
+
.card-h .ic{color:var(--gold);font-size:18px}
|
| 158 |
+
.card-h h2{font-family:Unbounded,sans-serif;font-weight:600;font-size:14px;letter-spacing:.01em}
|
| 159 |
+
.card-h .meta{margin-left:auto;font-family:'Bebas Neue',sans-serif;letter-spacing:.07em;font-size:12px;color:var(--muted-2)}
|
| 160 |
+
|
| 161 |
+
/* roll tape drop zone — clapperboard motif */
|
| 162 |
+
.drop{position:relative;border:1.5px dashed var(--line);border-radius:13px;padding:26px 16px;
|
| 163 |
+
text-align:center;cursor:pointer;transition:.22s var(--fx);overflow:hidden;
|
| 164 |
+
background:radial-gradient(200px 110px at 50% -20%,rgba(255,201,77,.07),transparent 70%)}
|
| 165 |
+
.drop:hover{border-color:var(--gold);background:radial-gradient(200px 130px at 50% -10%,rgba(255,201,77,.12),transparent 70%)}
|
| 166 |
+
.drop:focus-visible{border-color:var(--cyan)}
|
| 167 |
+
.drop .clap{font-size:30px;color:var(--gold);display:inline-flex;margin-bottom:8px}
|
| 168 |
+
.drop .t{font-family:Unbounded,sans-serif;font-weight:600;font-size:15px}
|
| 169 |
+
.drop .s{font-size:12px;color:var(--muted);margin-top:4px}
|
| 170 |
+
/* loaded: a little filmstrip readout */
|
| 171 |
+
.drop .loaded{display:none;align-items:center;gap:12px;text-align:left}
|
| 172 |
+
.drop.loaded{border-style:solid;border-color:rgba(61,231,255,.45);background:rgba(61,231,255,.05);cursor:default}
|
| 173 |
+
.drop.loaded .ask{display:none}
|
| 174 |
+
.drop.loaded .loaded{display:flex}
|
| 175 |
+
.strip{width:52px;height:52px;border-radius:9px;flex:none;position:relative;overflow:hidden;
|
| 176 |
+
background:linear-gradient(135deg,var(--raise),var(--stage));border:1px solid var(--line)}
|
| 177 |
+
.strip::before,.strip::after{content:"";position:absolute;top:4px;bottom:4px;width:5px;
|
| 178 |
+
background:repeating-linear-gradient(var(--night) 0 3px,transparent 3px 7px)}
|
| 179 |
+
.strip::before{left:3px}.strip::after{right:3px}
|
| 180 |
+
.strip .play{position:absolute;inset:0;display:grid;place-items:center;color:var(--cyan);font-size:16px}
|
| 181 |
+
.loaded .fn{font-family:Unbounded,sans-serif;font-weight:600;font-size:13px;color:var(--cream)}
|
| 182 |
+
.loaded .fd{font-family:'Bebas Neue',sans-serif;letter-spacing:.05em;font-size:13px;color:var(--cyan);margin-top:3px}
|
| 183 |
+
|
| 184 |
+
/* buttons */
|
| 185 |
+
.btn{width:100%;border:0;cursor:pointer;font-family:Unbounded,sans-serif;font-weight:600;font-size:14px;
|
| 186 |
+
color:var(--night);padding:14px;border-radius:12px;display:flex;align-items:center;justify-content:center;
|
| 187 |
+
gap:9px;transition:.18s var(--fx)}
|
| 188 |
+
.btn:disabled{opacity:.4;cursor:not-allowed;background:var(--raise);color:var(--muted-2)}
|
| 189 |
+
.btn.cyan{background:linear-gradient(135deg,var(--cyan),#16b6d8)}
|
| 190 |
+
.btn.cyan:not(:disabled):hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(61,231,255,.3)}
|
| 191 |
+
.btn.show{font-size:17px;padding:17px;background:linear-gradient(110deg,var(--gold),var(--hot));position:relative;overflow:hidden}
|
| 192 |
+
.btn.show:not(:disabled):hover{transform:translateY(-2px);box-shadow:0 16px 38px rgba(255,61,139,.4)}
|
| 193 |
+
.btn.show:not(:disabled)::after{content:"";position:absolute;top:0;bottom:0;width:40%;
|
| 194 |
+
background:linear-gradient(90deg,transparent,rgba(255,255,255,.35),transparent);
|
| 195 |
+
transform:translateX(-200%)}
|
| 196 |
+
@media(prefers-reduced-motion:no-preference){
|
| 197 |
+
.btn.show:not(:disabled)::after{animation:shine 3.4s ease-in-out 1.2s infinite}
|
| 198 |
}
|
| 199 |
+
@keyframes shine{0%,100%{transform:translateX(-220%)}40%,60%{transform:translateX(360%)}}
|
| 200 |
+
.mt{margin-top:14px}
|
| 201 |
+
|
| 202 |
+
/* casting sweep bar */
|
| 203 |
+
.sweep{height:6px;border-radius:4px;margin-top:13px;background:var(--night-2);position:relative;overflow:hidden;
|
| 204 |
+
opacity:0;height:0;margin-top:0;transition:.25s}
|
| 205 |
+
.casting .sweep{opacity:1;height:6px;margin-top:13px}
|
| 206 |
+
.sweep::after{content:"";position:absolute;inset:0;width:36%;
|
| 207 |
+
background:linear-gradient(90deg,transparent,var(--gold),transparent)}
|
| 208 |
+
@media(prefers-reduced-motion:no-preference){.sweep::after{animation:sw 1s linear infinite}}
|
| 209 |
+
@keyframes sw{from{transform:translateX(-120%)}to{transform:translateX(330%)}}
|
| 210 |
+
|
| 211 |
+
/* ============================================================
|
| 212 |
+
THE STAGE — Star Select. theatrical, spotlit well.
|
| 213 |
+
============================================================ */
|
| 214 |
+
.stage-panel{background:
|
| 215 |
+
radial-gradient(80% 60% at 50% -8%, rgba(255,201,77,.07), transparent 60%),
|
| 216 |
+
linear-gradient(180deg,var(--stage),var(--night-2));
|
| 217 |
+
border:1px solid var(--line);border-radius:20px;padding:20px;
|
| 218 |
+
box-shadow:inset 0 1px 0 rgba(255,243,233,.04), 0 24px 60px rgba(8,2,20,.5)}
|
| 219 |
+
.stage-h{display:flex;align-items:center;gap:11px;margin-bottom:16px}
|
| 220 |
+
.stage-h .ic{color:var(--gold);font-size:19px}
|
| 221 |
+
.stage-h h2{font-family:Unbounded,sans-serif;font-weight:700;font-size:16px}
|
| 222 |
+
.stage-h .count{margin-left:auto;white-space:nowrap;font-family:'Bebas Neue',sans-serif;letter-spacing:.08em;font-size:14px;
|
| 223 |
+
color:var(--muted);background:rgba(46,26,82,.6);padding:4px 11px;border-radius:8px}
|
| 224 |
+
|
| 225 |
+
/* empty state */
|
| 226 |
+
.empty{display:grid;place-items:center;min-height:330px;text-align:center;padding:20px}
|
| 227 |
+
.empty .spot{width:96px;height:96px;border-radius:50%;display:grid;place-items:center;margin-bottom:16px;
|
| 228 |
+
color:var(--muted-2);font-size:46px;
|
| 229 |
+
background:radial-gradient(circle at 50% 30%,rgba(255,201,77,.08),transparent 70%)}
|
| 230 |
+
.empty .t{font-family:Unbounded,sans-serif;font-weight:600;font-size:17px;color:var(--cream);margin-bottom:6px}
|
| 231 |
+
.empty .s{font-size:13px;color:var(--muted);max-width:300px}
|
| 232 |
+
|
| 233 |
+
/* star grid */
|
| 234 |
+
.roster{display:grid;grid-template-columns:repeat(auto-fill,minmax(164px,1fr));gap:16px}
|
| 235 |
+
|
| 236 |
+
/* ---- the star card (one reusable block) ----
|
| 237 |
+
per-card character color is set inline as --c. */
|
| 238 |
+
/* base state is fully visible — entrance is driven via WAAPI in JS so a
|
| 239 |
+
backgrounded tab never leaves cards stuck invisible. */
|
| 240 |
+
.star{--c:var(--gold);position:relative;border-radius:16px;overflow:hidden;
|
| 241 |
+
background:linear-gradient(180deg,var(--stage-2),var(--stage));
|
| 242 |
+
border:1px solid var(--line);transition:.25s var(--fx)}
|
| 243 |
+
.star:hover{transform:translateY(-3px);border-color:color-mix(in srgb,var(--c) 60%,transparent)}
|
| 244 |
+
|
| 245 |
+
/* portrait well with a spotlight beam tinted to the character color,
|
| 246 |
+
and an angular framed headshot placeholder (agent-select energy) */
|
| 247 |
+
.portrait{position:relative;height:138px;overflow:hidden;
|
| 248 |
+
background:radial-gradient(90% 80% at 50% 120%,color-mix(in srgb,var(--c) 22%,var(--night-2)),var(--night-2))}
|
| 249 |
+
.portrait .beam{position:absolute;top:-30px;left:50%;width:150px;height:170px;transform:translateX(-50%);
|
| 250 |
+
pointer-events:none;clip-path:polygon(40% 0,60% 0,100% 100%,0 100%);
|
| 251 |
+
background:linear-gradient(180deg,color-mix(in srgb,var(--c) 55%,transparent),transparent 70%);opacity:.7}
|
| 252 |
+
.portrait .head-ph{position:absolute;left:50%;bottom:-6px;transform:translateX(-50%);
|
| 253 |
+
color:color-mix(in srgb,var(--c) 50%,#ffffff);opacity:.4}
|
| 254 |
+
.portrait .frame{position:absolute;inset:8px;border:1.5px solid color-mix(in srgb,var(--c) 35%,transparent);
|
| 255 |
+
border-radius:11px;pointer-events:none}
|
| 256 |
+
.portrait .idx{position:absolute;top:10px;left:10px;font-family:'Bebas Neue',sans-serif;font-size:14px;
|
| 257 |
+
color:var(--cream);background:rgba(15,6,30,.7);padding:2px 7px;border-radius:7px;display:flex;align-items:center;gap:3px}
|
| 258 |
+
.portrait .idx .st{color:var(--c)}
|
| 259 |
+
.tier{position:absolute;top:10px;right:10px;font-family:'Bebas Neue',sans-serif;font-size:11px;letter-spacing:.02em;
|
| 260 |
+
white-space:nowrap;color:var(--night);background:var(--c);padding:3px 8px;border-radius:7px;
|
| 261 |
+
box-shadow:0 2px 10px color-mix(in srgb,var(--c) 50%,transparent)}
|
| 262 |
+
|
| 263 |
+
/* crown drops in on naming */
|
| 264 |
+
/* crown lifts in above the star's head on naming */
|
| 265 |
+
.crown{position:absolute;top:50px;left:50%;transform:translateX(-50%) translateY(12px) scale(.4) rotate(-12deg);
|
| 266 |
+
opacity:0;width:30px;height:30px;color:var(--gold);
|
| 267 |
+
filter:drop-shadow(0 2px 6px color-mix(in srgb,var(--gold) 70%,transparent));
|
| 268 |
+
transition:.4s var(--pop)}
|
| 269 |
+
.star.named .crown{opacity:1;transform:translateX(-50%) scale(1) rotate(0)}
|
| 270 |
+
|
| 271 |
+
.star-body{padding:13px}
|
| 272 |
+
.star-body label{font-family:'Bebas Neue',sans-serif;letter-spacing:.08em;font-size:12px;color:var(--muted);
|
| 273 |
+
display:block;margin-bottom:7px}
|
| 274 |
+
.star input{width:100%;background:var(--night-2);border:1px solid var(--line);color:var(--cream);
|
| 275 |
+
border-radius:10px;padding:10px 12px;font-family:Nunito;font-weight:800;font-size:14px;outline:none;transition:.16s}
|
| 276 |
+
.star input::placeholder{color:var(--muted-2);font-weight:600}
|
| 277 |
+
.star input:focus{border-color:var(--c);box-shadow:0 0 0 3px color-mix(in srgb,var(--c) 25%,transparent)}
|
| 278 |
+
|
| 279 |
+
/* crowned: card lights in its own character color */
|
| 280 |
+
.star.named{border-color:color-mix(in srgb,var(--c) 65%,transparent);
|
| 281 |
+
box-shadow:0 0 0 1px color-mix(in srgb,var(--c) 30%,transparent),0 10px 30px color-mix(in srgb,var(--c) 18%,transparent)}
|
| 282 |
+
.star.named .portrait{background:radial-gradient(90% 90% at 50% 120%,color-mix(in srgb,var(--c) 40%,var(--night-2)),var(--night-2))}
|
| 283 |
+
.star.named .portrait .beam{opacity:1}
|
| 284 |
+
.star.named .head-ph{opacity:.6}
|
| 285 |
+
|
| 286 |
+
/* ============================================================
|
| 287 |
+
VIBES (narration modes) + COCO host
|
| 288 |
+
============================================================ */
|
| 289 |
+
.vibes{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
|
| 290 |
+
.vibe{display:flex;align-items:center;gap:11px;padding:12px;border-radius:13px;cursor:pointer;color:var(--cream);
|
| 291 |
+
border:1px solid var(--line-soft);background:var(--stage-2);transition:.18s var(--fx);text-align:left}
|
| 292 |
+
.vibe:hover{border-color:var(--violet);transform:translateY(-1px)}
|
| 293 |
+
.vibe .ic{font-size:20px;color:var(--muted);display:inline-flex;transition:.18s}
|
| 294 |
+
.vibe>span:last-child{display:flex;flex-direction:column;gap:2px;min-width:0}
|
| 295 |
+
.vibe .vt{display:block;font-family:Unbounded,sans-serif;font-weight:600;font-size:13px;color:var(--cream)}
|
| 296 |
+
.vibe .vd{display:block;font-size:11px;color:var(--muted);line-height:1.25}
|
| 297 |
+
.vibe[aria-pressed="true"]{border-color:var(--vc,var(--hot));background:color-mix(in srgb,var(--vc,var(--hot)) 12%,var(--stage-2))}
|
| 298 |
+
.vibe[aria-pressed="true"] .ic{color:var(--vc,var(--hot))}
|
| 299 |
+
|
| 300 |
+
/* Coco — the host bug. re-dresses per vibe. */
|
| 301 |
+
.coco{display:flex;align-items:center;gap:13px;margin-top:14px;padding:13px 14px;border-radius:14px;
|
| 302 |
+
background:linear-gradient(120deg,color-mix(in srgb,var(--vc,var(--violet)) 12%,var(--stage-2)),var(--stage-2));
|
| 303 |
+
border:1px solid color-mix(in srgb,var(--vc,var(--violet)) 30%,var(--line-soft));transition:.35s var(--fx)}
|
| 304 |
+
.coco .av{position:relative;width:46px;height:46px;border-radius:13px;flex:none;display:grid;place-items:center;
|
| 305 |
+
background:radial-gradient(circle at 50% 30%,color-mix(in srgb,var(--vc,var(--violet)) 70%,#fff 5%),var(--vc,var(--violet)));
|
| 306 |
+
box-shadow:0 6px 18px color-mix(in srgb,var(--vc,var(--violet)) 40%,transparent);transition:.35s var(--fx)}
|
| 307 |
+
.coco .av .bird{width:30px;height:30px;color:var(--night);filter:drop-shadow(0 1px 1px rgba(255,255,255,.3))}
|
| 308 |
+
/* accessory badge sits on the avatar, swapped per vibe */
|
| 309 |
+
.coco .acc{position:absolute;right:-6px;bottom:-6px;width:22px;height:22px;border-radius:50%;
|
| 310 |
+
display:grid;place-items:center;background:var(--night);border:2px solid var(--vc,var(--violet));
|
| 311 |
+
color:var(--vc,var(--violet));font-size:11px}
|
| 312 |
+
.coco .acc svg{width:12px;height:12px}
|
| 313 |
+
.coco .say{font-size:13px;line-height:1.45}
|
| 314 |
+
.coco .say b{color:var(--vc,var(--gold))}
|
| 315 |
+
.coco .who{font-family:'Bebas Neue',sans-serif;letter-spacing:.1em;font-size:11px;color:var(--vc,var(--violet));
|
| 316 |
+
display:block;margin-bottom:2px}
|
| 317 |
+
|
| 318 |
+
/* privacy footnote — visible, not buried */
|
| 319 |
+
.note{margin-top:26px;text-align:center;font-size:12.5px;color:var(--muted);line-height:1.6}
|
| 320 |
+
.note b{color:var(--cream)}
|
| 321 |
+
.note .lock{color:var(--cyan);vertical-align:-2px;margin-right:5px}
|
| 322 |
+
|
| 323 |
+
/* ============================================================
|
| 324 |
+
VIEW SWITCH
|
| 325 |
+
============================================================ */
|
| 326 |
+
.view{display:none}
|
| 327 |
+
.view.active{display:block;animation:viewIn .4s var(--fx)}
|
| 328 |
+
@keyframes viewIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
| 329 |
+
|
| 330 |
+
/* ============================================================
|
| 331 |
+
BROADCAST PLAYER
|
| 332 |
+
============================================================ */
|
| 333 |
+
.booth-top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px;flex-wrap:wrap}
|
| 334 |
+
.back{display:inline-flex;align-items:center;gap:8px;background:var(--stage);border:1px solid var(--line);
|
| 335 |
+
color:var(--cream);font-family:Nunito;font-weight:700;font-size:13px;padding:9px 14px;border-radius:10px;cursor:pointer;transition:.16s}
|
| 336 |
+
.back:hover{border-color:var(--violet)}
|
| 337 |
+
.orient{display:flex;gap:4px;background:var(--stage);border:1px solid var(--line-soft);padding:4px;border-radius:11px}
|
| 338 |
+
.orient button{background:transparent;border:0;color:var(--muted);font-family:'Bebas Neue',sans-serif;letter-spacing:.06em;
|
| 339 |
+
font-size:13px;padding:7px 14px;border-radius:8px;cursor:pointer;transition:.16s}
|
| 340 |
+
.orient button[aria-pressed="true"]{background:var(--raise);color:var(--gold)}
|
| 341 |
+
|
| 342 |
+
.booth{display:grid;grid-template-columns:1fr 320px;gap:18px;align-items:start}
|
| 343 |
+
/* portrait: the clip is a phone-sized vertical frame, never a giant column-filling block.
|
| 344 |
+
capped width keeps the 9:16 height sane and the play-by-play rail beside it. */
|
| 345 |
+
.booth.portrait{grid-template-columns:minmax(0,340px) minmax(0,1fr)}
|
| 346 |
+
.booth.portrait .screen-wrap{max-width:340px}
|
| 347 |
+
@media(max-width:840px){
|
| 348 |
+
.booth,.booth.square{grid-template-columns:1fr}
|
| 349 |
+
.booth.portrait{grid-template-columns:1fr}
|
| 350 |
+
.booth.portrait .screen-wrap{margin-inline:auto}
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
/* the screen */
|
| 354 |
+
.screen-wrap{background:#000;border-radius:18px;overflow:hidden;border:1px solid var(--line);
|
| 355 |
+
box-shadow:0 24px 70px rgba(0,0,0,.6);position:relative}
|
| 356 |
+
.screen{position:relative;width:100%;aspect-ratio:16/9;background:
|
| 357 |
+
repeating-linear-gradient(135deg,#1c0f33 0 18px,#170c2b 18px 36px);
|
| 358 |
+
display:grid;place-items:center}
|
| 359 |
+
.booth.portrait .screen{aspect-ratio:9/16}
|
| 360 |
+
.booth.square .screen{aspect-ratio:1/1}
|
| 361 |
+
.screen .ph{font-family:'Bebas Neue',sans-serif;letter-spacing:.12em;color:var(--muted-2);font-size:15px;
|
| 362 |
+
text-align:center;padding:0 20px}
|
| 363 |
+
.screen .ph svg{width:40px;height:40px;margin:0 auto 10px;color:var(--line);display:block}
|
| 364 |
+
|
| 365 |
+
/* on-air bug */
|
| 366 |
+
.bug{position:absolute;top:14px;left:14px;display:flex;align-items:center;gap:8px;white-space:nowrap;
|
| 367 |
+
background:rgba(15,6,30,.6);backdrop-filter:blur(4px);padding:7px 12px;border-radius:9px;
|
| 368 |
+
font-family:'Bebas Neue',sans-serif;letter-spacing:.12em;font-size:14px;color:var(--cream)}
|
| 369 |
+
.bug .dot{width:10px;height:10px;border-radius:50%;background:var(--hot);box-shadow:0 0 10px var(--hot)}
|
| 370 |
+
@media(prefers-reduced-motion:no-preference){.bug .dot{animation:tw 1.1s infinite}}
|
| 371 |
+
|
| 372 |
+
/* timecode */
|
| 373 |
+
.tc{position:absolute;top:14px;right:14px;background:rgba(15,6,30,.6);backdrop-filter:blur(4px);
|
| 374 |
+
padding:7px 12px;border-radius:9px;font-family:'Bebas Neue',sans-serif;letter-spacing:.1em;font-size:15px;
|
| 375 |
+
color:var(--cyan);font-variant-numeric:tabular-nums}
|
| 376 |
+
|
| 377 |
+
/* lower-third chyron */
|
| 378 |
+
.chyron{position:absolute;left:14px;right:14px;bottom:14px;border-radius:12px;overflow:hidden;
|
| 379 |
+
background:linear-gradient(100deg,rgba(15,6,30,.92),rgba(36,18,65,.82));
|
| 380 |
+
border:1px solid color-mix(in srgb,var(--cc,var(--hot)) 40%,var(--line));
|
| 381 |
+
box-shadow:0 10px 30px rgba(0,0,0,.4);
|
| 382 |
+
transform:translateY(130%);opacity:0;transition:.45s var(--fx)}
|
| 383 |
+
.chyron.in{transform:none;opacity:1}
|
| 384 |
+
.chyron .bar{height:4px;background:linear-gradient(90deg,var(--cc,var(--hot)),var(--gold))}
|
| 385 |
+
.chyron .row{display:flex;align-items:stretch}
|
| 386 |
+
.chyron .who{display:flex;align-items:center;font-family:'Bebas Neue',sans-serif;letter-spacing:.06em;
|
| 387 |
+
font-size:17px;color:var(--night);background:var(--cc,var(--hot));padding:10px 14px;white-space:nowrap}
|
| 388 |
+
.chyron .line{padding:10px 16px;font-family:Nunito;font-weight:700;font-size:15px;color:var(--cream);
|
| 389 |
+
display:flex;align-items:center;line-height:1.3}
|
| 390 |
+
@media(prefers-reduced-motion:no-preference){
|
| 391 |
+
.chyron.in .line{animation:sharpen .5s var(--fx)}
|
| 392 |
}
|
| 393 |
+
@keyframes sharpen{from{filter:blur(7px);opacity:.3}to{filter:blur(0);opacity:1}}
|
| 394 |
+
|
| 395 |
+
/* transport */
|
| 396 |
+
.transport{display:flex;align-items:center;gap:14px;margin-top:14px}
|
| 397 |
+
.transport .pp{width:48px;height:48px;border-radius:50%;border:0;cursor:pointer;flex:none;display:grid;place-items:center;
|
| 398 |
+
background:linear-gradient(135deg,var(--gold),var(--hot));color:var(--night);font-size:20px;transition:.16s}
|
| 399 |
+
.transport .pp:hover{transform:scale(1.05)}
|
| 400 |
+
.scrub{flex:1;height:6px;border-radius:4px;background:var(--raise);position:relative;cursor:pointer}
|
| 401 |
+
.scrub .fill{position:absolute;left:0;top:0;bottom:0;border-radius:4px;width:0;
|
| 402 |
+
background:linear-gradient(90deg,var(--hot),var(--gold))}
|
| 403 |
+
.scrub .knob{position:absolute;top:50%;width:14px;height:14px;border-radius:50%;background:var(--cream);
|
| 404 |
+
transform:translate(-50%,-50%);left:0;box-shadow:0 2px 6px rgba(0,0,0,.5)}
|
| 405 |
+
|
| 406 |
+
/* play-by-play rail */
|
| 407 |
+
.pbp{background:var(--stage);border:1px solid var(--line-soft);border-radius:16px;overflow:hidden;
|
| 408 |
+
display:flex;flex-direction:column;max-height:520px}
|
| 409 |
+
.pbp .pbp-h{display:flex;align-items:center;gap:9px;padding:14px 16px;border-bottom:1px solid var(--line-soft)}
|
| 410 |
+
.pbp .pbp-h .ic{color:var(--gold);font-size:16px}
|
| 411 |
+
.pbp .pbp-h h3{font-family:Unbounded,sans-serif;font-weight:600;font-size:13px}
|
| 412 |
+
.pbp .pbp-h .ct{margin-left:auto;font-family:'Bebas Neue',sans-serif;letter-spacing:.06em;font-size:12px;color:var(--muted-2)}
|
| 413 |
+
.pbp .lines{overflow-y:auto;padding:6px}
|
| 414 |
+
.pbp .pl{display:flex;gap:11px;padding:11px 12px;border-radius:10px;cursor:pointer;transition:.15s;align-items:baseline}
|
| 415 |
+
.pbp .pl:hover{background:var(--stage-2)}
|
| 416 |
+
.pbp .pl .t{font-family:'Bebas Neue',sans-serif;letter-spacing:.04em;font-size:14px;color:var(--muted-2);
|
| 417 |
+
flex:none;width:42px;font-variant-numeric:tabular-nums;padding-top:1px}
|
| 418 |
+
.pbp .pl .tx{font-size:13px;line-height:1.45;color:var(--muted)}
|
| 419 |
+
.pbp .pl .tx b{color:var(--cream);font-weight:800}
|
| 420 |
+
.pbp .pl.now{background:color-mix(in srgb,var(--cc,var(--hot)) 14%,var(--stage-2))}
|
| 421 |
+
.pbp .pl.now .t{color:var(--cc,var(--hot))}
|
| 422 |
+
.pbp .pl.now .tx{color:var(--cream)}
|
| 423 |
+
.pbp .pl.now .tx b{color:var(--cc,var(--hot))}
|
| 424 |
+
|
| 425 |
+
/* export bar */
|
| 426 |
+
.export{padding:13px 16px;border-top:1px solid var(--line-soft);background:var(--night-2)}
|
| 427 |
+
.export .btn-srt{width:100%;display:flex;align-items:center;justify-content:center;gap:9px;cursor:pointer;
|
| 428 |
+
background:var(--stage-2);border:1px solid var(--line);color:var(--cream);font-family:Nunito;font-weight:700;
|
| 429 |
+
font-size:13px;padding:12px;border-radius:11px;transition:.16s}
|
| 430 |
+
.export .btn-srt:hover{border-color:var(--cyan);color:var(--cyan)}
|
| 431 |
+
.export .btn-srt .ic{color:var(--cyan);font-size:16px}
|
| 432 |
+
.export .hint{text-align:center;font-size:11px;color:var(--muted-2);margin-top:8px}
|
| 433 |
+
.export .hint b{color:var(--muted)}
|
| 434 |
+
|
| 435 |
+
/* confetti */
|
| 436 |
+
.confetti{position:fixed;top:-20px;width:11px;height:14px;z-index:90;pointer-events:none;border-radius:2px}
|
| 437 |
+
@media(prefers-reduced-motion:no-preference){.confetti{animation:fall linear forwards}}
|
| 438 |
+
@keyframes fall{to{transform:translateY(105vh) rotate(540deg);opacity:.2}}
|
| 439 |
+
|
| 440 |
+
/* ============================================================
|
| 441 |
+
UTILITIES
|
| 442 |
+
============================================================ */
|
| 443 |
+
:focus-visible{outline:2px solid var(--cyan);outline-offset:3px;border-radius:8px}
|
| 444 |
+
.toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%) translateY(20px);opacity:0;
|
| 445 |
+
background:var(--stage-2);border:1px solid var(--cyan);color:var(--cream);padding:12px 20px;border-radius:12px;
|
| 446 |
+
font-weight:700;font-size:13px;z-index:95;transition:.3s var(--fx);display:flex;align-items:center;gap:9px;
|
| 447 |
+
box-shadow:0 14px 40px rgba(0,0,0,.5)}
|
| 448 |
+
.toast.show{transform:translateX(-50%);opacity:1}
|
| 449 |
+
.toast .ic{color:var(--cyan)}
|
| 450 |
+
|
| 451 |
+
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
| 452 |
+
|
| 453 |
+
@media(max-width:560px){
|
| 454 |
+
.masthead{gap:14px}
|
| 455 |
+
.rundown .sub{display:none}
|
| 456 |
+
.vibes{grid-template-columns:1fr}
|
| 457 |
+
.roster{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}
|
| 458 |
}
|
| 459 |
+
|
| 460 |
+
/* ============================================================
|
| 461 |
+
INTEGRATION ADDITIONS — real <video> in the screen, real face
|
| 462 |
+
crops in star cards (added for the Gradio wiring)
|
| 463 |
+
============================================================ */
|
| 464 |
+
.screen-vid{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;display:none}
|
| 465 |
+
.screen.has-video .ph{display:none}
|
| 466 |
+
.screen.has-video .screen-vid{display:block}
|
| 467 |
+
.portrait .head-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
| 468 |
+
.star.named .head-img{filter:saturate(1.05)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
marquee.js
CHANGED
|
@@ -1,636 +1,185 @@
|
|
| 1 |
/* ============================================================
|
| 2 |
-
MARQUEE
|
| 3 |
-
|
|
|
|
| 4 |
============================================================ */
|
| 5 |
-
|
| 6 |
-
// Local state — declared first so the dynamic import below can reference it
|
| 7 |
-
let isOfflineMode = false;
|
| 8 |
-
|
| 9 |
-
// Dynamic import so landing screen works even if Gradio CDN fails to load
|
| 10 |
-
let Client = null;
|
| 11 |
-
try {
|
| 12 |
-
const gradioModule = await import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js");
|
| 13 |
-
Client = gradioModule.Client;
|
| 14 |
-
} catch (importErr) {
|
| 15 |
-
console.warn('[marquee] Gradio client failed to load — running in offline/demo mode.', importErr);
|
| 16 |
-
isOfflineMode = true;
|
| 17 |
-
}
|
| 18 |
|
| 19 |
const $ = (s, r = document) => r.querySelector(s);
|
| 20 |
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
|
| 21 |
const reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;
|
| 22 |
const pad = n => String(n).padStart(2, '0');
|
| 23 |
-
let currentVibe = 'football';
|
| 24 |
-
let currentVoiceModel = 'chatterbox';
|
| 25 |
-
let SESSION = null, VIDEO_URL = null, DUR = 0, REAL_SCRIPT = [], selectedFile = null;
|
| 26 |
-
let TTS_LINES = [];
|
| 27 |
-
let stars = [];
|
| 28 |
-
|
| 29 |
-
/* ============================================================
|
| 30 |
-
THEME LANGUAGE PARAMETERS (Per Vibe)
|
| 31 |
-
============================================================ */
|
| 32 |
-
const THEME_COPY = {
|
| 33 |
-
football: {
|
| 34 |
-
mastheadTag: "Everyone's a headliner.",
|
| 35 |
-
mastheadSub: "Turn any clip into a live broadcast.",
|
| 36 |
-
step1Ttl: "Roll Tape", step1Sub: "Drop a clip",
|
| 37 |
-
step2Ttl: "Scouting", step2Sub: "Find the players",
|
| 38 |
-
step3Ttl: "Squad", step3Sub: "Name them up",
|
| 39 |
-
step4Ttl: "Kick Off", step4Sub: "Go live",
|
| 40 |
-
rollCardTitle: "Roll the Footage",
|
| 41 |
-
dropTitle: "Drop your clip on the pitch",
|
| 42 |
-
castBtnLabel: "Scout the pitch",
|
| 43 |
-
liveBtnLabel: "Kick off | go live",
|
| 44 |
-
stageTitle: "Squad Select",
|
| 45 |
-
stageEmptyTitle:"No players spotted",
|
| 46 |
-
stageEmptySub: "Roll a clip and hit <b>Scout the pitch</b> | every face gets a squad card.",
|
| 47 |
-
backBtnLabel: "Back to dugout",
|
| 48 |
-
pbpTitle: "Match commentary",
|
| 49 |
-
noteText: "Your clip stays on <b>your device</b>. Player detection runs locally | Marquee only writes the captions.",
|
| 50 |
-
},
|
| 51 |
-
diva: {
|
| 52 |
-
mastheadTag: "Everyone's a headliner.",
|
| 53 |
-
mastheadSub: "Your private red carpet, darling.",
|
| 54 |
-
step1Ttl: "The Footage", step1Sub: "Drop your clip",
|
| 55 |
-
step2Ttl: "Spotting", step2Sub: "Who's arriving",
|
| 56 |
-
step3Ttl: "Guest List", step3Sub: "Name them, darling",
|
| 57 |
-
step4Ttl: "Showtime", step4Sub: "Walk the carpet",
|
| 58 |
-
rollCardTitle: "The Footage",
|
| 59 |
-
dropTitle: "Drop the clip, darling",
|
| 60 |
-
castBtnLabel: "Who's arriving?",
|
| 61 |
-
liveBtnLabel: "Walk the carpet | go live",
|
| 62 |
-
stageTitle: "Tonight's Guests",
|
| 63 |
-
stageEmptyTitle:"The carpet is empty",
|
| 64 |
-
stageEmptySub: "Roll a clip and hit <b>Who's arriving?</b> | every face gets a star card.",
|
| 65 |
-
backBtnLabel: "Back to the green room",
|
| 66 |
-
pbpTitle: "Red carpet commentary",
|
| 67 |
-
noteText: "Your clip is yours, darling. Detection is entirely local | Marquee only writes what it sees.",
|
| 68 |
-
},
|
| 69 |
-
wildlife: {
|
| 70 |
-
mastheadTag: "Everyone's a headliner.",
|
| 71 |
-
mastheadSub: "Observing humans in their natural habitat.",
|
| 72 |
-
step1Ttl: "The Footage", step1Sub: "Drop a clip",
|
| 73 |
-
step2Ttl: "Field Survey", step2Sub: "Locate specimens",
|
| 74 |
-
step3Ttl: "Field Notes", step3Sub: "Name the creatures",
|
| 75 |
-
step4Ttl: "Roll Camera", step4Sub: "Observe",
|
| 76 |
-
rollCardTitle: "The Footage",
|
| 77 |
-
dropTitle: "Place your clip here quietly",
|
| 78 |
-
castBtnLabel: "Locate specimens",
|
| 79 |
-
liveBtnLabel: "Roll camera | observe",
|
| 80 |
-
stageTitle: "Field Notes",
|
| 81 |
-
stageEmptyTitle:"No specimens located",
|
| 82 |
-
stageEmptySub: "Roll a clip and hit <b>Locate specimens</b> | each creature gets a field card.",
|
| 83 |
-
backBtnLabel: "Back to field base",
|
| 84 |
-
pbpTitle: "Field observations",
|
| 85 |
-
noteText: "Your footage remains undisturbed. Detection runs locally | Marquee only records observations.",
|
| 86 |
-
},
|
| 87 |
-
boxing: {
|
| 88 |
-
mastheadTag: "Everyone's a headliner.",
|
| 89 |
-
mastheadSub: "Ringside. Every move is championship-defining.",
|
| 90 |
-
step1Ttl: "The Fight Tape", step1Sub: "Drop the clip",
|
| 91 |
-
step2Ttl: "Corner Scan", step2Sub: "Find the fighters",
|
| 92 |
-
step3Ttl: "Fighter Cards", step3Sub: "Name the contenders",
|
| 93 |
-
step4Ttl: "Ring Bell", step4Sub: "Go ringside",
|
| 94 |
-
rollCardTitle: "Fight Tape",
|
| 95 |
-
dropTitle: "Drop the fight footage",
|
| 96 |
-
castBtnLabel: "Find the fighters",
|
| 97 |
-
liveBtnLabel: "Ding ding | go ringside",
|
| 98 |
-
stageTitle: "Fighter Cards",
|
| 99 |
-
stageEmptyTitle:"No fighters in the ring",
|
| 100 |
-
stageEmptySub: "Roll a clip and hit <b>Find the fighters</b> | every face gets a fighter card.",
|
| 101 |
-
backBtnLabel: "Back to the corner",
|
| 102 |
-
pbpTitle: "Ringside commentary",
|
| 103 |
-
noteText: "Your footage stays local. Fighter detection never leaves your device | Marquee only calls the action.",
|
| 104 |
-
},
|
| 105 |
-
masterchef: {
|
| 106 |
-
mastheadTag: "Everyone's a headliner.",
|
| 107 |
-
mastheadSub: "This is not a cooking show. This is a reckoning.",
|
| 108 |
-
step1Ttl: "The Footage", step1Sub: "Submit your clip",
|
| 109 |
-
step2Ttl: "Assessment", step2Sub: "Identify contestants",
|
| 110 |
-
step3Ttl: "Contestants", step3Sub: "Name them",
|
| 111 |
-
step4Ttl: "Begin the Cook", step4Sub: "Judge them",
|
| 112 |
-
rollCardTitle: "Submit Your Footage",
|
| 113 |
-
dropTitle: "Place your clip on the pass",
|
| 114 |
-
castBtnLabel: "Assess the contestants",
|
| 115 |
-
liveBtnLabel: "Begin the cook | judge them",
|
| 116 |
-
stageTitle: "Contestant Board",
|
| 117 |
-
stageEmptyTitle:"No contestants present",
|
| 118 |
-
stageEmptySub: "Submit a clip and hit <b>Assess the contestants</b> | each face gets a contestant card.",
|
| 119 |
-
backBtnLabel: "Back to the kitchen",
|
| 120 |
-
pbpTitle: "Judge's commentary",
|
| 121 |
-
noteText: "Your footage is submitted locally. Identification runs on-device | Marquee only delivers the verdict.",
|
| 122 |
-
},
|
| 123 |
-
};
|
| 124 |
-
|
| 125 |
-
/* ============================================================
|
| 126 |
-
PREVIEW SIMULATOR FALLBACK DATA
|
| 127 |
-
============================================================ */
|
| 128 |
-
const MOCK_SCRIPTS = {
|
| 129 |
-
football: [
|
| 130 |
-
{ t: 1, text: "Welcome to the pitch! The players are in position, and tension is through the roof." },
|
| 131 |
-
{ t: 5, text: "Look at {Star1}! Unbelievable close control. A magnificent exhibition of touch and spatial awareness." },
|
| 132 |
-
{ t: 11, text: "And now {Star2} steps in! Making their presence known. Defending with extreme, calculated precision!" },
|
| 133 |
-
{ t: 17, text: "Play is turning fast. We are witness to absolute tactical magic here today!" },
|
| 134 |
-
{ t: 23, text: "The stadium holds its breath. A phenomenal display of pure champion determination!" }
|
| 135 |
-
],
|
| 136 |
-
diva: [
|
| 137 |
-
{ t: 1, text: "Darling, the spotlight is officially active! Let's examine who is gracing us with their presence." },
|
| 138 |
-
{ t: 5, text: "Here comes {Star1}! Oh, that structure. That posture. It screams pure designer elegance, but are we loving the accessories?" },
|
| 139 |
-
{ t: 11, text: "But wait! {Star2} is making a move! The crowd is absolutely gasping. The pure theatricality of this entrance is divine." },
|
| 140 |
-
{ t: 17, text: "It is an absolute masterclass in walking the carpet, darling. Spectacularly unbothered." },
|
| 141 |
-
{ t: 23, text: "We are witnessing iconic history tonight. Put it on the cover of VOGUE!" }
|
| 142 |
-
],
|
| 143 |
-
wildlife: [
|
| 144 |
-
{ t: 1, text: "We are deep in the Serengeti. A spectacular ecosystem unfolding before us." },
|
| 145 |
-
{ t: 5, text: "Notice {Star1} rising from the grasslands. Moving with deliberate, majestic intent." },
|
| 146 |
-
{ t: 11, text: "A second specimen has appeared! That is {Star2}. Tracking the perimeter. Truly a stunning sight." },
|
| 147 |
-
{ t: 17, text: "Observe how they interact. A delicate dance of absolute natural balance." },
|
| 148 |
-
{ t: 23, text: "Nature at its most pristine and untouched. A truly marvelous encounter." }
|
| 149 |
-
],
|
| 150 |
-
boxing: [
|
| 151 |
-
{ t: 1, text: "Ladies and gentlemen... we are RINGSIDE! The absolute atmosphere of champion combat!" },
|
| 152 |
-
{ t: 5, text: "In the red corner... {Star1}! Unmatched speed. A fighter with absolutely nothing to lose!" },
|
| 153 |
-
{ t: 11, text: "And facing them... {Star2}! Heavy feet, iron jaw. What a spectacular counter-stance!" },
|
| 154 |
-
{ t: 17, text: "They are exchanging glances now! The arena is deafening! Nobody is backing down!" },
|
| 155 |
-
{ t: 23, text: "Ding ding! The bell signals pure, raw tactical chaos. Who takes the belt?!" }
|
| 156 |
-
],
|
| 157 |
-
masterchef: [
|
| 158 |
-
{ t: 1, text: "This kitchen is a place of precision. There is absolutely no room for failure today." },
|
| 159 |
-
{ t: 5, text: "Contestant {Star1} is at the pass. Their knife skills are elegant, but that reduction smells of pure desperation." },
|
| 160 |
-
{ t: 11, text: "Now look at {Star2}. A brave concept. Bold plating. But let's see if the flavor lives up to the presentation." },
|
| 161 |
-
{ t: 17, text: "The tension is absolute. This is not a cooking show, it is a clinical reckoning of your technique." },
|
| 162 |
-
{ t: 23, text: "The judges are silent. A single bite will seal your absolute fate today." }
|
| 163 |
-
]
|
| 164 |
-
};
|
| 165 |
-
|
| 166 |
-
const MOCK_FACE_CROPS = [
|
| 167 |
-
"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",
|
| 168 |
-
"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",
|
| 169 |
-
"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"
|
| 170 |
-
];
|
| 171 |
-
|
| 172 |
-
/* ============================================================
|
| 173 |
-
LANDING — Vibe Selector
|
| 174 |
-
============================================================ */
|
| 175 |
-
// Landing is visible by default (no splash screen). It hides once a vibe is picked.
|
| 176 |
-
const landingView = $('#landingView');
|
| 177 |
-
|
| 178 |
-
$('#landingVibes').addEventListener('click', e => {
|
| 179 |
-
const card = e.target.closest('.lv-card');
|
| 180 |
-
if (!card) return;
|
| 181 |
-
const vibe = card.dataset.vibe;
|
| 182 |
-
landingView.classList.add('hidden');
|
| 183 |
-
$('#appShell').hidden = false;
|
| 184 |
-
applyVibe(vibe);
|
| 185 |
-
});
|
| 186 |
-
|
| 187 |
-
/* ============================================================
|
| 188 |
-
THEME APPLICATION
|
| 189 |
-
============================================================ */
|
| 190 |
-
const VIBES = {
|
| 191 |
-
football: { acc:'#i-whistle', vc:'--star-lime', who:'Football Championship',
|
| 192 |
-
coco:"World Cup energy. Roaring play-by-play commentary for the beautiful game." },
|
| 193 |
-
diva: { acc:'#i-glam', vc:'--star-hot', who:'Diva Hour Glamour',
|
| 194 |
-
coco:"Red carpet glamour. Fashion feedback, high drama, and unsolicited feedback." },
|
| 195 |
-
wildlife: { acc:'#i-paw', vc:'--star-cyan', who:'Wildlife Expedition',
|
| 196 |
-
coco:"Hushed. Awestruck. Narrating your friends like rare creatures in the wild." },
|
| 197 |
-
boxing: { acc:'#i-glove', vc:'--star-tang', who:'Ringside Boxing',
|
| 198 |
-
coco:"Ringside. Every move is championship-defining. Nobody is safe from the hype." },
|
| 199 |
-
masterchef: { acc:'#i-chef', vc:'--star-gold', who:'MasterChef Judgment',
|
| 200 |
-
coco:"High stakes. Brutal precision. A cooking-show judge who takes everything personally." },
|
| 201 |
-
};
|
| 202 |
-
|
| 203 |
-
function applyVibe(vibe) {
|
| 204 |
-
document.body.setAttribute('data-vibe', vibe);
|
| 205 |
-
currentVibe = vibe;
|
| 206 |
-
|
| 207 |
-
const copy = THEME_COPY[vibe] || THEME_COPY.football;
|
| 208 |
-
const vibeData = VIBES[vibe] || {};
|
| 209 |
-
|
| 210 |
-
const tag = $('#mastheadTag');
|
| 211 |
-
if (tag) {
|
| 212 |
-
tag.childNodes[0].textContent = copy.mastheadTag;
|
| 213 |
-
const small = tag.querySelector('small');
|
| 214 |
-
if (small) small.textContent = copy.mastheadSub;
|
| 215 |
-
}
|
| 216 |
-
|
| 217 |
-
_setText('step1Ttl', copy.step1Ttl); _setText('step1Sub', copy.step1Sub);
|
| 218 |
-
_setText('step2Ttl', copy.step2Ttl); _setText('step2Sub', copy.step2Sub);
|
| 219 |
-
_setText('step3Ttl', copy.step3Ttl); _setText('step3Sub', copy.step3Sub);
|
| 220 |
-
_setText('step4Ttl', copy.step4Ttl); _setText('step4Sub', copy.step4Sub);
|
| 221 |
-
|
| 222 |
-
_setText('rollCardTitle', copy.rollCardTitle);
|
| 223 |
-
_setText('dropTitle', copy.dropTitle);
|
| 224 |
-
_setText('castBtnLabel', copy.castBtnLabel);
|
| 225 |
-
_setText('liveBtnLabel', copy.liveBtnLabel);
|
| 226 |
-
_setText('stageTitle', copy.stageTitle);
|
| 227 |
-
_setText('stageEmptyTitle', copy.stageEmptyTitle);
|
| 228 |
-
_setHTML('stageEmptySub', copy.stageEmptySub);
|
| 229 |
-
_setText('pbpTitle', copy.pbpTitle);
|
| 230 |
-
_setHTML('noteText', copy.noteText);
|
| 231 |
-
_setText('backBtnLabel', copy.backBtnLabel);
|
| 232 |
-
|
| 233 |
-
_setText('vibeBannerName', vibeData.who || '');
|
| 234 |
-
_setText('vibeBannerDesc', vibeData.coco || '');
|
| 235 |
-
|
| 236 |
-
const vibeBanner = $('#vibeBanner');
|
| 237 |
-
if (vibeBanner && vibeData) {
|
| 238 |
-
vibeBanner.style.setProperty('--vc', `var(${vibeData.vc})`);
|
| 239 |
-
vibeBanner.style.borderColor = `color-mix(in srgb, var(${vibeData.vc}) 25%, transparent)`;
|
| 240 |
-
vibeBanner.style.backgroundColor = `color-mix(in srgb, var(${vibeData.vc}) 8%, var(--stage))`;
|
| 241 |
-
}
|
| 242 |
-
|
| 243 |
-
const vibeBannerIcon = $('#vibeBannerIcon');
|
| 244 |
-
if (vibeBannerIcon && vibeData) {
|
| 245 |
-
vibeBannerIcon.innerHTML = `<svg class="i" style="width:20px; height:20px;"><use href="${vibeData.acc}"/></svg>`;
|
| 246 |
-
vibeBannerIcon.style.color = `var(${vibeData.vc})`;
|
| 247 |
-
vibeBannerIcon.style.backgroundColor = `color-mix(in srgb, var(${vibeData.vc}) 15%, transparent)`;
|
| 248 |
-
}
|
| 249 |
-
|
| 250 |
-
const bannerWave = $('#bannerWave');
|
| 251 |
-
if (bannerWave && vibeData) {
|
| 252 |
-
bannerWave.style.setProperty('--vc', `var(${vibeData.vc})`);
|
| 253 |
-
}
|
| 254 |
-
|
| 255 |
-
updateChangeShowButton(vibe);
|
| 256 |
-
playSoundEffect(vibe);
|
| 257 |
-
}
|
| 258 |
-
|
| 259 |
-
function _setText(id, val) {
|
| 260 |
-
const el = $('#' + id);
|
| 261 |
-
if (el && val !== undefined) el.textContent = val;
|
| 262 |
-
}
|
| 263 |
-
function _setHTML(id, val) {
|
| 264 |
-
const el = $('#' + id);
|
| 265 |
-
if (el && val !== undefined) el.innerHTML = val;
|
| 266 |
-
}
|
| 267 |
|
| 268 |
-
|
| 269 |
-
landingView.classList.remove('hidden');
|
| 270 |
-
$('#appShell').hidden = true;
|
| 271 |
-
});
|
| 272 |
-
|
| 273 |
-
function updateChangeShowButton(vibe) {
|
| 274 |
-
const btn = $('#changeVibeBtn');
|
| 275 |
-
if (!btn) return;
|
| 276 |
-
btn.style.setProperty('--primary', `var(${VIBES[vibe]?.vc || '--primary'})`);
|
| 277 |
-
}
|
| 278 |
-
|
| 279 |
-
/* ============================================================
|
| 280 |
-
API FETCH & EXPORT INTEGRATIONS
|
| 281 |
-
============================================================ */
|
| 282 |
const BASE = window.location.origin;
|
| 283 |
|
| 284 |
async function apiScan(file) {
|
| 285 |
-
if (isOfflineMode) {
|
| 286 |
-
await new Promise(r => setTimeout(r, 1200));
|
| 287 |
-
return {
|
| 288 |
-
session_id: "preview_session_" + Math.random().toString(36).substr(2, 9),
|
| 289 |
-
video_url: "",
|
| 290 |
-
duration: 30,
|
| 291 |
-
w: 1080, h: 1080, // 1:1 hardcoded
|
| 292 |
-
stars: [
|
| 293 |
-
{ tier: 'VIP', crop: MOCK_FACE_CROPS[0] },
|
| 294 |
-
{ tier: 'CONSTRUCT', crop: MOCK_FACE_CROPS[1] },
|
| 295 |
-
{ tier: 'SPECTATOR', crop: MOCK_FACE_CROPS[2] }
|
| 296 |
-
]
|
| 297 |
-
};
|
| 298 |
-
}
|
| 299 |
-
|
| 300 |
const fd = new FormData();
|
| 301 |
fd.append('file', file, file.name);
|
| 302 |
-
const res
|
| 303 |
const data = await res.json();
|
| 304 |
if (!data.ok) throw new Error(data.error || 'scan failed');
|
| 305 |
return data;
|
| 306 |
}
|
| 307 |
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
const star1 = names[0] || "Contender Alpha";
|
| 313 |
-
const star2 = names[1] || "Contender Beta";
|
| 314 |
-
const renderedScript = baseScript.map(line => {
|
| 315 |
-
let rText = line.text.replace(/{Star1}/g, star1).replace(/{Star2}/g, star2);
|
| 316 |
-
return { t: line.t, text: rText };
|
| 317 |
-
});
|
| 318 |
-
return { script: renderedScript };
|
| 319 |
-
}
|
| 320 |
-
|
| 321 |
-
if (!Client) throw new Error('Gradio client not available — backend required');
|
| 322 |
-
const client = await Client.connect(BASE);
|
| 323 |
-
const result = await client.predict('/generate', { session_id: sessionId, names, vibe });
|
| 324 |
-
const data = result?.data?.[0];
|
| 325 |
-
if (!data || data.ok === false) throw new Error((data && data.error) || 'generate failed');
|
| 326 |
-
return data;
|
| 327 |
}
|
| 328 |
|
| 329 |
-
async function
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
const res = await fetch(`${BASE}/api/tts`, {
|
| 335 |
-
method: 'POST',
|
| 336 |
-
headers: { 'Content-Type': 'application/json' },
|
| 337 |
-
body: JSON.stringify({ session_id: sessionId, vibe, script, voice_model: voiceModel }),
|
| 338 |
});
|
| 339 |
-
const data =
|
| 340 |
-
if (!data.ok) throw new Error(data.error || '
|
| 341 |
-
return data
|
| 342 |
-
}
|
| 343 |
-
|
| 344 |
-
/* ============================================================
|
| 345 |
-
WEB AUDIO SYNTH ENGINE
|
| 346 |
-
============================================================ */
|
| 347 |
-
class AudioEngine {
|
| 348 |
-
constructor() {
|
| 349 |
-
this.ctx = null;
|
| 350 |
-
this.videoNode = null;
|
| 351 |
-
this.duckGain = null;
|
| 352 |
-
this.commentaryGain = null;
|
| 353 |
-
this.buffers = [];
|
| 354 |
-
this.sources = [];
|
| 355 |
-
this.duckTimers = [];
|
| 356 |
-
this._ready = false;
|
| 357 |
-
}
|
| 358 |
-
|
| 359 |
-
async init(videoEl, ttsLines) {
|
| 360 |
-
if (!this.ctx) {
|
| 361 |
-
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
| 362 |
-
}
|
| 363 |
-
if (this.ctx.state === 'suspended') await this.ctx.resume();
|
| 364 |
-
|
| 365 |
-
if (!this.videoNode) {
|
| 366 |
-
this.videoNode = this.ctx.createMediaElementSource(videoEl);
|
| 367 |
-
this.duckGain = this.ctx.createGain();
|
| 368 |
-
this.commentaryGain = this.ctx.createGain();
|
| 369 |
-
this.videoNode.connect(this.duckGain);
|
| 370 |
-
this.duckGain.connect(this.ctx.destination);
|
| 371 |
-
this.commentaryGain.connect(this.ctx.destination);
|
| 372 |
-
}
|
| 373 |
-
|
| 374 |
-
this.duckGain.gain.value = 1.0;
|
| 375 |
-
this.commentaryGain.gain.value = parseFloat($('#volCommentary')?.value ?? 1);
|
| 376 |
-
|
| 377 |
-
this.buffers = await Promise.all(
|
| 378 |
-
ttsLines.map(async (line) => {
|
| 379 |
-
if (!line.audio_b64) return null;
|
| 380 |
-
try {
|
| 381 |
-
const bytes = Uint8Array.from(atob(line.audio_b64), c => c.charCodeAt(0));
|
| 382 |
-
return await this.ctx.decodeAudioData(bytes.buffer);
|
| 383 |
-
} catch(e) {
|
| 384 |
-
console.warn('[audio] decode failed for line:', line.text, e);
|
| 385 |
-
return null;
|
| 386 |
-
}
|
| 387 |
-
})
|
| 388 |
-
);
|
| 389 |
-
this.sources = new Array(ttsLines.length).fill(null);
|
| 390 |
-
this._ready = true;
|
| 391 |
-
}
|
| 392 |
-
|
| 393 |
-
scheduleAll(videoEl, ttsLines) {
|
| 394 |
-
if (!this._ready || !this.ctx) return;
|
| 395 |
-
const now = this.ctx.currentTime;
|
| 396 |
-
const vidNow = videoEl.currentTime;
|
| 397 |
-
const vidDur = videoEl.duration || DUR || 999;
|
| 398 |
-
|
| 399 |
-
this._cancelAll();
|
| 400 |
-
|
| 401 |
-
ttsLines.forEach((line, i) => {
|
| 402 |
-
const buf = this.buffers[i];
|
| 403 |
-
if (!buf) return;
|
| 404 |
-
|
| 405 |
-
const startOffset = line.t - vidNow;
|
| 406 |
-
if (startOffset < -0.1) return;
|
| 407 |
-
if (line.t >= vidDur) return;
|
| 408 |
-
|
| 409 |
-
const src = this.ctx.createBufferSource();
|
| 410 |
-
src.buffer = buf;
|
| 411 |
-
src.connect(this.commentaryGain);
|
| 412 |
-
const startAt = now + Math.max(0, startOffset);
|
| 413 |
-
src.start(startAt);
|
| 414 |
-
this.sources[i] = src;
|
| 415 |
-
|
| 416 |
-
const lineDur = line.duration ?? buf.duration;
|
| 417 |
-
const duckLevel = parseFloat($('#volDuck')?.value ?? 0.2);
|
| 418 |
-
const fadeT = 0.08;
|
| 419 |
-
const g = this.duckGain.gain;
|
| 420 |
-
g.cancelScheduledValues(startAt - fadeT);
|
| 421 |
-
g.setValueAtTime(1.0, startAt - fadeT);
|
| 422 |
-
g.linearRampToValueAtTime(duckLevel, startAt + fadeT);
|
| 423 |
-
g.setValueAtTime(duckLevel, startAt + lineDur - fadeT);
|
| 424 |
-
g.linearRampToValueAtTime(1.0, startAt + lineDur + fadeT);
|
| 425 |
-
});
|
| 426 |
-
}
|
| 427 |
-
|
| 428 |
-
pause() {
|
| 429 |
-
if (!this._ready) return;
|
| 430 |
-
this._cancelAll();
|
| 431 |
-
if (this.duckGain) {
|
| 432 |
-
this.duckGain.gain.cancelScheduledValues(0);
|
| 433 |
-
this.duckGain.gain.setValueAtTime(1.0, 0);
|
| 434 |
-
}
|
| 435 |
-
}
|
| 436 |
-
|
| 437 |
-
setCommentaryVolume(v) {
|
| 438 |
-
if (this.commentaryGain) this.commentaryGain.gain.value = v;
|
| 439 |
-
}
|
| 440 |
-
|
| 441 |
-
_cancelAll() {
|
| 442 |
-
this.sources.forEach(src => { try { src?.stop(); } catch(e){} });
|
| 443 |
-
this.sources = new Array(this.sources.length).fill(null);
|
| 444 |
-
if (this.duckGain) {
|
| 445 |
-
this.duckGain.gain.cancelScheduledValues(0);
|
| 446 |
-
this.duckGain.gain.setValueAtTime(1.0, 0);
|
| 447 |
-
}
|
| 448 |
-
}
|
| 449 |
}
|
| 450 |
|
| 451 |
-
|
|
|
|
| 452 |
|
| 453 |
-
/*
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
function playSoundEffect(vibe) {
|
| 458 |
-
try {
|
| 459 |
-
if (!soundCtx) soundCtx = new (window.AudioContext || window.webkitAudioContext)();
|
| 460 |
-
if (soundCtx.state === 'suspended') soundCtx.resume();
|
| 461 |
-
|
| 462 |
-
const t = soundCtx.currentTime;
|
| 463 |
-
const osc = soundCtx.createOscillator();
|
| 464 |
-
const g = soundCtx.createGain();
|
| 465 |
-
osc.connect(g); g.connect(soundCtx.destination);
|
| 466 |
-
|
| 467 |
-
if (vibe === 'football') {
|
| 468 |
-
osc.type = 'triangle';
|
| 469 |
-
osc.frequency.setValueAtTime(1000, t);
|
| 470 |
-
osc.frequency.exponentialRampToValueAtTime(1200, t + 0.1);
|
| 471 |
-
osc.frequency.setValueAtTime(1000, t + 0.12);
|
| 472 |
-
osc.frequency.exponentialRampToValueAtTime(1300, t + 0.25);
|
| 473 |
-
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.12, t + 0.02); g.gain.exponentialRampToValueAtTime(0, t + 0.3);
|
| 474 |
-
osc.start(t); osc.stop(t + 0.35);
|
| 475 |
-
} else if (vibe === 'diva') {
|
| 476 |
-
osc.type = 'sine';
|
| 477 |
-
osc.frequency.setValueAtTime(440, t); osc.frequency.exponentialRampToValueAtTime(1760, t + 0.5);
|
| 478 |
-
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.1, t + 0.05); g.gain.exponentialRampToValueAtTime(0, t + 0.5);
|
| 479 |
-
osc.start(t); osc.stop(t + 0.55);
|
| 480 |
-
} else if (vibe === 'wildlife') {
|
| 481 |
-
osc.type = 'sine';
|
| 482 |
-
osc.frequency.setValueAtTime(2500, t);
|
| 483 |
-
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.04, t + 0.02); g.gain.exponentialRampToValueAtTime(0, t + 0.2);
|
| 484 |
-
const osc2 = soundCtx.createOscillator();
|
| 485 |
-
const g2 = soundCtx.createGain();
|
| 486 |
-
osc2.type = 'sine'; osc2.frequency.setValueAtTime(2500, t + 0.22);
|
| 487 |
-
osc2.connect(g2); g2.connect(soundCtx.destination);
|
| 488 |
-
g2.gain.setValueAtTime(0, t + 0.22); g2.gain.linearRampToValueAtTime(0.04, t + 0.24); g2.gain.exponentialRampToValueAtTime(0, t + 0.42);
|
| 489 |
-
osc.start(t); osc.stop(t + 0.22); osc2.start(t + 0.22); osc2.stop(t + 0.45);
|
| 490 |
-
} else if (vibe === 'boxing') {
|
| 491 |
-
osc.type = 'sine'; osc.frequency.setValueAtTime(587.33, t);
|
| 492 |
-
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.25, t + 0.01); g.gain.exponentialRampToValueAtTime(0.001, t + 1.2);
|
| 493 |
-
osc.start(t); osc.stop(t + 1.3);
|
| 494 |
-
} else if (vibe === 'masterchef') {
|
| 495 |
-
osc.type = 'triangle'; osc.frequency.setValueAtTime(987.77, t);
|
| 496 |
-
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.15, t + 0.01); g.gain.exponentialRampToValueAtTime(0.001, t + 0.5);
|
| 497 |
-
osc.start(t); osc.stop(t + 0.5);
|
| 498 |
-
}
|
| 499 |
-
} catch(err) {
|
| 500 |
-
console.log("AudioContext blocked or uninitialized", err);
|
| 501 |
-
}
|
| 502 |
}
|
|
|
|
|
|
|
| 503 |
|
| 504 |
-
/*
|
| 505 |
-
INTERFACE CLOCK
|
| 506 |
-
============================================================ */
|
| 507 |
const clocks = $$('#clock');
|
| 508 |
-
function fmtClock(d)
|
| 509 |
-
setInterval(()
|
| 510 |
-
clocks.forEach(c
|
| 511 |
-
|
| 512 |
-
/*
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
c.classList.toggle('done', s < n);
|
| 526 |
-
c.classList.toggle('on', s === n);
|
| 527 |
-
if (s < n) c.querySelector('.node').innerHTML = '<svg class="i" style="width:18px;height:18px"><use href="#i-check"/></svg>';
|
| 528 |
-
else c.querySelector('.node').textContent = '0' + s;
|
| 529 |
});
|
| 530 |
}
|
| 531 |
|
| 532 |
-
/*
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
drop.classList.add('loaded');
|
| 542 |
-
fileName.textContent
|
| 543 |
-
fileDur.textContent
|
| 544 |
-
const url
|
| 545 |
-
const probe
|
| 546 |
-
probe.
|
| 547 |
-
probe.
|
| 548 |
-
probe.
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
setStatus('Tape loaded'); toast('Tape loaded | ready to cast');
|
| 552 |
}
|
| 553 |
-
drop.addEventListener('click',
|
| 554 |
-
drop.addEventListener('keydown',
|
| 555 |
-
fileInput.addEventListener('change',
|
| 556 |
-
['dragover','dragenter'].forEach(ev
|
| 557 |
-
['dragleave','drop'].forEach(ev
|
| 558 |
-
e.preventDefault();
|
| 559 |
-
if
|
| 560 |
}));
|
| 561 |
|
| 562 |
-
/*
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
const stagePanel = $('#stagePanel'), stageEmpty = $('#stageEmpty'), roster = $('#roster'), starCount = $('#starCount');
|
| 566 |
-
castBtn.addEventListener('click', startCasting);
|
| 567 |
|
| 568 |
-
async function startCasting()
|
| 569 |
-
if
|
| 570 |
-
castBtn.disabled
|
| 571 |
setStep(2); setStatus('Casting…');
|
| 572 |
-
castBtn.innerHTML
|
| 573 |
-
|
| 574 |
-
try {
|
| 575 |
toast('Uploading tape…');
|
| 576 |
const data = await apiScan(selectedFile);
|
| 577 |
-
SESSION
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
duration: data.duration || 0, w: data.w, h: data.h, stars: data.stars || [],
|
| 583 |
-
}));
|
| 584 |
-
revealCast(data.stars || []);
|
| 585 |
-
} catch(err) {
|
| 586 |
setStatus('Casting failed');
|
| 587 |
-
toast('Casting failed: '
|
| 588 |
rollCard.classList.remove('casting');
|
| 589 |
-
castBtn.disabled
|
| 590 |
-
castBtn.innerHTML
|
| 591 |
}
|
| 592 |
}
|
| 593 |
|
| 594 |
-
function revealCast(starData)
|
| 595 |
rollCard.classList.remove('casting');
|
| 596 |
-
castBtn.innerHTML
|
| 597 |
-
castBtn.disabled
|
| 598 |
-
setStep(3); setStatus(starData.length
|
| 599 |
-
|
| 600 |
-
stars = starData.map((s,
|
| 601 |
-
stageEmpty.style.display
|
| 602 |
-
roster.hidden
|
| 603 |
-
starCount.textContent
|
| 604 |
-
stars.forEach((s,
|
| 605 |
-
|
| 606 |
-
if
|
| 607 |
-
stageEmpty.style.display
|
| 608 |
-
stageEmpty.querySelector('.t').textContent
|
| 609 |
-
stageEmpty.querySelector('.s').innerHTML
|
| 610 |
-
roster.hidden
|
| 611 |
}
|
| 612 |
-
toast(stars.length
|
| 613 |
refreshLive();
|
| 614 |
}
|
| 615 |
|
| 616 |
-
/*
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
el.className = 'star';
|
| 623 |
-
el.style.setProperty('--c', `var(${s.color})`);
|
| 624 |
-
if (!reduce) {
|
| 625 |
el.animate(
|
| 626 |
-
[{
|
| 627 |
-
{
|
| 628 |
);
|
| 629 |
}
|
| 630 |
const portrait = s.crop
|
| 631 |
? `<img class="head-img" src="${s.crop}" alt="Player ${i+1}">`
|
| 632 |
: `<svg class="i head-ph" style="width:64px;height:68px"><use href="#i-portrait"/></svg>`;
|
| 633 |
-
el.innerHTML
|
| 634 |
<div class="portrait">
|
| 635 |
<div class="beam"></div>
|
| 636 |
${portrait}
|
|
@@ -641,342 +190,230 @@ function buildStar(s, i) {
|
|
| 641 |
</div>
|
| 642 |
<div class="star-body">
|
| 643 |
<label for="star${i}">Stage name</label>
|
| 644 |
-
<input id="star${i}" type="text" placeholder="e.g. The Maestro"
|
| 645 |
</div>`;
|
| 646 |
-
const input
|
| 647 |
-
input.addEventListener('input',
|
| 648 |
-
const v
|
| 649 |
-
s.name
|
| 650 |
-
const wasNamed
|
| 651 |
-
el.classList.toggle('named',
|
| 652 |
-
if
|
| 653 |
refreshLive();
|
| 654 |
});
|
| 655 |
return el;
|
| 656 |
}
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
c.
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
{ transform:'translateX(-50%) scale(1) rotate(0)', opacity:1 }],
|
| 665 |
-
{ duration:480, easing:'cubic-bezier(.2,1.7,.4,1)' }
|
| 666 |
-
);
|
| 667 |
}
|
| 668 |
|
| 669 |
-
/*
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 681 |
}
|
| 682 |
|
| 683 |
-
/*
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
function
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
starCount.textContent = n ? `${n}/${stars.length} NAMED` : `${stars.length} STARS FOUND`;
|
| 692 |
}
|
| 693 |
}
|
| 694 |
|
| 695 |
-
/*
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
liveBtn.
|
| 699 |
-
|
| 700 |
-
liveBtn.disabled = true;
|
| 701 |
-
liveBtn.innerHTML = '<svg class="i"><use href="#i-star"/></svg> Rolling cameras…';
|
| 702 |
setStatus('Generating…');
|
| 703 |
-
try
|
| 704 |
-
const names
|
| 705 |
-
const data
|
| 706 |
-
REAL_SCRIPT
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
liveBtn.innerHTML = '<svg class="i"><use href="#i-star"/></svg> Recording voice…';
|
| 710 |
-
toast('Script ready | recording Coco\'s voice…');
|
| 711 |
-
try {
|
| 712 |
-
TTS_LINES = await apiTts(SESSION, currentVibe, REAL_SCRIPT, currentVoiceModel);
|
| 713 |
-
} catch(ttsErr) {
|
| 714 |
-
console.warn('[tts] TTS failed, continuing without audio:', ttsErr);
|
| 715 |
-
TTS_LINES = REAL_SCRIPT.map(l => ({ ...l }));
|
| 716 |
-
toast('Voice unavailable | showing captions only');
|
| 717 |
-
}
|
| 718 |
-
|
| 719 |
-
setStep(4); setStatus('On air', true);
|
| 720 |
-
if (!reduce) confetti();
|
| 721 |
buildBroadcast();
|
| 722 |
-
setTimeout(()
|
| 723 |
-
}
|
| 724 |
setStatus('Showtime failed');
|
| 725 |
-
toast('Showtime failed: '
|
| 726 |
-
}
|
| 727 |
-
liveBtn.disabled
|
| 728 |
-
liveBtn.innerHTML
|
| 729 |
}
|
| 730 |
});
|
| 731 |
|
| 732 |
-
/*
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
const
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
if (toBroadcast) buildAudioControls();
|
| 741 |
-
window.scrollTo({ top:0, behavior: reduce ? 'auto' : 'smooth' });
|
| 742 |
}
|
| 743 |
-
$('#backBtn').addEventListener('click',
|
| 744 |
-
switchView('setup');
|
| 745 |
-
const v = $('#vid');
|
| 746 |
-
if (v) v.pause();
|
| 747 |
-
AUDIO.pause();
|
| 748 |
-
});
|
| 749 |
|
| 750 |
/* ============================================================
|
| 751 |
-
BROADCAST
|
| 752 |
============================================================ */
|
| 753 |
-
let script
|
| 754 |
-
const vid
|
| 755 |
-
linesEl
|
| 756 |
-
pp
|
| 757 |
-
const ICON_PLAY
|
| 758 |
-
const ICON_PAUSE
|
| 759 |
-
|
| 760 |
-
function fmt(s)
|
| 761 |
-
|
| 762 |
-
function buildBroadcast()
|
| 763 |
-
const d
|
| 764 |
-
const who
|
| 765 |
-
script
|
| 766 |
-
linesEl.innerHTML
|
| 767 |
-
pbpCt.textContent
|
| 768 |
-
if
|
| 769 |
-
linesEl.innerHTML
|
| 770 |
}
|
| 771 |
-
script.forEach((l,
|
| 772 |
-
const row
|
| 773 |
-
row.className
|
| 774 |
-
row.style.setProperty('--cc',
|
| 775 |
-
|
| 776 |
-
row.
|
| 777 |
-
row.addEventListener('click', () => seek(l.t));
|
| 778 |
linesEl.appendChild(row);
|
| 779 |
});
|
| 780 |
-
if
|
| 781 |
-
activeLine
|
| 782 |
-
|
| 783 |
-
if (vid && TTS_LINES.some(l => l.audio_b64)) {
|
| 784 |
-
AUDIO.init(vid, TTS_LINES).then(() => {
|
| 785 |
-
toast('Voice loaded | press play');
|
| 786 |
-
}).catch(e => console.warn('[audio] init failed:', e));
|
| 787 |
-
}
|
| 788 |
}
|
| 789 |
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
function
|
| 793 |
-
|
| 794 |
-
const
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
|
|
|
| 801 |
showChyron(idx);
|
| 802 |
-
if
|
| 803 |
-
|
| 804 |
-
const top = r.offsetTop - linesEl.clientHeight / 2 + r.clientHeight / 2;
|
| 805 |
-
linesEl.scrollTo({ top, behavior: reduce ? 'auto' : 'smooth' });
|
| 806 |
-
}
|
| 807 |
}
|
| 808 |
}
|
| 809 |
-
function showChyron(idx)
|
| 810 |
-
if
|
| 811 |
-
const l
|
| 812 |
chyron.classList.remove('in');
|
| 813 |
-
requestAnimationFrame(()
|
| 814 |
-
chyron.style.setProperty('--
|
| 815 |
-
chyWho.textContent
|
| 816 |
-
requestAnimationFrame(()
|
| 817 |
});
|
| 818 |
}
|
| 819 |
-
function loop()
|
| 820 |
-
function seek(t)
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
}
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
vid.addEventListener('
|
| 827 |
-
vid.addEventListener('
|
| 828 |
-
vid.addEventListener('pause', () => { pp.innerHTML = ICON_PLAY; if (rafP) cancelAnimationFrame(rafP); AUDIO.pause(); });
|
| 829 |
-
vid.addEventListener('ended', () => { pp.innerHTML = ICON_PLAY; AUDIO.pause(); });
|
| 830 |
-
vid.addEventListener('seeked', () => { if (!vid.paused) AUDIO.scheduleAll(vid, TTS_LINES); });
|
| 831 |
-
vid.addEventListener('timeupdate', renderPlayhead);
|
| 832 |
}
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
if (isOfflineMode && !selectedFile) {
|
| 838 |
-
if (mockPlayTimer) {
|
| 839 |
-
clearInterval(mockPlayTimer); mockPlayTimer = null; pp.innerHTML = ICON_PLAY;
|
| 840 |
-
} else {
|
| 841 |
-
pp.innerHTML = ICON_PAUSE;
|
| 842 |
-
mockPlayTimer = setInterval(() => {
|
| 843 |
-
let cur = (vid.currentTime || 0) + 1;
|
| 844 |
-
if (cur > dur()) cur = 0;
|
| 845 |
-
vid.currentTime = cur; renderPlayhead();
|
| 846 |
-
}, 1000);
|
| 847 |
-
}
|
| 848 |
-
return;
|
| 849 |
-
}
|
| 850 |
-
if (!vid) return;
|
| 851 |
-
vid.paused ? vid.play() : vid.pause();
|
| 852 |
-
});
|
| 853 |
-
|
| 854 |
-
scrub.addEventListener('click', e => {
|
| 855 |
-
const r = scrub.getBoundingClientRect();
|
| 856 |
-
seek(((e.clientX - r.left) / r.width) * dur());
|
| 857 |
});
|
| 858 |
|
| 859 |
-
/*
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
if
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
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;';
|
| 868 |
-
bar.innerHTML = `
|
| 869 |
-
<span style="opacity:.6">Voice Volume</span>
|
| 870 |
-
<input id="volCommentary" type="range" min="0" max="2" step="0.05" value="1"
|
| 871 |
-
style="width:80px;accent-color:var(--accent)" title="Commentary volume">
|
| 872 |
-
<span style="opacity:.6">Video Volume</span>
|
| 873 |
-
<input id="volDuck" type="range" min="0" max="1" step="0.05" value="0.2"
|
| 874 |
-
style="width:80px;accent-color:var(--secondary)" title="Video volume while speaking">
|
| 875 |
-
<span id="audioStatus" style="opacity:.5;margin-left:4px"></span>`;
|
| 876 |
-
const controls = $('#broadcastView .controls') || $('#broadcastView');
|
| 877 |
-
if (controls) controls.appendChild(bar);
|
| 878 |
-
$('#volCommentary').addEventListener('input', e => { AUDIO.setCommentaryVolume(parseFloat(e.target.value)); });
|
| 879 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 880 |
|
| 881 |
-
/*
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
const start = l.t, end = Math.min(D || l.t + 4, (script[i + 1] ? script[i + 1].t : l.t + 4));
|
| 889 |
-
return `${i + 1}\n${srtTime(start)} --> ${srtTime(end)}\n${l.who}: ${l.text}\n`;
|
| 890 |
}).join('\n');
|
| 891 |
-
const blob
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
toast('Captions exported | drop the .srt into CapCut');
|
| 898 |
-
} catch(e) {
|
| 899 |
-
toast('Export failed or blocked by sandbox environment.');
|
| 900 |
-
}
|
| 901 |
});
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60);
|
| 905 |
return `${pad(h)}:${pad(m)}:${pad(sec)},000`;
|
| 906 |
}
|
| 907 |
|
| 908 |
-
/*
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
label.textContent = 'Preparing file parameters…';
|
| 921 |
-
|
| 922 |
-
const stages = [
|
| 923 |
-
[15, 'Writing audio tracks…'],
|
| 924 |
-
[35, 'Merging subtitles…'],
|
| 925 |
-
[55, 'Mixing broadcast audio…'],
|
| 926 |
-
[75, 'Encoding stream layout…'],
|
| 927 |
-
[90, 'Finalizing broadcast multiplex…'],
|
| 928 |
-
];
|
| 929 |
-
let stageIdx = 0;
|
| 930 |
-
const ticker = setInterval(() => {
|
| 931 |
-
if (stageIdx < stages.length) {
|
| 932 |
-
const [pct, msg] = stages[stageIdx++];
|
| 933 |
-
fill2.style.width = pct + '%';
|
| 934 |
-
label.textContent = msg;
|
| 935 |
-
}
|
| 936 |
-
}, 3000);
|
| 937 |
-
|
| 938 |
-
await new Promise(r => setTimeout(r, 4500));
|
| 939 |
-
clearInterval(ticker);
|
| 940 |
-
fill2.style.width = '100%';
|
| 941 |
-
label.textContent = 'Render complete!';
|
| 942 |
-
toast('Preview broadcast mixed successfully!');
|
| 943 |
-
exportBtn.disabled = false;
|
| 944 |
-
exportBtn.innerHTML = '<svg class="i ic"><use href="#i-download"/></svg> Export broadcast (.mp4)';
|
| 945 |
-
});
|
| 946 |
-
}
|
| 947 |
-
|
| 948 |
-
/* ============================================================
|
| 949 |
-
CONFETTI
|
| 950 |
-
============================================================ */
|
| 951 |
-
function confetti() {
|
| 952 |
-
const cols = ['#FFC94D','#FF4D9A','#3DE7FF','#C18BFF','#9BE85B','#FF8A4D'];
|
| 953 |
-
for (let i = 0; i < 50; i++) {
|
| 954 |
-
const c = document.createElement('div');
|
| 955 |
-
c.className = 'confetti';
|
| 956 |
-
c.style.left = Math.random() * 100 + 'vw';
|
| 957 |
-
c.style.background = cols[i % cols.length];
|
| 958 |
-
c.style.animationDuration = (1.6 + Math.random() * 1.6) + 's';
|
| 959 |
-
c.style.animationDelay = (Math.random() * .3) + 's';
|
| 960 |
-
c.style.transform = `rotate(${Math.random() * 360}deg)`;
|
| 961 |
-
if (Math.random() > .5) c.style.borderRadius = '50%';
|
| 962 |
document.body.appendChild(c);
|
| 963 |
-
setTimeout(()
|
| 964 |
}
|
| 965 |
}
|
| 966 |
|
| 967 |
-
/*
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
clearTimeout(toastT); toastT = setTimeout(() => toastEl.classList.remove('show'), 2600);
|
| 974 |
-
}
|
| 975 |
-
|
| 976 |
-
/* ============================================================
|
| 977 |
-
PAGE INITIALIZATION
|
| 978 |
-
applyVibe sets default CSS vars so the page looks right before
|
| 979 |
-
the user picks a vibe on the landing screen.
|
| 980 |
-
No splash screen — landing shows immediately.
|
| 981 |
-
============================================================ */
|
| 982 |
-
applyVibe('football');
|
|
|
|
| 1 |
/* ============================================================
|
| 2 |
+
MARQUEE — flow engine, wired to the gradio.Server backend.
|
| 3 |
+
- scan: plain fetch() multipart upload (CPU-only endpoint)
|
| 4 |
+
- generate: Gradio JS client -> /generate (@app.api, ZeroGPU)
|
| 5 |
============================================================ */
|
| 6 |
+
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
const $ = (s, r = document) => r.querySelector(s);
|
| 9 |
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
|
| 10 |
const reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;
|
| 11 |
const pad = n => String(n).padStart(2, '0');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
/* ---------- Plain fetch API helpers — talk to FastAPI endpoints ---------- */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
const BASE = window.location.origin;
|
| 15 |
|
| 16 |
async function apiScan(file) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
const fd = new FormData();
|
| 18 |
fd.append('file', file, file.name);
|
| 19 |
+
const res = await fetch(`${BASE}/api/scan`, {method:'POST', body: fd});
|
| 20 |
const data = await res.json();
|
| 21 |
if (!data.ok) throw new Error(data.error || 'scan failed');
|
| 22 |
return data;
|
| 23 |
}
|
| 24 |
|
| 25 |
+
let _gradio = null;
|
| 26 |
+
async function gradioClient() {
|
| 27 |
+
if (!_gradio) _gradio = await Client.connect(BASE);
|
| 28 |
+
return _gradio;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
+
async function apiGenerate(sessionId, names, vibe) {
|
| 32 |
+
// /generate is an @app.api() endpoint -> call it through the Gradio queue.
|
| 33 |
+
const client = await gradioClient();
|
| 34 |
+
const result = await client.predict('/generate', {
|
| 35 |
+
session_id: sessionId, names: names, vibe: vibe
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
});
|
| 37 |
+
const data = result?.data?.[0];
|
| 38 |
+
if (!data || data.ok === false) throw new Error((data && data.error) || 'generate failed');
|
| 39 |
+
return data;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
+
/* ---------- session state from the backend ---------- */
|
| 43 |
+
let SESSION = null, VIDEO_URL = null, DUR = 0, REAL_SCRIPT = [], selectedFile = null;
|
| 44 |
|
| 45 |
+
/* ---------- auto-fit the iframe to content (same-origin srcdoc) ---------- */
|
| 46 |
+
function fitFrame() {
|
| 47 |
+
try { if (window.frameElement) window.frameElement.style.height = document.documentElement.scrollHeight + 'px'; }
|
| 48 |
+
catch (e) {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
}
|
| 50 |
+
new ResizeObserver(fitFrame).observe(document.body);
|
| 51 |
+
window.addEventListener('load', fitFrame);
|
| 52 |
|
| 53 |
+
/* ---------- live clock ---------- */
|
|
|
|
|
|
|
| 54 |
const clocks = $$('#clock');
|
| 55 |
+
function fmtClock(d){return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;}
|
| 56 |
+
setInterval(()=>{const t=fmtClock(new Date());clocks.forEach(c=>c.textContent=t);},1000);
|
| 57 |
+
clocks.forEach(c=>c.textContent=fmtClock(new Date()));
|
| 58 |
+
|
| 59 |
+
/* ---------- status pill ---------- */
|
| 60 |
+
const status=$('#status'), statusTxt=$('#statusTxt');
|
| 61 |
+
function setStatus(txt,live){statusTxt.textContent=txt;status.classList.toggle('live',!!live);}
|
| 62 |
+
|
| 63 |
+
/* ---------- rundown ---------- */
|
| 64 |
+
const cues=$$('#rundown .cue');
|
| 65 |
+
function setStep(n){
|
| 66 |
+
cues.forEach(c=>{
|
| 67 |
+
const s=+c.dataset.step;
|
| 68 |
+
c.classList.toggle('done',s<n);
|
| 69 |
+
c.classList.toggle('on',s===n);
|
| 70 |
+
if(s<n)c.querySelector('.node').innerHTML='<svg class="i" style="width:18px;height:18px"><use href="#i-check"/></svg>';
|
| 71 |
+
else c.querySelector('.node').textContent='0'+s;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
});
|
| 73 |
}
|
| 74 |
|
| 75 |
+
/* ---------- cast colors + tiers (presentation) ---------- */
|
| 76 |
+
const COLORS=['--star-gold','--star-hot','--star-cyan','--star-violet','--star-lime','--star-tang'];
|
| 77 |
+
let stars=[]; // {name, color, tier, crop}
|
| 78 |
+
|
| 79 |
+
/* ---------- VIBES — Coco's wardrobe + the show's voice ----------
|
| 80 |
+
NOTE: the play-by-play LINES are no longer templated here — the real
|
| 81 |
+
commentary comes from the VLM. We keep Coco's persona + colors. */
|
| 82 |
+
const VIBES={
|
| 83 |
+
football:{ acc:'#i-whistle', vc:'--star-lime', who:'COCO · TOUCHLINE',
|
| 84 |
+
coco:"Whistle's on. I'm calling this one like a cup final — keep your eyes on the pitch!"},
|
| 85 |
+
diva:{ acc:'#i-glam', vc:'--star-hot', who:'COCO · RED CARPET',
|
| 86 |
+
coco:"Shades on, boa fluffed. Darling, we are SO ready for the carpet. Bring the drama."},
|
| 87 |
+
wildlife:{ acc:'#i-paw', vc:'--star-cyan', who:'COCO · FIELD UNIT',
|
| 88 |
+
coco:"*whispering* …we must be very quiet now. A rare specimen has entered the frame."},
|
| 89 |
+
boxing:{ acc:'#i-glove', vc:'--star-tang', who:'COCO · RINGSIDE',
|
| 90 |
+
coco:"Gloves laced, ringside seat warm. Ladies and gentlemen… in the RED corner!"}
|
| 91 |
+
};
|
| 92 |
+
let currentVibe=null;
|
| 93 |
|
| 94 |
+
/* ---------- ROLL TAPE (real file) ---------- */
|
| 95 |
+
const drop=$('#drop'), castBtn=$('#castBtn'), liveBtn=$('#liveBtn'), rollCard=$('#rollCard');
|
| 96 |
+
const fileInput=$('#fileInput'), fileName=$('#fileName'), fileDur=$('#fileDur');
|
| 97 |
+
|
| 98 |
+
function handleFile(file){
|
| 99 |
+
if(!file) return;
|
| 100 |
+
selectedFile=file;
|
| 101 |
drop.classList.add('loaded');
|
| 102 |
+
fileName.textContent=file.name;
|
| 103 |
+
fileDur.textContent='reading…';
|
| 104 |
+
const url=URL.createObjectURL(file);
|
| 105 |
+
const probe=document.createElement('video'); probe.preload='metadata';
|
| 106 |
+
probe.onloadedmetadata=()=>{ fileDur.textContent=fmt(probe.duration)+' · ready to cast'; URL.revokeObjectURL(url); };
|
| 107 |
+
probe.onerror=()=>{ fileDur.textContent='ready to cast'; };
|
| 108 |
+
probe.src=url;
|
| 109 |
+
castBtn.disabled=false;
|
| 110 |
+
setStatus('Tape loaded'); toast('Tape loaded — ready to cast');
|
|
|
|
| 111 |
}
|
| 112 |
+
drop.addEventListener('click',()=>fileInput.click());
|
| 113 |
+
drop.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();fileInput.click();}});
|
| 114 |
+
fileInput.addEventListener('change',e=>handleFile(e.target.files[0]));
|
| 115 |
+
['dragover','dragenter'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.style.borderColor='var(--cyan)';}));
|
| 116 |
+
['dragleave','drop'].forEach(ev=>drop.addEventListener(ev,e=>{
|
| 117 |
+
e.preventDefault();drop.style.borderColor='';
|
| 118 |
+
if(ev==='drop'&&e.dataTransfer.files[0])handleFile(e.dataTransfer.files[0]);
|
| 119 |
}));
|
| 120 |
|
| 121 |
+
/* ---------- CASTING (real scan) ---------- */
|
| 122 |
+
const stagePanel=$('#stagePanel'), stageEmpty=$('#stageEmpty'), roster=$('#roster'), starCount=$('#starCount');
|
| 123 |
+
castBtn.addEventListener('click',startCasting);
|
|
|
|
|
|
|
| 124 |
|
| 125 |
+
async function startCasting(){
|
| 126 |
+
if(castBtn.disabled||!selectedFile) return;
|
| 127 |
+
castBtn.disabled=true; rollCard.classList.add('casting');
|
| 128 |
setStep(2); setStatus('Casting…');
|
| 129 |
+
castBtn.innerHTML='<svg class="i"><use href="#i-spot"/></svg> Scanning the tape…';
|
| 130 |
+
try{
|
|
|
|
| 131 |
toast('Uploading tape…');
|
| 132 |
const data = await apiScan(selectedFile);
|
| 133 |
+
SESSION=data.session_id; VIDEO_URL=data.video_url; DUR=data.duration||0;
|
| 134 |
+
setBoothOrient(data.w,data.h);
|
| 135 |
+
const vid=$('#vid'); vid.src=VIDEO_URL; $('#screen').classList.add('has-video');
|
| 136 |
+
revealCast(data.stars||[]);
|
| 137 |
+
}catch(err){
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
setStatus('Casting failed');
|
| 139 |
+
toast('Casting failed: '+(err.message||err));
|
| 140 |
rollCard.classList.remove('casting');
|
| 141 |
+
castBtn.disabled=false;
|
| 142 |
+
castBtn.innerHTML='<svg class="i"><use href="#i-spot"/></svg> Find the stars';
|
| 143 |
}
|
| 144 |
}
|
| 145 |
|
| 146 |
+
function revealCast(starData){
|
| 147 |
rollCard.classList.remove('casting');
|
| 148 |
+
castBtn.innerHTML='<svg class="i"><use href="#i-spot"/></svg> Re-cast';
|
| 149 |
+
castBtn.disabled=false;
|
| 150 |
+
setStep(3); setStatus(starData.length+' stars found');
|
| 151 |
+
|
| 152 |
+
stars = starData.map((s,i)=>({name:'',tier:s.tier||'STAR',color:COLORS[i%COLORS.length],crop:s.crop||null}));
|
| 153 |
+
stageEmpty.style.display='none';
|
| 154 |
+
roster.hidden=false; roster.innerHTML='';
|
| 155 |
+
starCount.textContent=stars.length+' STARS FOUND';
|
| 156 |
+
stars.forEach((s,i)=>roster.appendChild(buildStar(s,i)));
|
| 157 |
+
|
| 158 |
+
if(!stars.length){
|
| 159 |
+
stageEmpty.style.display='';
|
| 160 |
+
stageEmpty.querySelector('.t').textContent='No stars spotted';
|
| 161 |
+
stageEmpty.querySelector('.s').innerHTML='No faces found — but the show still goes on. Pick a vibe and go live for nameless narration.';
|
| 162 |
+
roster.hidden=true;
|
| 163 |
}
|
| 164 |
+
toast(stars.length?stars.length+' stars found — give them stage names':'No faces found — you can still go live');
|
| 165 |
refreshLive();
|
| 166 |
}
|
| 167 |
|
| 168 |
+
/* ---------- THE STAR CARD (one reusable block) ---------- */
|
| 169 |
+
function buildStar(s,i){
|
| 170 |
+
const el=document.createElement('div');
|
| 171 |
+
el.className='star';
|
| 172 |
+
el.style.setProperty('--c',`var(${s.color})`);
|
| 173 |
+
if(!reduce){
|
|
|
|
|
|
|
|
|
|
| 174 |
el.animate(
|
| 175 |
+
[{opacity:0,transform:'translateY(14px) scale(.98)'},{opacity:1,transform:'none'}],
|
| 176 |
+
{duration:480,delay:i*90,easing:'cubic-bezier(.2,.9,.25,1)',fill:'backwards'}
|
| 177 |
);
|
| 178 |
}
|
| 179 |
const portrait = s.crop
|
| 180 |
? `<img class="head-img" src="${s.crop}" alt="Player ${i+1}">`
|
| 181 |
: `<svg class="i head-ph" style="width:64px;height:68px"><use href="#i-portrait"/></svg>`;
|
| 182 |
+
el.innerHTML=`
|
| 183 |
<div class="portrait">
|
| 184 |
<div class="beam"></div>
|
| 185 |
${portrait}
|
|
|
|
| 190 |
</div>
|
| 191 |
<div class="star-body">
|
| 192 |
<label for="star${i}">Stage name</label>
|
| 193 |
+
<input id="star${i}" type="text" placeholder="e.g. The Maestro" maxlength="22" autocomplete="off">
|
| 194 |
</div>`;
|
| 195 |
+
const input=el.querySelector('input');
|
| 196 |
+
input.addEventListener('input',()=>{
|
| 197 |
+
const v=input.value.trim();
|
| 198 |
+
s.name=v;
|
| 199 |
+
const wasNamed=el.classList.contains('named');
|
| 200 |
+
el.classList.toggle('named',!!v);
|
| 201 |
+
if(v && !wasNamed && !reduce) popCrown(el);
|
| 202 |
refreshLive();
|
| 203 |
});
|
| 204 |
return el;
|
| 205 |
}
|
| 206 |
+
function popCrown(el){
|
| 207 |
+
const c=el.querySelector('.crown');
|
| 208 |
+
c.style.animation='none';void c.offsetWidth;
|
| 209 |
+
c.animate([{transform:'translateX(-50%) translateY(10px) scale(.4) rotate(-12deg)',opacity:0},
|
| 210 |
+
{transform:'translateX(-50%) scale(1.25) rotate(8deg)',opacity:1,offset:.6},
|
| 211 |
+
{transform:'translateX(-50%) scale(1) rotate(0)',opacity:1}],
|
| 212 |
+
{duration:480,easing:'cubic-bezier(.2,1.7,.4,1)'});
|
|
|
|
|
|
|
|
|
|
| 213 |
}
|
| 214 |
|
| 215 |
+
/* ---------- VIBES + COCO ---------- */
|
| 216 |
+
const vibesWrap=$('#vibes'), coco=$('#coco'), cocoLine=$('#cocoLine'),
|
| 217 |
+
cocoAcc=$('#cocoAcc'), cocoWho=coco.querySelector('.who');
|
| 218 |
+
vibesWrap.addEventListener('click',e=>{
|
| 219 |
+
const b=e.target.closest('.vibe'); if(!b)return;
|
| 220 |
+
$$('.vibe',vibesWrap).forEach(v=>v.setAttribute('aria-pressed','false'));
|
| 221 |
+
b.setAttribute('aria-pressed','true');
|
| 222 |
+
currentVibe=b.dataset.vibe;
|
| 223 |
+
dressCoco(currentVibe);
|
| 224 |
+
refreshLive();
|
| 225 |
+
});
|
| 226 |
+
function dressCoco(v){
|
| 227 |
+
const d=VIBES[v]; if(!d)return;
|
| 228 |
+
coco.style.setProperty('--vc',`var(${d.vc})`);
|
| 229 |
+
cocoAcc.innerHTML=`<svg><use href="${d.acc}"/></svg>`;
|
| 230 |
+
cocoWho.textContent=d.who;
|
| 231 |
+
cocoLine.textContent=d.coco;
|
| 232 |
+
if(!reduce){
|
| 233 |
+
coco.animate([{transform:'scale(.98)',opacity:.6},{transform:'scale(1)',opacity:1}],{duration:300,easing:'ease-out'});
|
| 234 |
+
const av=coco.querySelector('.av');
|
| 235 |
+
av.animate([{transform:'rotate(-8deg) scale(.9)'},{transform:'rotate(6deg) scale(1.08)'},{transform:'none'}],{duration:420,easing:'cubic-bezier(.2,1.7,.4,1)'});
|
| 236 |
+
}
|
| 237 |
}
|
| 238 |
|
| 239 |
+
/* ---------- LIVE-READY GATE ----------
|
| 240 |
+
ready when a vibe is picked (names optional — nameless narration is allowed) */
|
| 241 |
+
function namedCount(){return stars.filter(s=>s.name).length;}
|
| 242 |
+
function refreshLive(){
|
| 243 |
+
liveBtn.disabled=!(currentVibe && SESSION);
|
| 244 |
+
if(stars.length){
|
| 245 |
+
const n=namedCount();
|
| 246 |
+
starCount.textContent = n? `${n}/${stars.length} NAMED` : `${stars.length} STARS FOUND`;
|
|
|
|
| 247 |
}
|
| 248 |
}
|
| 249 |
|
| 250 |
+
/* ---------- SHOWTIME (real commentary) ---------- */
|
| 251 |
+
liveBtn.addEventListener('click',async ()=>{
|
| 252 |
+
if(liveBtn.disabled)return;
|
| 253 |
+
liveBtn.disabled=true;
|
| 254 |
+
liveBtn.innerHTML='<svg class="i"><use href="#i-star"/></svg> Rolling cameras…';
|
|
|
|
|
|
|
| 255 |
setStatus('Generating…');
|
| 256 |
+
try{
|
| 257 |
+
const names=stars.map(s=>s.name||'');
|
| 258 |
+
const data=await apiGenerate(SESSION, names, currentVibe);
|
| 259 |
+
REAL_SCRIPT=data.script||[];
|
| 260 |
+
setStep(4); setStatus('On air',true);
|
| 261 |
+
if(!reduce) confetti();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
buildBroadcast();
|
| 263 |
+
setTimeout(()=>{switchView('broadcast');fitFrame();}, reduce?0:450);
|
| 264 |
+
}catch(err){
|
| 265 |
setStatus('Showtime failed');
|
| 266 |
+
toast('Showtime failed: '+(err.message||err));
|
| 267 |
+
}finally{
|
| 268 |
+
liveBtn.disabled=false;
|
| 269 |
+
liveBtn.innerHTML='<svg class="i"><use href="#i-star"/></svg> Showtime — go live';
|
| 270 |
}
|
| 271 |
});
|
| 272 |
|
| 273 |
+
/* ---------- VIEW SWITCH ---------- */
|
| 274 |
+
const setupView=$('#setupView'), broadcastView=$('#broadcastView');
|
| 275 |
+
function switchView(which){
|
| 276 |
+
const toBroadcast=which==='broadcast';
|
| 277 |
+
setupView.classList.toggle('active',!toBroadcast);
|
| 278 |
+
broadcastView.classList.toggle('active',toBroadcast);
|
| 279 |
+
window.scrollTo({top:0,behavior:reduce?'auto':'smooth'});
|
| 280 |
+
fitFrame();
|
|
|
|
|
|
|
| 281 |
}
|
| 282 |
+
$('#backBtn').addEventListener('click',()=>{switchView('setup');const v=$('#vid');if(v)v.pause();});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
|
| 284 |
/* ============================================================
|
| 285 |
+
BROADCAST PLAYER — driven by a real <video>
|
| 286 |
============================================================ */
|
| 287 |
+
let script=[]; // {t, who, text, cc}
|
| 288 |
+
const vid=$('#vid'), screen=$('#screen'), chyron=$('#chyron'), chyWho=$('#chyWho'), chyLine=$('#chyLine'),
|
| 289 |
+
linesEl=$('#lines'), pbpCt=$('#pbpCt'), tcEl=$('#tc'), fill=$('#fill'), knob=$('#knob'),
|
| 290 |
+
pp=$('#pp'), scrub=$('#scrub'), tcDur=$('#tcDur');
|
| 291 |
+
const ICON_PLAY='<svg class="i"><use href="#i-play"/></svg>';
|
| 292 |
+
const ICON_PAUSE='<svg class="i"><use href="#i-pause"/></svg>';
|
| 293 |
+
|
| 294 |
+
function fmt(s){s=isFinite(s)?s:0;return pad(Math.floor(s/60))+':'+pad(Math.floor(s%60));}
|
| 295 |
+
|
| 296 |
+
function buildBroadcast(){
|
| 297 |
+
const d=VIBES[currentVibe]||{vc:'--hot',who:'COCO'};
|
| 298 |
+
const who=(d.who||'COCO').replace('COCO · ','');
|
| 299 |
+
script=(REAL_SCRIPT||[]).map(l=>({t:+l.t||0, text:l.text||'', who, cc:d.vc}));
|
| 300 |
+
linesEl.innerHTML='';
|
| 301 |
+
pbpCt.textContent=script.length+' LINES';
|
| 302 |
+
if(!script.length){
|
| 303 |
+
linesEl.innerHTML='<div class="pl"><span class="tx">No commentary generated — try another clip.</span></div>';
|
| 304 |
}
|
| 305 |
+
script.forEach((l,i)=>{
|
| 306 |
+
const row=document.createElement('div');
|
| 307 |
+
row.className='pl'; row.dataset.i=i;
|
| 308 |
+
row.style.setProperty('--cc',`var(${l.cc})`);
|
| 309 |
+
row.innerHTML=`<span class="t">${fmt(l.t)}</span><span class="tx"><b>${l.who}</b> · ${l.text}</span>`;
|
| 310 |
+
row.addEventListener('click',()=>seek(l.t));
|
|
|
|
| 311 |
linesEl.appendChild(row);
|
| 312 |
});
|
| 313 |
+
if(vid){ try{vid.currentTime=0;}catch(e){} }
|
| 314 |
+
activeLine=-1; renderPlayhead();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
}
|
| 316 |
|
| 317 |
+
/* playback engine — reads the real video element */
|
| 318 |
+
let activeLine=-1, rafP=null;
|
| 319 |
+
function dur(){ return DUR || (vid&&vid.duration) || 0; }
|
| 320 |
+
function renderPlayhead(){
|
| 321 |
+
const D=dur(), cur=(vid&&vid.currentTime)||0;
|
| 322 |
+
const pct=D?(cur/D)*100:0;
|
| 323 |
+
fill.style.width=pct+'%'; knob.style.left=pct+'%'; tcEl.textContent=fmt(cur);
|
| 324 |
+
let idx=-1;
|
| 325 |
+
for(let i=0;i<script.length;i++){if(script[i].t<=cur)idx=i;else break;}
|
| 326 |
+
if(idx!==activeLine){
|
| 327 |
+
activeLine=idx;
|
| 328 |
+
$$('.pl',linesEl).forEach((r,i)=>r.classList.toggle('now',i===idx));
|
| 329 |
showChyron(idx);
|
| 330 |
+
if(idx>=0){const r=linesEl.children[idx];const top=r.offsetTop-linesEl.clientHeight/2+r.clientHeight/2;
|
| 331 |
+
linesEl.scrollTo({top,behavior:reduce?'auto':'smooth'});}
|
|
|
|
|
|
|
|
|
|
| 332 |
}
|
| 333 |
}
|
| 334 |
+
function showChyron(idx){
|
| 335 |
+
if(idx<0){chyron.classList.remove('in');return;}
|
| 336 |
+
const l=script[idx];
|
| 337 |
chyron.classList.remove('in');
|
| 338 |
+
requestAnimationFrame(()=>{
|
| 339 |
+
chyron.style.setProperty('--cc',`var(${l.cc})`);
|
| 340 |
+
chyWho.textContent=l.who; chyLine.textContent=l.text;
|
| 341 |
+
requestAnimationFrame(()=>chyron.classList.add('in'));
|
| 342 |
});
|
| 343 |
}
|
| 344 |
+
function loop(){ renderPlayhead(); if(vid && !vid.paused && !vid.ended) rafP=requestAnimationFrame(loop); }
|
| 345 |
+
function seek(t){ if(vid) vid.currentTime=Math.max(0,Math.min(dur(),t)); }
|
| 346 |
+
|
| 347 |
+
if(vid){
|
| 348 |
+
vid.addEventListener('loadedmetadata',()=>{ if(vid.duration&&isFinite(vid.duration))DUR=vid.duration; tcDur.textContent=fmt(dur()); renderPlayhead(); });
|
| 349 |
+
vid.addEventListener('play',()=>{ pp.innerHTML=ICON_PAUSE; loop(); });
|
| 350 |
+
vid.addEventListener('pause',()=>{ pp.innerHTML=ICON_PLAY; if(rafP)cancelAnimationFrame(rafP); });
|
| 351 |
+
vid.addEventListener('ended',()=>{ pp.innerHTML=ICON_PLAY; });
|
| 352 |
+
vid.addEventListener('timeupdate',renderPlayhead);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
}
|
| 354 |
+
pp.addEventListener('click',()=>{ if(!vid)return; vid.paused?vid.play():vid.pause(); });
|
| 355 |
+
scrub.addEventListener('click',e=>{
|
| 356 |
+
const r=scrub.getBoundingClientRect();
|
| 357 |
+
seek(((e.clientX-r.left)/r.width)*dur());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
});
|
| 359 |
|
| 360 |
+
/* orientation toggle (manual) + auto from real dims */
|
| 361 |
+
const booth=$('#booth');
|
| 362 |
+
function setBoothOrient(w,h){
|
| 363 |
+
booth.classList.remove('portrait','square');
|
| 364 |
+
let o='landscape';
|
| 365 |
+
if(w&&h){ if(Math.abs(w-h)/Math.max(w,h)<0.08) o='square'; else if(h>w) o='portrait'; }
|
| 366 |
+
if(o!=='landscape') booth.classList.add(o);
|
| 367 |
+
$$('.orient button').forEach(x=>x.setAttribute('aria-pressed', String(x.dataset.orient===o)));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
}
|
| 369 |
+
$$('.orient button').forEach(b=>b.addEventListener('click',()=>{
|
| 370 |
+
$$('.orient button').forEach(x=>x.setAttribute('aria-pressed','false'));
|
| 371 |
+
b.setAttribute('aria-pressed','true');
|
| 372 |
+
booth.classList.remove('portrait','square');
|
| 373 |
+
if(b.dataset.orient!=='landscape')booth.classList.add(b.dataset.orient);
|
| 374 |
+
fitFrame();
|
| 375 |
+
}));
|
| 376 |
|
| 377 |
+
/* ---------- SRT EXPORT (CapCut-ready) ---------- */
|
| 378 |
+
$('#srtBtn').addEventListener('click',()=>{
|
| 379 |
+
if(!script.length){toast('Go live first');return;}
|
| 380 |
+
const D=dur();
|
| 381 |
+
const srt=script.map((l,i)=>{
|
| 382 |
+
const start=l.t, end=Math.min(D||l.t+4, (script[i+1]?script[i+1].t:l.t+4));
|
| 383 |
+
return `${i+1}\n${srtTime(start)} --> ${srtTime(end)}\n${l.who}: ${l.text}\n`;
|
|
|
|
|
|
|
| 384 |
}).join('\n');
|
| 385 |
+
const blob=new Blob([srt],{type:'text/plain'});
|
| 386 |
+
const a=document.createElement('a');
|
| 387 |
+
a.href=URL.createObjectURL(blob);
|
| 388 |
+
a.download=`marquee_${currentVibe||'show'}_captions.srt`;
|
| 389 |
+
a.click(); URL.revokeObjectURL(a.href);
|
| 390 |
+
toast('Captions exported — drop the .srt into CapCut');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
});
|
| 392 |
+
function srtTime(s){
|
| 393 |
+
const h=Math.floor(s/3600), m=Math.floor((s%3600)/60), sec=Math.floor(s%60);
|
|
|
|
| 394 |
return `${pad(h)}:${pad(m)}:${pad(sec)},000`;
|
| 395 |
}
|
| 396 |
|
| 397 |
+
/* ---------- confetti ---------- */
|
| 398 |
+
function confetti(){
|
| 399 |
+
const cols=['#FFC94D','#FF4D9A','#3DE7FF','#C18BFF','#9BE85B','#FF8A4D'];
|
| 400 |
+
for(let i=0;i<70;i++){
|
| 401 |
+
const c=document.createElement('div');
|
| 402 |
+
c.className='confetti';
|
| 403 |
+
c.style.left=Math.random()*100+'vw';
|
| 404 |
+
c.style.background=cols[i%cols.length];
|
| 405 |
+
c.style.animationDuration=(1.6+Math.random()*1.6)+'s';
|
| 406 |
+
c.style.animationDelay=(Math.random()*.3)+'s';
|
| 407 |
+
c.style.transform=`rotate(${Math.random()*360}deg)`;
|
| 408 |
+
if(Math.random()>.5)c.style.borderRadius='50%';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
document.body.appendChild(c);
|
| 410 |
+
setTimeout(()=>c.remove(),3600);
|
| 411 |
}
|
| 412 |
}
|
| 413 |
|
| 414 |
+
/* ---------- toast ---------- */
|
| 415 |
+
const toastEl=$('#toast'), toastTxt=$('#toastTxt'); let toastT;
|
| 416 |
+
function toast(msg){
|
| 417 |
+
toastTxt.textContent=msg; toastEl.classList.add('show');
|
| 418 |
+
clearTimeout(toastT); toastT=setTimeout(()=>toastEl.classList.remove('show'),2600);
|
| 419 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
marquee_inlined_preview.html
ADDED
|
@@ -0,0 +1,1093 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Marquee — Setup Console & Broadcast</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;500;600;700;800&family=Bebas+Neue&family=Nunito:wght@600;700;800;900&display=swap" rel="stylesheet">
|
| 10 |
+
<style>/* ============================================================
|
| 11 |
+
MARQUEE — design system
|
| 12 |
+
Tokens straight from the brief so they lift into Gradio css=.
|
| 13 |
+
Sections: tokens · base · masthead · rundown · desk · stage
|
| 14 |
+
· star card · vibes + Coco · broadcast player · utilities
|
| 15 |
+
============================================================ */
|
| 16 |
+
|
| 17 |
+
:root{
|
| 18 |
+
/* night & surfaces */
|
| 19 |
+
--night:#190B2E; /* base */
|
| 20 |
+
--night-2:#120821; /* deeper, for wells */
|
| 21 |
+
--stage:#241241; /* panels */
|
| 22 |
+
--stage-2:#2E1A52; /* raised */
|
| 23 |
+
--raise:#382060; /* inputs-on-dark */
|
| 24 |
+
--line:#3A2A5E; /* borders */
|
| 25 |
+
--line-soft:#2C1E4C;
|
| 26 |
+
|
| 27 |
+
/* leads + helper */
|
| 28 |
+
--hot:#FF3D8B; /* primary / on-air / energy */
|
| 29 |
+
--gold:#FFC94D; /* stardom: lights, crowns */
|
| 30 |
+
--cyan:#3DE7FF; /* confirm / scan */
|
| 31 |
+
--violet:#B98BFF; /* playful / mascot */
|
| 32 |
+
|
| 33 |
+
/* text */
|
| 34 |
+
--cream:#FFF3E9;
|
| 35 |
+
--muted:#A98FC9;
|
| 36 |
+
--muted-2:#7C66A6;
|
| 37 |
+
|
| 38 |
+
/* the casting rainbow — each star gets their own character color.
|
| 39 |
+
all high-chroma, all in-family: the "agent select" punch. */
|
| 40 |
+
--star-gold:#FFC94D;
|
| 41 |
+
--star-hot:#FF4D9A;
|
| 42 |
+
--star-cyan:#3DE7FF;
|
| 43 |
+
--star-violet:#C18BFF;
|
| 44 |
+
--star-lime:#9BE85B;
|
| 45 |
+
--star-tang:#FF8A4D;
|
| 46 |
+
|
| 47 |
+
--fx:cubic-bezier(.2,.9,.25,1);
|
| 48 |
+
--pop:cubic-bezier(.2,1.7,.4,1);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
*{box-sizing:border-box;margin:0;padding:0}
|
| 52 |
+
html{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
|
| 53 |
+
|
| 54 |
+
body{
|
| 55 |
+
font-family:Nunito,system-ui,sans-serif;
|
| 56 |
+
color:var(--cream);
|
| 57 |
+
background:var(--night);
|
| 58 |
+
min-height:100vh;
|
| 59 |
+
line-height:1.5;
|
| 60 |
+
/* one justified light: a stage spotlight from above + a faint floor wash.
|
| 61 |
+
no decorative blob gradients. */
|
| 62 |
+
background-image:
|
| 63 |
+
radial-gradient(120% 70% at 50% -25%, rgba(255,201,77,.10), transparent 55%),
|
| 64 |
+
radial-gradient(140% 90% at 50% 120%, rgba(61,231,255,.05), transparent 60%);
|
| 65 |
+
background-attachment:fixed;
|
| 66 |
+
}
|
| 67 |
+
/* film grain — the only decorative texture, very subtle */
|
| 68 |
+
body::after{
|
| 69 |
+
content:"";position:fixed;inset:0;pointer-events:none;z-index:80;opacity:.04;
|
| 70 |
+
mix-blend-mode:overlay;
|
| 71 |
+
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.disp{font-family:Unbounded,sans-serif}
|
| 75 |
+
.marq{font-family:'Bebas Neue',sans-serif;letter-spacing:.06em}
|
| 76 |
+
svg.i{width:1em;height:1em;display:block;flex:none}
|
| 77 |
+
|
| 78 |
+
.shell{max-width:1180px;margin:0 auto;padding:clamp(16px,3vw,34px) clamp(16px,4vw,40px) 40px;position:relative;z-index:1}
|
| 79 |
+
|
| 80 |
+
/* ============================================================
|
| 81 |
+
MASTHEAD — the broadcast header bar. signature lives here.
|
| 82 |
+
============================================================ */
|
| 83 |
+
.masthead{
|
| 84 |
+
display:flex;align-items:center;justify-content:space-between;gap:20px;
|
| 85 |
+
padding-bottom:22px;margin-bottom:6px;flex-wrap:wrap;
|
| 86 |
+
}
|
| 87 |
+
.brand{display:flex;align-items:center;gap:18px}
|
| 88 |
+
|
| 89 |
+
/* the lit marquee sign — spend the boldness here */
|
| 90 |
+
.sign{
|
| 91 |
+
position:relative;padding:14px 22px 15px;border-radius:14px;
|
| 92 |
+
border:2px solid rgba(255,201,77,.55);
|
| 93 |
+
background:
|
| 94 |
+
radial-gradient(120% 140% at 50% 0%, rgba(255,201,77,.14), transparent 70%),
|
| 95 |
+
linear-gradient(180deg, rgba(255,201,77,.05), rgba(25,11,46,.2));
|
| 96 |
+
box-shadow:inset 0 0 22px rgba(255,201,77,.16), 0 10px 40px rgba(255,61,139,.10);
|
| 97 |
+
}
|
| 98 |
+
.sign h1{
|
| 99 |
+
font-family:Unbounded,sans-serif;font-weight:800;font-size:clamp(26px,3.4vw,38px);
|
| 100 |
+
letter-spacing:.04em;line-height:.9;
|
| 101 |
+
color:var(--cream);
|
| 102 |
+
text-shadow:0 0 18px rgba(255,201,77,.45), 0 0 2px rgba(255,201,77,.8);
|
| 103 |
+
}
|
| 104 |
+
/* perimeter bulbs, top + bottom rows */
|
| 105 |
+
.sign .bulbs{position:absolute;left:14px;right:14px;display:flex;justify-content:space-between}
|
| 106 |
+
.sign .bulbs.t{top:-5px}.sign .bulbs.b{bottom:-5px}
|
| 107 |
+
.sign .bulbs i{width:6px;height:6px;border-radius:50%;background:var(--gold);
|
| 108 |
+
box-shadow:0 0 8px var(--gold),0 0 2px #fff;animation:tw 2.6s infinite}
|
| 109 |
+
.sign .bulbs i:nth-child(2n){animation-delay:.7s}
|
| 110 |
+
.sign .bulbs i:nth-child(3n){animation-delay:1.5s;background:var(--hot);box-shadow:0 0 8px var(--hot),0 0 2px #fff}
|
| 111 |
+
@keyframes tw{0%,100%{opacity:1}48%{opacity:.28}}
|
| 112 |
+
|
| 113 |
+
.tag{font-family:Unbounded,sans-serif;font-weight:500;font-size:13px;color:var(--gold);letter-spacing:.01em}
|
| 114 |
+
.tag small{display:block;font-family:Nunito;font-weight:600;color:var(--muted);font-size:12px;margin-top:3px;letter-spacing:0}
|
| 115 |
+
|
| 116 |
+
/* right side: live clock + status */
|
| 117 |
+
.broadcast-meta{display:flex;align-items:center;gap:14px}
|
| 118 |
+
.clock{font-family:'Bebas Neue',sans-serif;letter-spacing:.12em;font-size:20px;color:var(--muted);
|
| 119 |
+
font-variant-numeric:tabular-nums}
|
| 120 |
+
.status{display:flex;align-items:center;gap:9px;white-space:nowrap;font-family:'Bebas Neue',sans-serif;letter-spacing:.14em;
|
| 121 |
+
font-size:16px;color:var(--muted);border:1px solid var(--line);padding:8px 15px;border-radius:999px;
|
| 122 |
+
background:rgba(46,26,82,.5)}
|
| 123 |
+
.status .led{width:9px;height:9px;border-radius:50%;background:var(--muted-2);transition:.3s}
|
| 124 |
+
.status.live{color:var(--hot);border-color:rgba(255,61,139,.5);background:rgba(255,61,139,.08)}
|
| 125 |
+
.status.live .led{background:var(--hot);box-shadow:0 0 12px var(--hot);animation:tw 1.1s infinite}
|
| 126 |
+
|
| 127 |
+
/* ============================================================
|
| 128 |
+
RUNDOWN — broadcast running order, not 4 identical cards.
|
| 129 |
+
nodes on a connecting track.
|
| 130 |
+
============================================================ */
|
| 131 |
+
.rundown{position:relative;display:grid;grid-template-columns:repeat(4,1fr);
|
| 132 |
+
margin:8px 0 30px;padding:0 6px}
|
| 133 |
+
.rundown::before{content:"";position:absolute;top:19px;left:11%;right:11%;height:2px;
|
| 134 |
+
background:var(--line-soft)}
|
| 135 |
+
.cue{position:relative;display:flex;flex-direction:column;align-items:center;gap:9px;text-align:center}
|
| 136 |
+
.cue .node{width:40px;height:40px;border-radius:50%;display:grid;place-items:center;
|
| 137 |
+
background:var(--stage);border:2px solid var(--line);
|
| 138 |
+
font-family:'Bebas Neue',sans-serif;font-size:20px;color:var(--muted-2);
|
| 139 |
+
position:relative;z-index:1;transition:.35s var(--fx)}
|
| 140 |
+
.cue .ttl{font-family:Unbounded,sans-serif;font-weight:600;font-size:13px;color:var(--muted)}
|
| 141 |
+
.cue .sub{font-size:11px;color:var(--muted-2);letter-spacing:.01em}
|
| 142 |
+
/* done */
|
| 143 |
+
.cue.done .node{border-color:var(--cyan);color:var(--cyan);background:rgba(61,231,255,.08)}
|
| 144 |
+
.cue.done .ttl{color:var(--cream)}
|
| 145 |
+
/* active */
|
| 146 |
+
.cue.on .node{border-color:var(--gold);color:var(--gold);background:rgba(255,201,77,.12);
|
| 147 |
+
box-shadow:0 0 0 5px rgba(255,201,77,.1),0 0 22px rgba(255,201,77,.3)}
|
| 148 |
+
.cue.on .ttl{color:var(--gold)}
|
| 149 |
+
@media(prefers-reduced-motion:no-preference){
|
| 150 |
+
.cue.on .node{animation:cuePulse 2s infinite}
|
| 151 |
+
}
|
| 152 |
+
@keyframes cuePulse{0%,100%{box-shadow:0 0 0 5px rgba(255,201,77,.1),0 0 22px rgba(255,201,77,.28)}
|
| 153 |
+
50%{box-shadow:0 0 0 8px rgba(255,201,77,.04),0 0 30px rgba(255,201,77,.45)}}
|
| 154 |
+
|
| 155 |
+
/* ============================================================
|
| 156 |
+
LAYOUT — control desk (left) vs the stage (right).
|
| 157 |
+
intentionally asymmetric; the two halves are treated differently.
|
| 158 |
+
============================================================ */
|
| 159 |
+
.console{display:grid;grid-template-columns:362px 1fr;gap:20px;align-items:start}
|
| 160 |
+
@media(max-width:900px){.console{grid-template-columns:1fr}}
|
| 161 |
+
|
| 162 |
+
/* the desk: flat, utilitarian — it's the control surface, not a showpiece */
|
| 163 |
+
.desk{display:flex;flex-direction:column;gap:16px}
|
| 164 |
+
.card{background:var(--stage);border:1px solid var(--line-soft);border-radius:16px;padding:18px}
|
| 165 |
+
.card-h{display:flex;align-items:center;gap:10px;margin-bottom:14px}
|
| 166 |
+
.card-h .ic{color:var(--gold);font-size:18px}
|
| 167 |
+
.card-h h2{font-family:Unbounded,sans-serif;font-weight:600;font-size:14px;letter-spacing:.01em}
|
| 168 |
+
.card-h .meta{margin-left:auto;font-family:'Bebas Neue',sans-serif;letter-spacing:.07em;font-size:12px;color:var(--muted-2)}
|
| 169 |
+
|
| 170 |
+
/* roll tape drop zone — clapperboard motif */
|
| 171 |
+
.drop{position:relative;border:1.5px dashed var(--line);border-radius:13px;padding:26px 16px;
|
| 172 |
+
text-align:center;cursor:pointer;transition:.22s var(--fx);overflow:hidden;
|
| 173 |
+
background:radial-gradient(200px 110px at 50% -20%,rgba(255,201,77,.07),transparent 70%)}
|
| 174 |
+
.drop:hover{border-color:var(--gold);background:radial-gradient(200px 130px at 50% -10%,rgba(255,201,77,.12),transparent 70%)}
|
| 175 |
+
.drop:focus-visible{border-color:var(--cyan)}
|
| 176 |
+
.drop .clap{font-size:30px;color:var(--gold);display:inline-flex;margin-bottom:8px}
|
| 177 |
+
.drop .t{font-family:Unbounded,sans-serif;font-weight:600;font-size:15px}
|
| 178 |
+
.drop .s{font-size:12px;color:var(--muted);margin-top:4px}
|
| 179 |
+
/* loaded: a little filmstrip readout */
|
| 180 |
+
.drop .loaded{display:none;align-items:center;gap:12px;text-align:left}
|
| 181 |
+
.drop.loaded{border-style:solid;border-color:rgba(61,231,255,.45);background:rgba(61,231,255,.05);cursor:default}
|
| 182 |
+
.drop.loaded .ask{display:none}
|
| 183 |
+
.drop.loaded .loaded{display:flex}
|
| 184 |
+
.strip{width:52px;height:52px;border-radius:9px;flex:none;position:relative;overflow:hidden;
|
| 185 |
+
background:linear-gradient(135deg,var(--raise),var(--stage));border:1px solid var(--line)}
|
| 186 |
+
.strip::before,.strip::after{content:"";position:absolute;top:4px;bottom:4px;width:5px;
|
| 187 |
+
background:repeating-linear-gradient(var(--night) 0 3px,transparent 3px 7px)}
|
| 188 |
+
.strip::before{left:3px}.strip::after{right:3px}
|
| 189 |
+
.strip .play{position:absolute;inset:0;display:grid;place-items:center;color:var(--cyan);font-size:16px}
|
| 190 |
+
.loaded .fn{font-family:Unbounded,sans-serif;font-weight:600;font-size:13px;color:var(--cream)}
|
| 191 |
+
.loaded .fd{font-family:'Bebas Neue',sans-serif;letter-spacing:.05em;font-size:13px;color:var(--cyan);margin-top:3px}
|
| 192 |
+
|
| 193 |
+
/* buttons */
|
| 194 |
+
.btn{width:100%;border:0;cursor:pointer;font-family:Unbounded,sans-serif;font-weight:600;font-size:14px;
|
| 195 |
+
color:var(--night);padding:14px;border-radius:12px;display:flex;align-items:center;justify-content:center;
|
| 196 |
+
gap:9px;transition:.18s var(--fx)}
|
| 197 |
+
.btn:disabled{opacity:.4;cursor:not-allowed;background:var(--raise);color:var(--muted-2)}
|
| 198 |
+
.btn.cyan{background:linear-gradient(135deg,var(--cyan),#16b6d8)}
|
| 199 |
+
.btn.cyan:not(:disabled):hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(61,231,255,.3)}
|
| 200 |
+
.btn.show{font-size:17px;padding:17px;background:linear-gradient(110deg,var(--gold),var(--hot));position:relative;overflow:hidden}
|
| 201 |
+
.btn.show:not(:disabled):hover{transform:translateY(-2px);box-shadow:0 16px 38px rgba(255,61,139,.4)}
|
| 202 |
+
.btn.show:not(:disabled)::after{content:"";position:absolute;top:0;bottom:0;width:40%;
|
| 203 |
+
background:linear-gradient(90deg,transparent,rgba(255,255,255,.35),transparent);
|
| 204 |
+
transform:translateX(-200%)}
|
| 205 |
+
@media(prefers-reduced-motion:no-preference){
|
| 206 |
+
.btn.show:not(:disabled)::after{animation:shine 3.4s ease-in-out 1.2s infinite}
|
| 207 |
+
}
|
| 208 |
+
@keyframes shine{0%,100%{transform:translateX(-220%)}40%,60%{transform:translateX(360%)}}
|
| 209 |
+
.mt{margin-top:14px}
|
| 210 |
+
|
| 211 |
+
/* casting sweep bar */
|
| 212 |
+
.sweep{height:6px;border-radius:4px;margin-top:13px;background:var(--night-2);position:relative;overflow:hidden;
|
| 213 |
+
opacity:0;height:0;margin-top:0;transition:.25s}
|
| 214 |
+
.casting .sweep{opacity:1;height:6px;margin-top:13px}
|
| 215 |
+
.sweep::after{content:"";position:absolute;inset:0;width:36%;
|
| 216 |
+
background:linear-gradient(90deg,transparent,var(--gold),transparent)}
|
| 217 |
+
@media(prefers-reduced-motion:no-preference){.sweep::after{animation:sw 1s linear infinite}}
|
| 218 |
+
@keyframes sw{from{transform:translateX(-120%)}to{transform:translateX(330%)}}
|
| 219 |
+
|
| 220 |
+
/* ============================================================
|
| 221 |
+
THE STAGE — Star Select. theatrical, spotlit well.
|
| 222 |
+
============================================================ */
|
| 223 |
+
.stage-panel{background:
|
| 224 |
+
radial-gradient(80% 60% at 50% -8%, rgba(255,201,77,.07), transparent 60%),
|
| 225 |
+
linear-gradient(180deg,var(--stage),var(--night-2));
|
| 226 |
+
border:1px solid var(--line);border-radius:20px;padding:20px;
|
| 227 |
+
box-shadow:inset 0 1px 0 rgba(255,243,233,.04), 0 24px 60px rgba(8,2,20,.5)}
|
| 228 |
+
.stage-h{display:flex;align-items:center;gap:11px;margin-bottom:16px}
|
| 229 |
+
.stage-h .ic{color:var(--gold);font-size:19px}
|
| 230 |
+
.stage-h h2{font-family:Unbounded,sans-serif;font-weight:700;font-size:16px}
|
| 231 |
+
.stage-h .count{margin-left:auto;white-space:nowrap;font-family:'Bebas Neue',sans-serif;letter-spacing:.08em;font-size:14px;
|
| 232 |
+
color:var(--muted);background:rgba(46,26,82,.6);padding:4px 11px;border-radius:8px}
|
| 233 |
+
|
| 234 |
+
/* empty state */
|
| 235 |
+
.empty{display:grid;place-items:center;min-height:330px;text-align:center;padding:20px}
|
| 236 |
+
.empty .spot{width:96px;height:96px;border-radius:50%;display:grid;place-items:center;margin-bottom:16px;
|
| 237 |
+
color:var(--muted-2);font-size:46px;
|
| 238 |
+
background:radial-gradient(circle at 50% 30%,rgba(255,201,77,.08),transparent 70%)}
|
| 239 |
+
.empty .t{font-family:Unbounded,sans-serif;font-weight:600;font-size:17px;color:var(--cream);margin-bottom:6px}
|
| 240 |
+
.empty .s{font-size:13px;color:var(--muted);max-width:300px}
|
| 241 |
+
|
| 242 |
+
/* star grid */
|
| 243 |
+
.roster{display:grid;grid-template-columns:repeat(auto-fill,minmax(164px,1fr));gap:16px}
|
| 244 |
+
|
| 245 |
+
/* ---- the star card (one reusable block) ----
|
| 246 |
+
per-card character color is set inline as --c. */
|
| 247 |
+
/* base state is fully visible — entrance is driven via WAAPI in JS so a
|
| 248 |
+
backgrounded tab never leaves cards stuck invisible. */
|
| 249 |
+
.star{--c:var(--gold);position:relative;border-radius:16px;overflow:hidden;
|
| 250 |
+
background:linear-gradient(180deg,var(--stage-2),var(--stage));
|
| 251 |
+
border:1px solid var(--line);transition:.25s var(--fx)}
|
| 252 |
+
.star:hover{transform:translateY(-3px);border-color:color-mix(in srgb,var(--c) 60%,transparent)}
|
| 253 |
+
|
| 254 |
+
/* portrait well with a spotlight beam tinted to the character color,
|
| 255 |
+
and an angular framed headshot placeholder (agent-select energy) */
|
| 256 |
+
.portrait{position:relative;height:138px;overflow:hidden;
|
| 257 |
+
background:radial-gradient(90% 80% at 50% 120%,color-mix(in srgb,var(--c) 22%,var(--night-2)),var(--night-2))}
|
| 258 |
+
.portrait .beam{position:absolute;top:-30px;left:50%;width:150px;height:170px;transform:translateX(-50%);
|
| 259 |
+
pointer-events:none;clip-path:polygon(40% 0,60% 0,100% 100%,0 100%);
|
| 260 |
+
background:linear-gradient(180deg,color-mix(in srgb,var(--c) 55%,transparent),transparent 70%);opacity:.7}
|
| 261 |
+
.portrait .head-ph{position:absolute;left:50%;bottom:-6px;transform:translateX(-50%);
|
| 262 |
+
color:color-mix(in srgb,var(--c) 50%,#ffffff);opacity:.4}
|
| 263 |
+
.portrait .frame{position:absolute;inset:8px;border:1.5px solid color-mix(in srgb,var(--c) 35%,transparent);
|
| 264 |
+
border-radius:11px;pointer-events:none}
|
| 265 |
+
.portrait .idx{position:absolute;top:10px;left:10px;font-family:'Bebas Neue',sans-serif;font-size:14px;
|
| 266 |
+
color:var(--cream);background:rgba(15,6,30,.7);padding:2px 7px;border-radius:7px;display:flex;align-items:center;gap:3px}
|
| 267 |
+
.portrait .idx .st{color:var(--c)}
|
| 268 |
+
.tier{position:absolute;top:10px;right:10px;font-family:'Bebas Neue',sans-serif;font-size:11px;letter-spacing:.02em;
|
| 269 |
+
white-space:nowrap;color:var(--night);background:var(--c);padding:3px 8px;border-radius:7px;
|
| 270 |
+
box-shadow:0 2px 10px color-mix(in srgb,var(--c) 50%,transparent)}
|
| 271 |
+
|
| 272 |
+
/* crown drops in on naming */
|
| 273 |
+
/* crown lifts in above the star's head on naming */
|
| 274 |
+
.crown{position:absolute;top:50px;left:50%;transform:translateX(-50%) translateY(12px) scale(.4) rotate(-12deg);
|
| 275 |
+
opacity:0;width:30px;height:30px;color:var(--gold);
|
| 276 |
+
filter:drop-shadow(0 2px 6px color-mix(in srgb,var(--gold) 70%,transparent));
|
| 277 |
+
transition:.4s var(--pop)}
|
| 278 |
+
.star.named .crown{opacity:1;transform:translateX(-50%) scale(1) rotate(0)}
|
| 279 |
+
|
| 280 |
+
.star-body{padding:13px}
|
| 281 |
+
.star-body label{font-family:'Bebas Neue',sans-serif;letter-spacing:.08em;font-size:12px;color:var(--muted);
|
| 282 |
+
display:block;margin-bottom:7px}
|
| 283 |
+
.star input{width:100%;background:var(--night-2);border:1px solid var(--line);color:var(--cream);
|
| 284 |
+
border-radius:10px;padding:10px 12px;font-family:Nunito;font-weight:800;font-size:14px;outline:none;transition:.16s}
|
| 285 |
+
.star input::placeholder{color:var(--muted-2);font-weight:600}
|
| 286 |
+
.star input:focus{border-color:var(--c);box-shadow:0 0 0 3px color-mix(in srgb,var(--c) 25%,transparent)}
|
| 287 |
+
|
| 288 |
+
/* crowned: card lights in its own character color */
|
| 289 |
+
.star.named{border-color:color-mix(in srgb,var(--c) 65%,transparent);
|
| 290 |
+
box-shadow:0 0 0 1px color-mix(in srgb,var(--c) 30%,transparent),0 10px 30px color-mix(in srgb,var(--c) 18%,transparent)}
|
| 291 |
+
.star.named .portrait{background:radial-gradient(90% 90% at 50% 120%,color-mix(in srgb,var(--c) 40%,var(--night-2)),var(--night-2))}
|
| 292 |
+
.star.named .portrait .beam{opacity:1}
|
| 293 |
+
.star.named .head-ph{opacity:.6}
|
| 294 |
+
|
| 295 |
+
/* ============================================================
|
| 296 |
+
VIBES (narration modes) + COCO host
|
| 297 |
+
============================================================ */
|
| 298 |
+
.vibes{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
|
| 299 |
+
.vibe{display:flex;align-items:center;gap:11px;padding:12px;border-radius:13px;cursor:pointer;color:var(--cream);
|
| 300 |
+
border:1px solid var(--line-soft);background:var(--stage-2);transition:.18s var(--fx);text-align:left}
|
| 301 |
+
.vibe:hover{border-color:var(--violet);transform:translateY(-1px)}
|
| 302 |
+
.vibe .ic{font-size:20px;color:var(--muted);display:inline-flex;transition:.18s}
|
| 303 |
+
.vibe>span:last-child{display:flex;flex-direction:column;gap:2px;min-width:0}
|
| 304 |
+
.vibe .vt{display:block;font-family:Unbounded,sans-serif;font-weight:600;font-size:13px;color:var(--cream)}
|
| 305 |
+
.vibe .vd{display:block;font-size:11px;color:var(--muted);line-height:1.25}
|
| 306 |
+
.vibe[aria-pressed="true"]{border-color:var(--vc,var(--hot));background:color-mix(in srgb,var(--vc,var(--hot)) 12%,var(--stage-2))}
|
| 307 |
+
.vibe[aria-pressed="true"] .ic{color:var(--vc,var(--hot))}
|
| 308 |
+
|
| 309 |
+
/* Coco — the host bug. re-dresses per vibe. */
|
| 310 |
+
.coco{display:flex;align-items:center;gap:13px;margin-top:14px;padding:13px 14px;border-radius:14px;
|
| 311 |
+
background:linear-gradient(120deg,color-mix(in srgb,var(--vc,var(--violet)) 12%,var(--stage-2)),var(--stage-2));
|
| 312 |
+
border:1px solid color-mix(in srgb,var(--vc,var(--violet)) 30%,var(--line-soft));transition:.35s var(--fx)}
|
| 313 |
+
.coco .av{position:relative;width:46px;height:46px;border-radius:13px;flex:none;display:grid;place-items:center;
|
| 314 |
+
background:radial-gradient(circle at 50% 30%,color-mix(in srgb,var(--vc,var(--violet)) 70%,#fff 5%),var(--vc,var(--violet)));
|
| 315 |
+
box-shadow:0 6px 18px color-mix(in srgb,var(--vc,var(--violet)) 40%,transparent);transition:.35s var(--fx)}
|
| 316 |
+
.coco .av .bird{width:30px;height:30px;color:var(--night);filter:drop-shadow(0 1px 1px rgba(255,255,255,.3))}
|
| 317 |
+
/* accessory badge sits on the avatar, swapped per vibe */
|
| 318 |
+
.coco .acc{position:absolute;right:-6px;bottom:-6px;width:22px;height:22px;border-radius:50%;
|
| 319 |
+
display:grid;place-items:center;background:var(--night);border:2px solid var(--vc,var(--violet));
|
| 320 |
+
color:var(--vc,var(--violet));font-size:11px}
|
| 321 |
+
.coco .acc svg{width:12px;height:12px}
|
| 322 |
+
.coco .say{font-size:13px;line-height:1.45}
|
| 323 |
+
.coco .say b{color:var(--vc,var(--gold))}
|
| 324 |
+
.coco .who{font-family:'Bebas Neue',sans-serif;letter-spacing:.1em;font-size:11px;color:var(--vc,var(--violet));
|
| 325 |
+
display:block;margin-bottom:2px}
|
| 326 |
+
|
| 327 |
+
/* privacy footnote — visible, not buried */
|
| 328 |
+
.note{margin-top:26px;text-align:center;font-size:12.5px;color:var(--muted);line-height:1.6}
|
| 329 |
+
.note b{color:var(--cream)}
|
| 330 |
+
.note .lock{color:var(--cyan);vertical-align:-2px;margin-right:5px}
|
| 331 |
+
|
| 332 |
+
/* ============================================================
|
| 333 |
+
VIEW SWITCH
|
| 334 |
+
============================================================ */
|
| 335 |
+
.view{display:none}
|
| 336 |
+
.view.active{display:block;animation:viewIn .4s var(--fx)}
|
| 337 |
+
@keyframes viewIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
| 338 |
+
|
| 339 |
+
/* ============================================================
|
| 340 |
+
BROADCAST PLAYER
|
| 341 |
+
============================================================ */
|
| 342 |
+
.booth-top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px;flex-wrap:wrap}
|
| 343 |
+
.back{display:inline-flex;align-items:center;gap:8px;background:var(--stage);border:1px solid var(--line);
|
| 344 |
+
color:var(--cream);font-family:Nunito;font-weight:700;font-size:13px;padding:9px 14px;border-radius:10px;cursor:pointer;transition:.16s}
|
| 345 |
+
.back:hover{border-color:var(--violet)}
|
| 346 |
+
.orient{display:flex;gap:4px;background:var(--stage);border:1px solid var(--line-soft);padding:4px;border-radius:11px}
|
| 347 |
+
.orient button{background:transparent;border:0;color:var(--muted);font-family:'Bebas Neue',sans-serif;letter-spacing:.06em;
|
| 348 |
+
font-size:13px;padding:7px 14px;border-radius:8px;cursor:pointer;transition:.16s}
|
| 349 |
+
.orient button[aria-pressed="true"]{background:var(--raise);color:var(--gold)}
|
| 350 |
+
|
| 351 |
+
.booth{display:grid;grid-template-columns:1fr 320px;gap:18px;align-items:start}
|
| 352 |
+
/* portrait: the clip is a phone-sized vertical frame, never a giant column-filling block.
|
| 353 |
+
capped width keeps the 9:16 height sane and the play-by-play rail beside it. */
|
| 354 |
+
.booth.portrait{grid-template-columns:minmax(0,340px) minmax(0,1fr)}
|
| 355 |
+
.booth.portrait .screen-wrap{max-width:340px}
|
| 356 |
+
@media(max-width:840px){
|
| 357 |
+
.booth,.booth.square{grid-template-columns:1fr}
|
| 358 |
+
.booth.portrait{grid-template-columns:1fr}
|
| 359 |
+
.booth.portrait .screen-wrap{margin-inline:auto}
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
/* the screen */
|
| 363 |
+
.screen-wrap{background:#000;border-radius:18px;overflow:hidden;border:1px solid var(--line);
|
| 364 |
+
box-shadow:0 24px 70px rgba(0,0,0,.6);position:relative}
|
| 365 |
+
.screen{position:relative;width:100%;aspect-ratio:16/9;background:
|
| 366 |
+
repeating-linear-gradient(135deg,#1c0f33 0 18px,#170c2b 18px 36px);
|
| 367 |
+
display:grid;place-items:center}
|
| 368 |
+
.booth.portrait .screen{aspect-ratio:9/16}
|
| 369 |
+
.booth.square .screen{aspect-ratio:1/1}
|
| 370 |
+
.screen .ph{font-family:'Bebas Neue',sans-serif;letter-spacing:.12em;color:var(--muted-2);font-size:15px;
|
| 371 |
+
text-align:center;padding:0 20px}
|
| 372 |
+
.screen .ph svg{width:40px;height:40px;margin:0 auto 10px;color:var(--line);display:block}
|
| 373 |
+
|
| 374 |
+
/* on-air bug */
|
| 375 |
+
.bug{position:absolute;top:14px;left:14px;display:flex;align-items:center;gap:8px;white-space:nowrap;
|
| 376 |
+
background:rgba(15,6,30,.6);backdrop-filter:blur(4px);padding:7px 12px;border-radius:9px;
|
| 377 |
+
font-family:'Bebas Neue',sans-serif;letter-spacing:.12em;font-size:14px;color:var(--cream)}
|
| 378 |
+
.bug .dot{width:10px;height:10px;border-radius:50%;background:var(--hot);box-shadow:0 0 10px var(--hot)}
|
| 379 |
+
@media(prefers-reduced-motion:no-preference){.bug .dot{animation:tw 1.1s infinite}}
|
| 380 |
+
|
| 381 |
+
/* timecode */
|
| 382 |
+
.tc{position:absolute;top:14px;right:14px;background:rgba(15,6,30,.6);backdrop-filter:blur(4px);
|
| 383 |
+
padding:7px 12px;border-radius:9px;font-family:'Bebas Neue',sans-serif;letter-spacing:.1em;font-size:15px;
|
| 384 |
+
color:var(--cyan);font-variant-numeric:tabular-nums}
|
| 385 |
+
|
| 386 |
+
/* lower-third chyron */
|
| 387 |
+
.chyron{position:absolute;left:14px;right:14px;bottom:14px;border-radius:12px;overflow:hidden;
|
| 388 |
+
background:linear-gradient(100deg,rgba(15,6,30,.92),rgba(36,18,65,.82));
|
| 389 |
+
border:1px solid color-mix(in srgb,var(--cc,var(--hot)) 40%,var(--line));
|
| 390 |
+
box-shadow:0 10px 30px rgba(0,0,0,.4);
|
| 391 |
+
transform:translateY(130%);opacity:0;transition:.45s var(--fx)}
|
| 392 |
+
.chyron.in{transform:none;opacity:1}
|
| 393 |
+
.chyron .bar{height:4px;background:linear-gradient(90deg,var(--cc,var(--hot)),var(--gold))}
|
| 394 |
+
.chyron .row{display:flex;align-items:stretch}
|
| 395 |
+
.chyron .who{display:flex;align-items:center;font-family:'Bebas Neue',sans-serif;letter-spacing:.06em;
|
| 396 |
+
font-size:17px;color:var(--night);background:var(--cc,var(--hot));padding:10px 14px;white-space:nowrap}
|
| 397 |
+
.chyron .line{padding:10px 16px;font-family:Nunito;font-weight:700;font-size:15px;color:var(--cream);
|
| 398 |
+
display:flex;align-items:center;line-height:1.3}
|
| 399 |
+
@media(prefers-reduced-motion:no-preference){
|
| 400 |
+
.chyron.in .line{animation:sharpen .5s var(--fx)}
|
| 401 |
+
}
|
| 402 |
+
@keyframes sharpen{from{filter:blur(7px);opacity:.3}to{filter:blur(0);opacity:1}}
|
| 403 |
+
|
| 404 |
+
/* transport */
|
| 405 |
+
.transport{display:flex;align-items:center;gap:14px;margin-top:14px}
|
| 406 |
+
.transport .pp{width:48px;height:48px;border-radius:50%;border:0;cursor:pointer;flex:none;display:grid;place-items:center;
|
| 407 |
+
background:linear-gradient(135deg,var(--gold),var(--hot));color:var(--night);font-size:20px;transition:.16s}
|
| 408 |
+
.transport .pp:hover{transform:scale(1.05)}
|
| 409 |
+
.scrub{flex:1;height:6px;border-radius:4px;background:var(--raise);position:relative;cursor:pointer}
|
| 410 |
+
.scrub .fill{position:absolute;left:0;top:0;bottom:0;border-radius:4px;width:0;
|
| 411 |
+
background:linear-gradient(90deg,var(--hot),var(--gold))}
|
| 412 |
+
.scrub .knob{position:absolute;top:50%;width:14px;height:14px;border-radius:50%;background:var(--cream);
|
| 413 |
+
transform:translate(-50%,-50%);left:0;box-shadow:0 2px 6px rgba(0,0,0,.5)}
|
| 414 |
+
|
| 415 |
+
/* play-by-play rail */
|
| 416 |
+
.pbp{background:var(--stage);border:1px solid var(--line-soft);border-radius:16px;overflow:hidden;
|
| 417 |
+
display:flex;flex-direction:column;max-height:520px}
|
| 418 |
+
.pbp .pbp-h{display:flex;align-items:center;gap:9px;padding:14px 16px;border-bottom:1px solid var(--line-soft)}
|
| 419 |
+
.pbp .pbp-h .ic{color:var(--gold);font-size:16px}
|
| 420 |
+
.pbp .pbp-h h3{font-family:Unbounded,sans-serif;font-weight:600;font-size:13px}
|
| 421 |
+
.pbp .pbp-h .ct{margin-left:auto;font-family:'Bebas Neue',sans-serif;letter-spacing:.06em;font-size:12px;color:var(--muted-2)}
|
| 422 |
+
.pbp .lines{overflow-y:auto;padding:6px}
|
| 423 |
+
.pbp .pl{display:flex;gap:11px;padding:11px 12px;border-radius:10px;cursor:pointer;transition:.15s;align-items:baseline}
|
| 424 |
+
.pbp .pl:hover{background:var(--stage-2)}
|
| 425 |
+
.pbp .pl .t{font-family:'Bebas Neue',sans-serif;letter-spacing:.04em;font-size:14px;color:var(--muted-2);
|
| 426 |
+
flex:none;width:42px;font-variant-numeric:tabular-nums;padding-top:1px}
|
| 427 |
+
.pbp .pl .tx{font-size:13px;line-height:1.45;color:var(--muted)}
|
| 428 |
+
.pbp .pl .tx b{color:var(--cream);font-weight:800}
|
| 429 |
+
.pbp .pl.now{background:color-mix(in srgb,var(--cc,var(--hot)) 14%,var(--stage-2))}
|
| 430 |
+
.pbp .pl.now .t{color:var(--cc,var(--hot))}
|
| 431 |
+
.pbp .pl.now .tx{color:var(--cream)}
|
| 432 |
+
.pbp .pl.now .tx b{color:var(--cc,var(--hot))}
|
| 433 |
+
|
| 434 |
+
/* export bar */
|
| 435 |
+
.export{padding:13px 16px;border-top:1px solid var(--line-soft);background:var(--night-2)}
|
| 436 |
+
.export .btn-srt{width:100%;display:flex;align-items:center;justify-content:center;gap:9px;cursor:pointer;
|
| 437 |
+
background:var(--stage-2);border:1px solid var(--line);color:var(--cream);font-family:Nunito;font-weight:700;
|
| 438 |
+
font-size:13px;padding:12px;border-radius:11px;transition:.16s}
|
| 439 |
+
.export .btn-srt:hover{border-color:var(--cyan);color:var(--cyan)}
|
| 440 |
+
.export .btn-srt .ic{color:var(--cyan);font-size:16px}
|
| 441 |
+
.export .hint{text-align:center;font-size:11px;color:var(--muted-2);margin-top:8px}
|
| 442 |
+
.export .hint b{color:var(--muted)}
|
| 443 |
+
|
| 444 |
+
/* confetti */
|
| 445 |
+
.confetti{position:fixed;top:-20px;width:11px;height:14px;z-index:90;pointer-events:none;border-radius:2px}
|
| 446 |
+
@media(prefers-reduced-motion:no-preference){.confetti{animation:fall linear forwards}}
|
| 447 |
+
@keyframes fall{to{transform:translateY(105vh) rotate(540deg);opacity:.2}}
|
| 448 |
+
|
| 449 |
+
/* ============================================================
|
| 450 |
+
UTILITIES
|
| 451 |
+
============================================================ */
|
| 452 |
+
:focus-visible{outline:2px solid var(--cyan);outline-offset:3px;border-radius:8px}
|
| 453 |
+
.toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%) translateY(20px);opacity:0;
|
| 454 |
+
background:var(--stage-2);border:1px solid var(--cyan);color:var(--cream);padding:12px 20px;border-radius:12px;
|
| 455 |
+
font-weight:700;font-size:13px;z-index:95;transition:.3s var(--fx);display:flex;align-items:center;gap:9px;
|
| 456 |
+
box-shadow:0 14px 40px rgba(0,0,0,.5)}
|
| 457 |
+
.toast.show{transform:translateX(-50%);opacity:1}
|
| 458 |
+
.toast .ic{color:var(--cyan)}
|
| 459 |
+
|
| 460 |
+
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
| 461 |
+
|
| 462 |
+
@media(max-width:560px){
|
| 463 |
+
.masthead{gap:14px}
|
| 464 |
+
.rundown .sub{display:none}
|
| 465 |
+
.vibes{grid-template-columns:1fr}
|
| 466 |
+
.roster{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
/* ============================================================
|
| 470 |
+
INTEGRATION ADDITIONS — real <video> in the screen, real face
|
| 471 |
+
crops in star cards (added for the Gradio wiring)
|
| 472 |
+
============================================================ */
|
| 473 |
+
.screen-vid{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;display:none}
|
| 474 |
+
.screen.has-video .ph{display:none}
|
| 475 |
+
.screen.has-video .screen-vid{display:block}
|
| 476 |
+
.portrait .head-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
| 477 |
+
.star.named .head-img{filter:saturate(1.05)}
|
| 478 |
+
</style>
|
| 479 |
+
</head>
|
| 480 |
+
<body>
|
| 481 |
+
|
| 482 |
+
<!-- =======================================================
|
| 483 |
+
ICON SPRITE — line icons, broadcast vernacular
|
| 484 |
+
======================================================= -->
|
| 485 |
+
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
| 486 |
+
<defs>
|
| 487 |
+
<symbol id="i-clap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><path d="M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Z"/><path d="m3 8 1.6-3.4 3.8 1L6.7 9M9.4 5.6l3.8 1L11.5 10M15 7l3.8 1-1.5 3.4M21 8l-.8-2.2-3.4-.9"/></symbol>
|
| 488 |
+
<symbol id="i-play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></symbol>
|
| 489 |
+
<symbol id="i-pause" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></symbol>
|
| 490 |
+
<symbol id="i-star" viewBox="0 0 24 24" fill="currentColor"><path d="m12 2 2.9 6.3 6.9.8-5.1 4.7 1.4 6.8L12 17.8 5.9 20.6l1.4-6.8L2.2 9.1l6.9-.8z"/></symbol>
|
| 491 |
+
<symbol id="i-spot" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 3v3M5 6l2 2M19 6l-2 2"/><path d="M9 9h6l3 11H6z" fill="currentColor" opacity=".15"/><path d="M9 9h6l3 11H6z"/></symbol>
|
| 492 |
+
<symbol id="i-crown" viewBox="0 0 24 24" fill="currentColor"><path d="M3 7l4 4 5-7 5 7 4-4-2 12H5z"/></symbol>
|
| 493 |
+
<symbol id="i-bird" viewBox="0 0 24 24" fill="currentColor"><path d="M14 4a3 3 0 0 0-3 3c0 .5-.4 1-1 1.5-1.8 1.3-3 3.4-3 5.8C7 17.9 9.6 20 13 20c.6 0 1-.6.8-1.2C13.3 17 13 16 14 15c2.2-.4 4-2.3 4-4.8V7a3 3 0 0 0-3-3zm.6 3.2a.9.9 0 1 1 0-.1z"/></symbol>
|
| 494 |
+
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></symbol>
|
| 495 |
+
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v10m0 0 4-4m-4 4-4-4M5 20h14"/></symbol>
|
| 496 |
+
<symbol id="i-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M6 11a6 6 0 0 0 12 0M12 17v4"/></symbol>
|
| 497 |
+
<symbol id="i-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01"/></symbol>
|
| 498 |
+
<symbol id="i-whistle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M3 11a5 5 0 0 0 5 5h4l5 3v-8a3 3 0 0 0-3-3H8a5 5 0 0 0-5 3Z"/><path d="M14 4v3"/></symbol>
|
| 499 |
+
<symbol id="i-glam" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M3 14c2-1 4-1 5 0M16 14c2-1 4-1 5 0M3 11h18M7 11l1 5M17 11l-1 5"/><circle cx="6.5" cy="8.5" r="2"/><circle cx="17.5" cy="8.5" r="2"/></symbol>
|
| 500 |
+
<symbol id="i-paw" viewBox="0 0 24 24" fill="currentColor"><circle cx="6" cy="9" r="2"/><circle cx="11" cy="6" r="2"/><circle cx="16" cy="7" r="2"/><circle cx="19" cy="11" r="2"/><path d="M12 12c-3 0-6 2.5-6 5 0 2 2 3 6 3s6-1 6-3c0-2.5-3-5-6-5Z"/></symbol>
|
| 501 |
+
<symbol id="i-glove" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><path d="M7 10V6a2 2 0 0 1 4 0v3M11 9V5a2 2 0 0 1 4 0v5l2 1a3 3 0 0 1 1.4 3.4l-.8 3A2 2 0 0 1 15.7 21H10a3 3 0 0 1-2.8-2L5.5 14A2.5 2.5 0 0 1 7 10Z"/></symbol>
|
| 502 |
+
<symbol id="i-cam" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3z"/></symbol>
|
| 503 |
+
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m4 12 5 5L20 6"/></symbol>
|
| 504 |
+
<symbol id="i-back" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></symbol>
|
| 505 |
+
<symbol id="i-portrait" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="8" r="4.4"/><path d="M3.5 22c0-4.7 3.8-8 8.5-8s8.5 3.3 8.5 8z"/></symbol>
|
| 506 |
+
</defs>
|
| 507 |
+
</svg>
|
| 508 |
+
|
| 509 |
+
<div class="shell">
|
| 510 |
+
|
| 511 |
+
<!-- ============== MASTHEAD ============== -->
|
| 512 |
+
<header class="masthead">
|
| 513 |
+
<div class="brand">
|
| 514 |
+
<div class="sign">
|
| 515 |
+
<div class="bulbs t"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
| 516 |
+
<h1 class="disp">MARQUEE</h1>
|
| 517 |
+
<div class="bulbs b"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
| 518 |
+
</div>
|
| 519 |
+
<div class="tag">Everyone's a headliner.<small>Turn any clip into a live broadcast.</small></div>
|
| 520 |
+
</div>
|
| 521 |
+
<div class="broadcast-meta">
|
| 522 |
+
<span class="clock" id="clock">--:--:--</span>
|
| 523 |
+
<span class="status" id="status"><span class="led"></span><span id="statusTxt">Standby</span></span>
|
| 524 |
+
</div>
|
| 525 |
+
</header>
|
| 526 |
+
|
| 527 |
+
<!-- ============================================================
|
| 528 |
+
VIEW 1 — SETUP CONSOLE
|
| 529 |
+
============================================================ -->
|
| 530 |
+
<section class="view active" id="setupView">
|
| 531 |
+
|
| 532 |
+
<!-- rundown / running order -->
|
| 533 |
+
<nav class="rundown" id="rundown" aria-label="Show rundown">
|
| 534 |
+
<div class="cue on" data-step="1"><div class="node">01</div><div class="ttl">Roll Tape</div><div class="sub">Drop a clip</div></div>
|
| 535 |
+
<div class="cue" data-step="2"><div class="node">02</div><div class="ttl">Casting</div><div class="sub">Find the faces</div></div>
|
| 536 |
+
<div class="cue" data-step="3"><div class="node">03</div><div class="ttl">Star Select</div><div class="sub">Name the stars</div></div>
|
| 537 |
+
<div class="cue" data-step="4"><div class="node">04</div><div class="ttl">Showtime</div><div class="sub">Go live</div></div>
|
| 538 |
+
</nav>
|
| 539 |
+
|
| 540 |
+
<div class="console">
|
| 541 |
+
|
| 542 |
+
<!-- ---------- LEFT: the control desk ---------- -->
|
| 543 |
+
<div class="desk">
|
| 544 |
+
|
| 545 |
+
<!-- Roll Tape -->
|
| 546 |
+
<div class="card" id="rollCard">
|
| 547 |
+
<div class="card-h"><svg class="i ic"><use href="#i-clap"/></svg><h2>Roll Tape</h2><span class="meta">SRC 01</span></div>
|
| 548 |
+
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Drop a video clip to roll tape">
|
| 549 |
+
<div class="ask">
|
| 550 |
+
<span class="clap"><svg class="i"><use href="#i-clap"/></svg></span>
|
| 551 |
+
<div class="t">Drop a clip on the desk</div>
|
| 552 |
+
<div class="s">MP4 · MOV · up to 60s · portrait, landscape or square</div>
|
| 553 |
+
</div>
|
| 554 |
+
<div class="loaded">
|
| 555 |
+
<div class="strip"><span class="play"><svg class="i"><use href="#i-play"/></svg></span></div>
|
| 556 |
+
<div>
|
| 557 |
+
<div class="fn" id="fileName">rooftop_freekick.mp4</div>
|
| 558 |
+
<div class="fd"><span id="fileDur">00:42</span> · 1080×1920 · ready to cast</div>
|
| 559 |
+
</div>
|
| 560 |
+
</div>
|
| 561 |
+
</div>
|
| 562 |
+
<input type="file" id="fileInput" accept="video/*" hidden>
|
| 563 |
+
<button class="btn cyan mt" id="castBtn" disabled><svg class="i"><use href="#i-spot"/></svg> Find the stars</button>
|
| 564 |
+
<div class="sweep"></div>
|
| 565 |
+
</div>
|
| 566 |
+
|
| 567 |
+
<!-- Pick the show / vibes -->
|
| 568 |
+
<div class="card" id="vibeCard">
|
| 569 |
+
<div class="card-h"><svg class="i ic"><use href="#i-mic"/></svg><h2>Pick the show</h2><span class="meta">TONE</span></div>
|
| 570 |
+
<div class="vibes" id="vibes" role="group" aria-label="Narration vibe">
|
| 571 |
+
<button class="vibe" data-vibe="football" style="--vc:var(--star-lime)" aria-pressed="false">
|
| 572 |
+
<span class="ic"><svg class="i"><use href="#i-whistle"/></svg></span>
|
| 573 |
+
<span><span class="vt">Football</span><span class="vd">Roaring play-by-play</span></span>
|
| 574 |
+
</button>
|
| 575 |
+
<button class="vibe" data-vibe="diva" style="--vc:var(--star-hot)" aria-pressed="false">
|
| 576 |
+
<span class="ic"><svg class="i"><use href="#i-glam"/></svg></span>
|
| 577 |
+
<span><span class="vt">Diva Hour</span><span class="vd">Red-carpet shade</span></span>
|
| 578 |
+
</button>
|
| 579 |
+
<button class="vibe" data-vibe="wildlife" style="--vc:var(--star-cyan)" aria-pressed="false">
|
| 580 |
+
<span class="ic"><svg class="i"><use href="#i-paw"/></svg></span>
|
| 581 |
+
<span><span class="vt">Wildlife</span><span class="vd">Hushed nature doc</span></span>
|
| 582 |
+
</button>
|
| 583 |
+
<button class="vibe" data-vibe="boxing" style="--vc:var(--star-tang)" aria-pressed="false">
|
| 584 |
+
<span class="ic"><svg class="i"><use href="#i-glove"/></svg></span>
|
| 585 |
+
<span><span class="vt">Boxing</span><span class="vd">Ringside hype</span></span>
|
| 586 |
+
</button>
|
| 587 |
+
</div>
|
| 588 |
+
|
| 589 |
+
<!-- Coco the host — re-dresses per vibe -->
|
| 590 |
+
<div class="coco" id="coco">
|
| 591 |
+
<div class="av">
|
| 592 |
+
<svg class="i bird"><use href="#i-bird"/></svg>
|
| 593 |
+
<span class="acc" id="cocoAcc"><svg><use href="#i-star"/></svg></span>
|
| 594 |
+
</div>
|
| 595 |
+
<div class="say">
|
| 596 |
+
<span class="who">Coco · your host</span>
|
| 597 |
+
<span id="cocoLine">Pick a show and I'll set the mood. I dress for the occasion.</span>
|
| 598 |
+
</div>
|
| 599 |
+
</div>
|
| 600 |
+
</div>
|
| 601 |
+
|
| 602 |
+
<!-- Showtime -->
|
| 603 |
+
<button class="btn show" id="liveBtn" disabled><svg class="i"><use href="#i-star"/></svg> Showtime — go live</button>
|
| 604 |
+
|
| 605 |
+
</div>
|
| 606 |
+
|
| 607 |
+
<!-- ---------- RIGHT: the stage ---------- -->
|
| 608 |
+
<div class="stage-panel" id="stagePanel">
|
| 609 |
+
<div class="stage-h">
|
| 610 |
+
<svg class="i ic"><use href="#i-spot"/></svg>
|
| 611 |
+
<h2>Star Select</h2>
|
| 612 |
+
<span class="count" id="starCount">NO CAST YET</span>
|
| 613 |
+
</div>
|
| 614 |
+
|
| 615 |
+
<!-- empty -->
|
| 616 |
+
<div class="empty" id="stageEmpty">
|
| 617 |
+
<div class="spot"><svg class="i"><use href="#i-spot"/></svg></div>
|
| 618 |
+
<div class="t">The stage is dark</div>
|
| 619 |
+
<div class="s">Roll a clip and hit <b>Find the stars</b> — everyone we spot gets a card under the spotlight.</div>
|
| 620 |
+
</div>
|
| 621 |
+
|
| 622 |
+
<!-- roster (filled by JS) -->
|
| 623 |
+
<div class="roster" id="roster" hidden></div>
|
| 624 |
+
</div>
|
| 625 |
+
|
| 626 |
+
</div>
|
| 627 |
+
|
| 628 |
+
<p class="note">
|
| 629 |
+
<svg class="i lock"><use href="#i-lock"/></svg>
|
| 630 |
+
Your clip stays on <b>your machine</b>. Faces are detected locally and never uploaded — Marquee only writes the captions you see.
|
| 631 |
+
</p>
|
| 632 |
+
</section>
|
| 633 |
+
|
| 634 |
+
<!-- ============================================================
|
| 635 |
+
VIEW 2 — BROADCAST PLAYER
|
| 636 |
+
============================================================ -->
|
| 637 |
+
<section class="view" id="broadcastView">
|
| 638 |
+
<div class="booth-top">
|
| 639 |
+
<button class="back" id="backBtn"><svg class="i"><use href="#i-back"/></svg> Back to console</button>
|
| 640 |
+
<div class="orient" role="group" aria-label="Clip orientation">
|
| 641 |
+
<button data-orient="landscape" aria-pressed="true">16:9</button>
|
| 642 |
+
<button data-orient="square" aria-pressed="false">1:1</button>
|
| 643 |
+
<button data-orient="portrait" aria-pressed="false">9:16</button>
|
| 644 |
+
</div>
|
| 645 |
+
</div>
|
| 646 |
+
|
| 647 |
+
<div class="booth" id="booth">
|
| 648 |
+
<!-- the screen -->
|
| 649 |
+
<div>
|
| 650 |
+
<div class="screen-wrap">
|
| 651 |
+
<div class="screen" id="screen">
|
| 652 |
+
<video id="vid" class="screen-vid" playsinline preload="metadata"></video>
|
| 653 |
+
<div class="ph"><svg class="i"><use href="#i-cam"/></svg>YOUR CLIP PLAYS HERE</div>
|
| 654 |
+
<div class="bug"><span class="dot"></span> ON AIR</div>
|
| 655 |
+
<div class="tc" id="tc">00:00</div>
|
| 656 |
+
<!-- lower-third chyron -->
|
| 657 |
+
<div class="chyron" id="chyron">
|
| 658 |
+
<div class="bar"></div>
|
| 659 |
+
<div class="row"><span class="who" id="chyWho">COCO</span><span class="line" id="chyLine">Standing by…</span></div>
|
| 660 |
+
</div>
|
| 661 |
+
</div>
|
| 662 |
+
</div>
|
| 663 |
+
<div class="transport">
|
| 664 |
+
<button class="pp" id="pp" aria-label="Play / pause"><svg class="i"><use href="#i-play"/></svg></button>
|
| 665 |
+
<div class="scrub" id="scrub"><div class="fill" id="fill"></div><div class="knob" id="knob"></div></div>
|
| 666 |
+
<span class="clock" id="tcDur" style="font-size:16px">00:42</span>
|
| 667 |
+
</div>
|
| 668 |
+
</div>
|
| 669 |
+
|
| 670 |
+
<!-- play-by-play rail -->
|
| 671 |
+
<div class="pbp">
|
| 672 |
+
<div class="pbp-h"><svg class="i ic"><use href="#i-list"/></svg><h3>Play-by-play</h3><span class="ct" id="pbpCt">— lines</span></div>
|
| 673 |
+
<div class="lines" id="lines"></div>
|
| 674 |
+
<div class="export">
|
| 675 |
+
<button class="btn-srt" id="srtBtn"><svg class="i ic"><use href="#i-download"/></svg> Download captions (.srt)</button>
|
| 676 |
+
<div class="hint">Drops straight into <b>CapCut</b>, Premiere & DaVinci — timed to your clip.</div>
|
| 677 |
+
</div>
|
| 678 |
+
</div>
|
| 679 |
+
</div>
|
| 680 |
+
</section>
|
| 681 |
+
|
| 682 |
+
</div>
|
| 683 |
+
|
| 684 |
+
<div class="toast" id="toast"><svg class="i ic"><use href="#i-check"/></svg><span id="toastTxt">Done</span></div>
|
| 685 |
+
|
| 686 |
+
<script type="module">/* ============================================================
|
| 687 |
+
MARQUEE — flow engine, wired to the Gradio backend.
|
| 688 |
+
Runs as a module inside the app iframe.
|
| 689 |
+
Presentation code is unchanged from the design; the mock data
|
| 690 |
+
hooks (casting, commentary, playback) now call the real pipeline.
|
| 691 |
+
============================================================ */
|
| 692 |
+
import * as GradioClient from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
|
| 693 |
+
const Client = GradioClient.Client;
|
| 694 |
+
const handle_file = GradioClient.handle_file || ((f) => f);
|
| 695 |
+
|
| 696 |
+
const $ = (s, r = document) => r.querySelector(s);
|
| 697 |
+
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
|
| 698 |
+
const reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;
|
| 699 |
+
const pad = n => String(n).padStart(2, '0');
|
| 700 |
+
|
| 701 |
+
/* ---------- backend client (connects to this same Space) ---------- */
|
| 702 |
+
let _client = null;
|
| 703 |
+
async function api() {
|
| 704 |
+
if (!_client) _client = await Client.connect(window.location.origin);
|
| 705 |
+
return _client;
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
/* ---------- session state from the backend ---------- */
|
| 709 |
+
let SESSION = null, VIDEO_URL = null, DUR = 0, REAL_SCRIPT = [], selectedFile = null;
|
| 710 |
+
|
| 711 |
+
/* ---------- auto-fit the iframe to content (same-origin srcdoc) ---------- */
|
| 712 |
+
function fitFrame() {
|
| 713 |
+
try { if (window.frameElement) window.frameElement.style.height = document.documentElement.scrollHeight + 'px'; }
|
| 714 |
+
catch (e) {}
|
| 715 |
+
}
|
| 716 |
+
new ResizeObserver(fitFrame).observe(document.body);
|
| 717 |
+
window.addEventListener('load', fitFrame);
|
| 718 |
+
|
| 719 |
+
/* ---------- live clock ---------- */
|
| 720 |
+
const clocks = $$('#clock');
|
| 721 |
+
function fmtClock(d){return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;}
|
| 722 |
+
setInterval(()=>{const t=fmtClock(new Date());clocks.forEach(c=>c.textContent=t);},1000);
|
| 723 |
+
clocks.forEach(c=>c.textContent=fmtClock(new Date()));
|
| 724 |
+
|
| 725 |
+
/* ---------- status pill ---------- */
|
| 726 |
+
const status=$('#status'), statusTxt=$('#statusTxt');
|
| 727 |
+
function setStatus(txt,live){statusTxt.textContent=txt;status.classList.toggle('live',!!live);}
|
| 728 |
+
|
| 729 |
+
/* ---------- rundown ---------- */
|
| 730 |
+
const cues=$$('#rundown .cue');
|
| 731 |
+
function setStep(n){
|
| 732 |
+
cues.forEach(c=>{
|
| 733 |
+
const s=+c.dataset.step;
|
| 734 |
+
c.classList.toggle('done',s<n);
|
| 735 |
+
c.classList.toggle('on',s===n);
|
| 736 |
+
if(s<n)c.querySelector('.node').innerHTML='<svg class="i" style="width:18px;height:18px"><use href="#i-check"/></svg>';
|
| 737 |
+
else c.querySelector('.node').textContent='0'+s;
|
| 738 |
+
});
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
/* ---------- cast colors + tiers (presentation) ---------- */
|
| 742 |
+
const COLORS=['--star-gold','--star-hot','--star-cyan','--star-violet','--star-lime','--star-tang'];
|
| 743 |
+
let stars=[]; // {name, color, tier, crop}
|
| 744 |
+
|
| 745 |
+
/* ---------- VIBES — Coco's wardrobe + the show's voice ----------
|
| 746 |
+
NOTE: the play-by-play LINES are no longer templated here — the real
|
| 747 |
+
commentary comes from the VLM. We keep Coco's persona + colors. */
|
| 748 |
+
const VIBES={
|
| 749 |
+
football:{ acc:'#i-whistle', vc:'--star-lime', who:'COCO · TOUCHLINE',
|
| 750 |
+
coco:"Whistle's on. I'm calling this one like a cup final — keep your eyes on the pitch!"},
|
| 751 |
+
diva:{ acc:'#i-glam', vc:'--star-hot', who:'COCO · RED CARPET',
|
| 752 |
+
coco:"Shades on, boa fluffed. Darling, we are SO ready for the carpet. Bring the drama."},
|
| 753 |
+
wildlife:{ acc:'#i-paw', vc:'--star-cyan', who:'COCO · FIELD UNIT',
|
| 754 |
+
coco:"*whispering* …we must be very quiet now. A rare specimen has entered the frame."},
|
| 755 |
+
boxing:{ acc:'#i-glove', vc:'--star-tang', who:'COCO · RINGSIDE',
|
| 756 |
+
coco:"Gloves laced, ringside seat warm. Ladies and gentlemen… in the RED corner!"}
|
| 757 |
+
};
|
| 758 |
+
let currentVibe=null;
|
| 759 |
+
|
| 760 |
+
/* ---------- ROLL TAPE (real file) ---------- */
|
| 761 |
+
const drop=$('#drop'), castBtn=$('#castBtn'), liveBtn=$('#liveBtn'), rollCard=$('#rollCard');
|
| 762 |
+
const fileInput=$('#fileInput'), fileName=$('#fileName'), fileDur=$('#fileDur');
|
| 763 |
+
|
| 764 |
+
function handleFile(file){
|
| 765 |
+
if(!file) return;
|
| 766 |
+
selectedFile=file;
|
| 767 |
+
drop.classList.add('loaded');
|
| 768 |
+
fileName.textContent=file.name;
|
| 769 |
+
fileDur.textContent='reading…';
|
| 770 |
+
const url=URL.createObjectURL(file);
|
| 771 |
+
const probe=document.createElement('video'); probe.preload='metadata';
|
| 772 |
+
probe.onloadedmetadata=()=>{ fileDur.textContent=fmt(probe.duration)+' · ready to cast'; URL.revokeObjectURL(url); };
|
| 773 |
+
probe.onerror=()=>{ fileDur.textContent='ready to cast'; };
|
| 774 |
+
probe.src=url;
|
| 775 |
+
castBtn.disabled=false;
|
| 776 |
+
setStatus('Tape loaded'); toast('Tape loaded — ready to cast');
|
| 777 |
+
}
|
| 778 |
+
drop.addEventListener('click',()=>fileInput.click());
|
| 779 |
+
drop.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();fileInput.click();}});
|
| 780 |
+
fileInput.addEventListener('change',e=>handleFile(e.target.files[0]));
|
| 781 |
+
['dragover','dragenter'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.style.borderColor='var(--cyan)';}));
|
| 782 |
+
['dragleave','drop'].forEach(ev=>drop.addEventListener(ev,e=>{
|
| 783 |
+
e.preventDefault();drop.style.borderColor='';
|
| 784 |
+
if(ev==='drop'&&e.dataTransfer.files[0])handleFile(e.dataTransfer.files[0]);
|
| 785 |
+
}));
|
| 786 |
+
|
| 787 |
+
/* ---------- CASTING (real scan) ---------- */
|
| 788 |
+
const stagePanel=$('#stagePanel'), stageEmpty=$('#stageEmpty'), roster=$('#roster'), starCount=$('#starCount');
|
| 789 |
+
castBtn.addEventListener('click',startCasting);
|
| 790 |
+
|
| 791 |
+
async function startCasting(){
|
| 792 |
+
if(castBtn.disabled||!selectedFile) return;
|
| 793 |
+
castBtn.disabled=true; rollCard.classList.add('casting');
|
| 794 |
+
setStep(2); setStatus('Casting…');
|
| 795 |
+
castBtn.innerHTML='<svg class="i"><use href="#i-spot"/></svg> Scanning the tape…';
|
| 796 |
+
try{
|
| 797 |
+
const c=await api();
|
| 798 |
+
const res=await c.predict("/scan",[handle_file(selectedFile)]);
|
| 799 |
+
const data=res.data[0];
|
| 800 |
+
if(!data||!data.ok) throw new Error((data&&data.error)||'scan failed');
|
| 801 |
+
SESSION=data.session_id; VIDEO_URL=data.video_url; DUR=data.duration||0;
|
| 802 |
+
setBoothOrient(data.w,data.h);
|
| 803 |
+
const vid=$('#vid'); vid.src=VIDEO_URL; $('#screen').classList.add('has-video');
|
| 804 |
+
revealCast(data.stars||[]);
|
| 805 |
+
}catch(err){
|
| 806 |
+
setStatus('Casting failed');
|
| 807 |
+
toast('Casting failed: '+(err.message||err));
|
| 808 |
+
rollCard.classList.remove('casting');
|
| 809 |
+
castBtn.disabled=false;
|
| 810 |
+
castBtn.innerHTML='<svg class="i"><use href="#i-spot"/></svg> Find the stars';
|
| 811 |
+
}
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
function revealCast(starData){
|
| 815 |
+
rollCard.classList.remove('casting');
|
| 816 |
+
castBtn.innerHTML='<svg class="i"><use href="#i-spot"/></svg> Re-cast';
|
| 817 |
+
castBtn.disabled=false;
|
| 818 |
+
setStep(3); setStatus(starData.length+' stars found');
|
| 819 |
+
|
| 820 |
+
stars = starData.map((s,i)=>({name:'',tier:s.tier||'STAR',color:COLORS[i%COLORS.length],crop:s.crop||null}));
|
| 821 |
+
stageEmpty.style.display='none';
|
| 822 |
+
roster.hidden=false; roster.innerHTML='';
|
| 823 |
+
starCount.textContent=stars.length+' STARS FOUND';
|
| 824 |
+
stars.forEach((s,i)=>roster.appendChild(buildStar(s,i)));
|
| 825 |
+
|
| 826 |
+
if(!stars.length){
|
| 827 |
+
stageEmpty.style.display='';
|
| 828 |
+
stageEmpty.querySelector('.t').textContent='No stars spotted';
|
| 829 |
+
stageEmpty.querySelector('.s').innerHTML='No faces found — but the show still goes on. Pick a vibe and go live for nameless narration.';
|
| 830 |
+
roster.hidden=true;
|
| 831 |
+
}
|
| 832 |
+
toast(stars.length?stars.length+' stars found — give them stage names':'No faces found — you can still go live');
|
| 833 |
+
refreshLive();
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
/* ---------- THE STAR CARD (one reusable block) ---------- */
|
| 837 |
+
function buildStar(s,i){
|
| 838 |
+
const el=document.createElement('div');
|
| 839 |
+
el.className='star';
|
| 840 |
+
el.style.setProperty('--c',`var(${s.color})`);
|
| 841 |
+
if(!reduce){
|
| 842 |
+
el.animate(
|
| 843 |
+
[{opacity:0,transform:'translateY(14px) scale(.98)'},{opacity:1,transform:'none'}],
|
| 844 |
+
{duration:480,delay:i*90,easing:'cubic-bezier(.2,.9,.25,1)',fill:'backwards'}
|
| 845 |
+
);
|
| 846 |
+
}
|
| 847 |
+
const portrait = s.crop
|
| 848 |
+
? `<img class="head-img" src="${s.crop}" alt="Player ${i+1}">`
|
| 849 |
+
: `<svg class="i head-ph" style="width:64px;height:68px"><use href="#i-portrait"/></svg>`;
|
| 850 |
+
el.innerHTML=`
|
| 851 |
+
<div class="portrait">
|
| 852 |
+
<div class="beam"></div>
|
| 853 |
+
${portrait}
|
| 854 |
+
<div class="frame"></div>
|
| 855 |
+
<span class="idx"><span class="st">★</span>${i+1}</span>
|
| 856 |
+
<span class="tier">${s.tier}</span>
|
| 857 |
+
<svg class="i crown"><use href="#i-crown"/></svg>
|
| 858 |
+
</div>
|
| 859 |
+
<div class="star-body">
|
| 860 |
+
<label for="star${i}">Stage name</label>
|
| 861 |
+
<input id="star${i}" type="text" placeholder="e.g. The Maestro" maxlength="22" autocomplete="off">
|
| 862 |
+
</div>`;
|
| 863 |
+
const input=el.querySelector('input');
|
| 864 |
+
input.addEventListener('input',()=>{
|
| 865 |
+
const v=input.value.trim();
|
| 866 |
+
s.name=v;
|
| 867 |
+
const wasNamed=el.classList.contains('named');
|
| 868 |
+
el.classList.toggle('named',!!v);
|
| 869 |
+
if(v && !wasNamed && !reduce) popCrown(el);
|
| 870 |
+
refreshLive();
|
| 871 |
+
});
|
| 872 |
+
return el;
|
| 873 |
+
}
|
| 874 |
+
function popCrown(el){
|
| 875 |
+
const c=el.querySelector('.crown');
|
| 876 |
+
c.style.animation='none';void c.offsetWidth;
|
| 877 |
+
c.animate([{transform:'translateX(-50%) translateY(10px) scale(.4) rotate(-12deg)',opacity:0},
|
| 878 |
+
{transform:'translateX(-50%) scale(1.25) rotate(8deg)',opacity:1,offset:.6},
|
| 879 |
+
{transform:'translateX(-50%) scale(1) rotate(0)',opacity:1}],
|
| 880 |
+
{duration:480,easing:'cubic-bezier(.2,1.7,.4,1)'});
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
/* ---------- VIBES + COCO ---------- */
|
| 884 |
+
const vibesWrap=$('#vibes'), coco=$('#coco'), cocoLine=$('#cocoLine'),
|
| 885 |
+
cocoAcc=$('#cocoAcc'), cocoWho=coco.querySelector('.who');
|
| 886 |
+
vibesWrap.addEventListener('click',e=>{
|
| 887 |
+
const b=e.target.closest('.vibe'); if(!b)return;
|
| 888 |
+
$$('.vibe',vibesWrap).forEach(v=>v.setAttribute('aria-pressed','false'));
|
| 889 |
+
b.setAttribute('aria-pressed','true');
|
| 890 |
+
currentVibe=b.dataset.vibe;
|
| 891 |
+
dressCoco(currentVibe);
|
| 892 |
+
refreshLive();
|
| 893 |
+
});
|
| 894 |
+
function dressCoco(v){
|
| 895 |
+
const d=VIBES[v]; if(!d)return;
|
| 896 |
+
coco.style.setProperty('--vc',`var(${d.vc})`);
|
| 897 |
+
cocoAcc.innerHTML=`<svg><use href="${d.acc}"/></svg>`;
|
| 898 |
+
cocoWho.textContent=d.who;
|
| 899 |
+
cocoLine.textContent=d.coco;
|
| 900 |
+
if(!reduce){
|
| 901 |
+
coco.animate([{transform:'scale(.98)',opacity:.6},{transform:'scale(1)',opacity:1}],{duration:300,easing:'ease-out'});
|
| 902 |
+
const av=coco.querySelector('.av');
|
| 903 |
+
av.animate([{transform:'rotate(-8deg) scale(.9)'},{transform:'rotate(6deg) scale(1.08)'},{transform:'none'}],{duration:420,easing:'cubic-bezier(.2,1.7,.4,1)'});
|
| 904 |
+
}
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
/* ---------- LIVE-READY GATE ----------
|
| 908 |
+
ready when a vibe is picked (names optional — nameless narration is allowed) */
|
| 909 |
+
function namedCount(){return stars.filter(s=>s.name).length;}
|
| 910 |
+
function refreshLive(){
|
| 911 |
+
liveBtn.disabled=!(currentVibe && SESSION);
|
| 912 |
+
if(stars.length){
|
| 913 |
+
const n=namedCount();
|
| 914 |
+
starCount.textContent = n? `${n}/${stars.length} NAMED` : `${stars.length} STARS FOUND`;
|
| 915 |
+
}
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
/* ---------- SHOWTIME (real commentary) ---------- */
|
| 919 |
+
liveBtn.addEventListener('click',async ()=>{
|
| 920 |
+
if(liveBtn.disabled)return;
|
| 921 |
+
liveBtn.disabled=true;
|
| 922 |
+
liveBtn.innerHTML='<svg class="i"><use href="#i-star"/></svg> Rolling cameras…';
|
| 923 |
+
setStatus('Generating…');
|
| 924 |
+
try{
|
| 925 |
+
const names=stars.map(s=>s.name||'');
|
| 926 |
+
const c=await api();
|
| 927 |
+
const res=await c.predict("/generate",[SESSION, names, currentVibe]);
|
| 928 |
+
const data=res.data[0];
|
| 929 |
+
if(!data||!data.ok) throw new Error((data&&data.error)||'generate failed');
|
| 930 |
+
REAL_SCRIPT=data.script||[];
|
| 931 |
+
setStep(4); setStatus('On air',true);
|
| 932 |
+
if(!reduce) confetti();
|
| 933 |
+
buildBroadcast();
|
| 934 |
+
setTimeout(()=>{switchView('broadcast');fitFrame();}, reduce?0:450);
|
| 935 |
+
}catch(err){
|
| 936 |
+
setStatus('Showtime failed');
|
| 937 |
+
toast('Showtime failed: '+(err.message||err));
|
| 938 |
+
}finally{
|
| 939 |
+
liveBtn.disabled=false;
|
| 940 |
+
liveBtn.innerHTML='<svg class="i"><use href="#i-star"/></svg> Showtime — go live';
|
| 941 |
+
}
|
| 942 |
+
});
|
| 943 |
+
|
| 944 |
+
/* ---------- VIEW SWITCH ---------- */
|
| 945 |
+
const setupView=$('#setupView'), broadcastView=$('#broadcastView');
|
| 946 |
+
function switchView(which){
|
| 947 |
+
const toBroadcast=which==='broadcast';
|
| 948 |
+
setupView.classList.toggle('active',!toBroadcast);
|
| 949 |
+
broadcastView.classList.toggle('active',toBroadcast);
|
| 950 |
+
window.scrollTo({top:0,behavior:reduce?'auto':'smooth'});
|
| 951 |
+
fitFrame();
|
| 952 |
+
}
|
| 953 |
+
$('#backBtn').addEventListener('click',()=>{switchView('setup');const v=$('#vid');if(v)v.pause();});
|
| 954 |
+
|
| 955 |
+
/* ============================================================
|
| 956 |
+
BROADCAST PLAYER — driven by a real <video>
|
| 957 |
+
============================================================ */
|
| 958 |
+
let script=[]; // {t, who, text, cc}
|
| 959 |
+
const vid=$('#vid'), screen=$('#screen'), chyron=$('#chyron'), chyWho=$('#chyWho'), chyLine=$('#chyLine'),
|
| 960 |
+
linesEl=$('#lines'), pbpCt=$('#pbpCt'), tcEl=$('#tc'), fill=$('#fill'), knob=$('#knob'),
|
| 961 |
+
pp=$('#pp'), scrub=$('#scrub'), tcDur=$('#tcDur');
|
| 962 |
+
const ICON_PLAY='<svg class="i"><use href="#i-play"/></svg>';
|
| 963 |
+
const ICON_PAUSE='<svg class="i"><use href="#i-pause"/></svg>';
|
| 964 |
+
|
| 965 |
+
function fmt(s){s=isFinite(s)?s:0;return pad(Math.floor(s/60))+':'+pad(Math.floor(s%60));}
|
| 966 |
+
|
| 967 |
+
function buildBroadcast(){
|
| 968 |
+
const d=VIBES[currentVibe]||{vc:'--hot',who:'COCO'};
|
| 969 |
+
const who=(d.who||'COCO').replace('COCO · ','');
|
| 970 |
+
script=(REAL_SCRIPT||[]).map(l=>({t:+l.t||0, text:l.text||'', who, cc:d.vc}));
|
| 971 |
+
linesEl.innerHTML='';
|
| 972 |
+
pbpCt.textContent=script.length+' LINES';
|
| 973 |
+
if(!script.length){
|
| 974 |
+
linesEl.innerHTML='<div class="pl"><span class="tx">No commentary generated — try another clip.</span></div>';
|
| 975 |
+
}
|
| 976 |
+
script.forEach((l,i)=>{
|
| 977 |
+
const row=document.createElement('div');
|
| 978 |
+
row.className='pl'; row.dataset.i=i;
|
| 979 |
+
row.style.setProperty('--cc',`var(${l.cc})`);
|
| 980 |
+
row.innerHTML=`<span class="t">${fmt(l.t)}</span><span class="tx"><b>${l.who}</b> · ${l.text}</span>`;
|
| 981 |
+
row.addEventListener('click',()=>seek(l.t));
|
| 982 |
+
linesEl.appendChild(row);
|
| 983 |
+
});
|
| 984 |
+
if(vid){ try{vid.currentTime=0;}catch(e){} }
|
| 985 |
+
activeLine=-1; renderPlayhead();
|
| 986 |
+
}
|
| 987 |
+
|
| 988 |
+
/* playback engine — reads the real video element */
|
| 989 |
+
let activeLine=-1, rafP=null;
|
| 990 |
+
function dur(){ return DUR || (vid&&vid.duration) || 0; }
|
| 991 |
+
function renderPlayhead(){
|
| 992 |
+
const D=dur(), cur=(vid&&vid.currentTime)||0;
|
| 993 |
+
const pct=D?(cur/D)*100:0;
|
| 994 |
+
fill.style.width=pct+'%'; knob.style.left=pct+'%'; tcEl.textContent=fmt(cur);
|
| 995 |
+
let idx=-1;
|
| 996 |
+
for(let i=0;i<script.length;i++){if(script[i].t<=cur)idx=i;else break;}
|
| 997 |
+
if(idx!==activeLine){
|
| 998 |
+
activeLine=idx;
|
| 999 |
+
$$('.pl',linesEl).forEach((r,i)=>r.classList.toggle('now',i===idx));
|
| 1000 |
+
showChyron(idx);
|
| 1001 |
+
if(idx>=0){const r=linesEl.children[idx];const top=r.offsetTop-linesEl.clientHeight/2+r.clientHeight/2;
|
| 1002 |
+
linesEl.scrollTo({top,behavior:reduce?'auto':'smooth'});}
|
| 1003 |
+
}
|
| 1004 |
+
}
|
| 1005 |
+
function showChyron(idx){
|
| 1006 |
+
if(idx<0){chyron.classList.remove('in');return;}
|
| 1007 |
+
const l=script[idx];
|
| 1008 |
+
chyron.classList.remove('in');
|
| 1009 |
+
requestAnimationFrame(()=>{
|
| 1010 |
+
chyron.style.setProperty('--cc',`var(${l.cc})`);
|
| 1011 |
+
chyWho.textContent=l.who; chyLine.textContent=l.text;
|
| 1012 |
+
requestAnimationFrame(()=>chyron.classList.add('in'));
|
| 1013 |
+
});
|
| 1014 |
+
}
|
| 1015 |
+
function loop(){ renderPlayhead(); if(vid && !vid.paused && !vid.ended) rafP=requestAnimationFrame(loop); }
|
| 1016 |
+
function seek(t){ if(vid) vid.currentTime=Math.max(0,Math.min(dur(),t)); }
|
| 1017 |
+
|
| 1018 |
+
if(vid){
|
| 1019 |
+
vid.addEventListener('loadedmetadata',()=>{ if(vid.duration&&isFinite(vid.duration))DUR=vid.duration; tcDur.textContent=fmt(dur()); renderPlayhead(); });
|
| 1020 |
+
vid.addEventListener('play',()=>{ pp.innerHTML=ICON_PAUSE; loop(); });
|
| 1021 |
+
vid.addEventListener('pause',()=>{ pp.innerHTML=ICON_PLAY; if(rafP)cancelAnimationFrame(rafP); });
|
| 1022 |
+
vid.addEventListener('ended',()=>{ pp.innerHTML=ICON_PLAY; });
|
| 1023 |
+
vid.addEventListener('timeupdate',renderPlayhead);
|
| 1024 |
+
}
|
| 1025 |
+
pp.addEventListener('click',()=>{ if(!vid)return; vid.paused?vid.play():vid.pause(); });
|
| 1026 |
+
scrub.addEventListener('click',e=>{
|
| 1027 |
+
const r=scrub.getBoundingClientRect();
|
| 1028 |
+
seek(((e.clientX-r.left)/r.width)*dur());
|
| 1029 |
+
});
|
| 1030 |
+
|
| 1031 |
+
/* orientation toggle (manual) + auto from real dims */
|
| 1032 |
+
const booth=$('#booth');
|
| 1033 |
+
function setBoothOrient(w,h){
|
| 1034 |
+
booth.classList.remove('portrait','square');
|
| 1035 |
+
let o='landscape';
|
| 1036 |
+
if(w&&h){ if(Math.abs(w-h)/Math.max(w,h)<0.08) o='square'; else if(h>w) o='portrait'; }
|
| 1037 |
+
if(o!=='landscape') booth.classList.add(o);
|
| 1038 |
+
$$('.orient button').forEach(x=>x.setAttribute('aria-pressed', String(x.dataset.orient===o)));
|
| 1039 |
+
}
|
| 1040 |
+
$$('.orient button').forEach(b=>b.addEventListener('click',()=>{
|
| 1041 |
+
$$('.orient button').forEach(x=>x.setAttribute('aria-pressed','false'));
|
| 1042 |
+
b.setAttribute('aria-pressed','true');
|
| 1043 |
+
booth.classList.remove('portrait','square');
|
| 1044 |
+
if(b.dataset.orient!=='landscape')booth.classList.add(b.dataset.orient);
|
| 1045 |
+
fitFrame();
|
| 1046 |
+
}));
|
| 1047 |
+
|
| 1048 |
+
/* ---------- SRT EXPORT (CapCut-ready) ---------- */
|
| 1049 |
+
$('#srtBtn').addEventListener('click',()=>{
|
| 1050 |
+
if(!script.length){toast('Go live first');return;}
|
| 1051 |
+
const D=dur();
|
| 1052 |
+
const srt=script.map((l,i)=>{
|
| 1053 |
+
const start=l.t, end=Math.min(D||l.t+4, (script[i+1]?script[i+1].t:l.t+4));
|
| 1054 |
+
return `${i+1}\n${srtTime(start)} --> ${srtTime(end)}\n${l.who}: ${l.text}\n`;
|
| 1055 |
+
}).join('\n');
|
| 1056 |
+
const blob=new Blob([srt],{type:'text/plain'});
|
| 1057 |
+
const a=document.createElement('a');
|
| 1058 |
+
a.href=URL.createObjectURL(blob);
|
| 1059 |
+
a.download=`marquee_${currentVibe||'show'}_captions.srt`;
|
| 1060 |
+
a.click(); URL.revokeObjectURL(a.href);
|
| 1061 |
+
toast('Captions exported — drop the .srt into CapCut');
|
| 1062 |
+
});
|
| 1063 |
+
function srtTime(s){
|
| 1064 |
+
const h=Math.floor(s/3600), m=Math.floor((s%3600)/60), sec=Math.floor(s%60);
|
| 1065 |
+
return `${pad(h)}:${pad(m)}:${pad(sec)},000`;
|
| 1066 |
+
}
|
| 1067 |
+
|
| 1068 |
+
/* ---------- confetti ---------- */
|
| 1069 |
+
function confetti(){
|
| 1070 |
+
const cols=['#FFC94D','#FF4D9A','#3DE7FF','#C18BFF','#9BE85B','#FF8A4D'];
|
| 1071 |
+
for(let i=0;i<70;i++){
|
| 1072 |
+
const c=document.createElement('div');
|
| 1073 |
+
c.className='confetti';
|
| 1074 |
+
c.style.left=Math.random()*100+'vw';
|
| 1075 |
+
c.style.background=cols[i%cols.length];
|
| 1076 |
+
c.style.animationDuration=(1.6+Math.random()*1.6)+'s';
|
| 1077 |
+
c.style.animationDelay=(Math.random()*.3)+'s';
|
| 1078 |
+
c.style.transform=`rotate(${Math.random()*360}deg)`;
|
| 1079 |
+
if(Math.random()>.5)c.style.borderRadius='50%';
|
| 1080 |
+
document.body.appendChild(c);
|
| 1081 |
+
setTimeout(()=>c.remove(),3600);
|
| 1082 |
+
}
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
/* ---------- toast ---------- */
|
| 1086 |
+
const toastEl=$('#toast'), toastTxt=$('#toastTxt'); let toastT;
|
| 1087 |
+
function toast(msg){
|
| 1088 |
+
toastTxt.textContent=msg; toastEl.classList.add('show');
|
| 1089 |
+
clearTimeout(toastT); toastT=setTimeout(()=>toastEl.classList.remove('show'),2600);
|
| 1090 |
+
}
|
| 1091 |
+
</script>
|
| 1092 |
+
</body>
|
| 1093 |
+
</html>
|
ov_models.py
CHANGED
|
@@ -1,15 +1,19 @@
|
|
| 1 |
-
"""ov_models.py — OpenVINO runtime wrappers for
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
- face-detection-retail-0004
|
| 5 |
-
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
import urllib.request
|
|
@@ -20,14 +24,15 @@ import numpy as np
|
|
| 20 |
import openvino as ov
|
| 21 |
|
| 22 |
MODELS_DIR = Path(__file__).parent / "models"
|
| 23 |
-
PRECISION
|
| 24 |
|
| 25 |
-
# Models we actually need now
|
| 26 |
MODEL_NAMES = [
|
| 27 |
-
"face-detection-retail-0004",
|
| 28 |
-
"
|
|
|
|
| 29 |
]
|
| 30 |
|
|
|
|
| 31 |
_OMZ_BASE = ("https://storage.openvinotoolkit.org/repositories/open_model_zoo/"
|
| 32 |
"2023.0/models_bin/1")
|
| 33 |
|
|
@@ -35,17 +40,22 @@ _core = ov.Core()
|
|
| 35 |
|
| 36 |
|
| 37 |
def download_models():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
for name in MODEL_NAMES:
|
| 39 |
-
dst
|
| 40 |
-
xml
|
| 41 |
-
binf = dst / f"{name}.bin"
|
| 42 |
if xml.exists() and binf.exists():
|
| 43 |
continue
|
| 44 |
dst.mkdir(parents=True, exist_ok=True)
|
| 45 |
for target in (xml, binf):
|
| 46 |
-
url
|
| 47 |
-
part = target.with_suffix(target.suffix + ".part")
|
| 48 |
print(f"[ov] downloading {target.name} ...")
|
|
|
|
| 49 |
urllib.request.urlretrieve(url, part)
|
| 50 |
part.replace(target)
|
| 51 |
|
|
@@ -59,18 +69,21 @@ def _find_xml(name: str) -> str:
|
|
| 59 |
|
| 60 |
|
| 61 |
class OVModel:
|
| 62 |
-
"""Compile once, infer many. Handles NCHW resize + BGR."""
|
| 63 |
|
| 64 |
def __init__(self, name: str):
|
| 65 |
-
model
|
| 66 |
-
self.compiled
|
| 67 |
-
self.input
|
|
|
|
| 68 |
_, _, self.h, self.w = self.input.shape
|
| 69 |
|
| 70 |
def _prep(self, bgr: np.ndarray) -> np.ndarray:
|
| 71 |
img = cv2.resize(bgr, (self.w, self.h))
|
| 72 |
-
|
|
|
|
| 73 |
|
| 74 |
def infer(self, bgr: np.ndarray):
|
|
|
|
| 75 |
out = self.compiled({self.input: self._prep(bgr)})
|
| 76 |
return out[self.compiled.output(0)]
|
|
|
|
| 1 |
+
"""ov_models.py — thin OpenVINO runtime wrappers for the Intel face models.
|
| 2 |
+
|
| 3 |
+
Three models, all CPU, all from Open Model Zoo:
|
| 4 |
+
- face-detection-retail-0004 : SSD face detector
|
| 5 |
+
- landmarks-regression-retail-0009 : 5 keypoints (for alignment)
|
| 6 |
+
- face-reidentification-retail-0095 : 256-d embedding, cosine-comparable
|
| 7 |
+
|
| 8 |
+
[OpenVINO 101] An OV model ships as two files: <name>.xml (graph) + <name>.bin
|
| 9 |
+
(weights), the "IR" format. You read it, compile it for a target device once
|
| 10 |
+
(slow), then call the compiled model many times (fast). compile_model("CPU")
|
| 11 |
+
picks CPU; nothing here ever touches a GPU, so it won't fight the VLM for
|
| 12 |
+
Zero GPU quota.
|
| 13 |
+
|
| 14 |
+
The IR files are pulled straight from Intel's public OMZ storage at boot — no
|
| 15 |
+
`omz_downloader` / `openvino-dev` needed (that package is deprecated and fights
|
| 16 |
+
modern `openvino`). Nothing to commit; first boot fetches ~5 MB total.
|
| 17 |
"""
|
| 18 |
|
| 19 |
import urllib.request
|
|
|
|
| 24 |
import openvino as ov
|
| 25 |
|
| 26 |
MODELS_DIR = Path(__file__).parent / "models"
|
| 27 |
+
PRECISION = "FP16" # FP16 IR runs fine on CPU; smaller download than FP32
|
| 28 |
|
|
|
|
| 29 |
MODEL_NAMES = [
|
| 30 |
+
"face-detection-retail-0004", # detector
|
| 31 |
+
"landmarks-regression-retail-0009", # 5 keypoints -> alignment
|
| 32 |
+
"face-reidentification-retail-0095", # 256-d embedding
|
| 33 |
]
|
| 34 |
|
| 35 |
+
# All three live under the same 2023.0 / models_bin/1 path on OMZ storage.
|
| 36 |
_OMZ_BASE = ("https://storage.openvinotoolkit.org/repositories/open_model_zoo/"
|
| 37 |
"2023.0/models_bin/1")
|
| 38 |
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
def download_models():
|
| 43 |
+
"""Fetch the FP16 IR (.xml + .bin) for each model into models/<name>/.
|
| 44 |
+
|
| 45 |
+
Idempotent: skips anything already on disk, so it's safe to call at every
|
| 46 |
+
boot. Downloads to a .part file first, then renames, so an interrupted
|
| 47 |
+
download never leaves a half-written file that looks complete.
|
| 48 |
+
"""
|
| 49 |
for name in MODEL_NAMES:
|
| 50 |
+
dst = MODELS_DIR / name
|
| 51 |
+
xml, binf = dst / f"{name}.xml", dst / f"{name}.bin"
|
|
|
|
| 52 |
if xml.exists() and binf.exists():
|
| 53 |
continue
|
| 54 |
dst.mkdir(parents=True, exist_ok=True)
|
| 55 |
for target in (xml, binf):
|
| 56 |
+
url = f"{_OMZ_BASE}/{name}/{PRECISION}/{target.name}"
|
|
|
|
| 57 |
print(f"[ov] downloading {target.name} ...")
|
| 58 |
+
part = target.with_suffix(target.suffix + ".part")
|
| 59 |
urllib.request.urlretrieve(url, part)
|
| 60 |
part.replace(target)
|
| 61 |
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
class OVModel:
|
| 72 |
+
"""Compile once, infer many. Handles NCHW resize + BGR for you."""
|
| 73 |
|
| 74 |
def __init__(self, name: str):
|
| 75 |
+
model = _core.read_model(_find_xml(name))
|
| 76 |
+
self.compiled = _core.compile_model(model, "CPU")
|
| 77 |
+
self.input = self.compiled.input(0)
|
| 78 |
+
# IR input layout is N,C,H,W — pull the model's expected H,W.
|
| 79 |
_, _, self.h, self.w = self.input.shape
|
| 80 |
|
| 81 |
def _prep(self, bgr: np.ndarray) -> np.ndarray:
|
| 82 |
img = cv2.resize(bgr, (self.w, self.h))
|
| 83 |
+
img = img.transpose(2, 0, 1)[None].astype(np.float32) # HWC->NCHW
|
| 84 |
+
return img
|
| 85 |
|
| 86 |
def infer(self, bgr: np.ndarray):
|
| 87 |
+
"""Returns the first output blob as a numpy array."""
|
| 88 |
out = self.compiled({self.input: self._prep(bgr)})
|
| 89 |
return out[self.compiled.output(0)]
|
requirements.txt
CHANGED
|
@@ -1,37 +1,17 @@
|
|
| 1 |
-
# --- Gradio runtime ---
|
| 2 |
gradio>=6.18
|
| 3 |
spaces
|
| 4 |
|
| 5 |
-
# --- VLM (Qwen2.5-VL-7B) ---
|
| 6 |
torch
|
| 7 |
-
torchvision
|
| 8 |
transformers>=4.49
|
| 9 |
accelerate
|
| 10 |
qwen-vl-utils
|
| 11 |
|
| 12 |
-
# --- OpenVINO face models ---
|
| 13 |
openvino>=2024.4
|
| 14 |
|
| 15 |
-
# --- Chatterbox TTS runtime deps ---
|
| 16 |
-
# chatterbox-tts itself installed via subprocess --no-deps in app.py
|
| 17 |
-
# (all released versions conflict with our transformers/gradio versions).
|
| 18 |
-
# We encode WAV with stdlib `wave` so torchaudio.save/torchcodec not needed.
|
| 19 |
-
librosa
|
| 20 |
-
omegaconf
|
| 21 |
-
conformer
|
| 22 |
-
diffusers
|
| 23 |
-
einops
|
| 24 |
-
safetensors
|
| 25 |
-
s3tokenizer
|
| 26 |
-
pykakasi
|
| 27 |
-
spacy-pkuseg
|
| 28 |
-
pyloudnorm
|
| 29 |
-
resemble-perth
|
| 30 |
-
scipy
|
| 31 |
-
tokenizers
|
| 32 |
-
|
| 33 |
-
orpheus-speech
|
| 34 |
-
|
| 35 |
# --- media / utils ---
|
| 36 |
opencv-python-headless
|
| 37 |
pillow
|
|
|
|
| 1 |
+
# --- Gradio runtime (v6.18 first ships gradio.Server; matches the Space sdk_version) ---
|
| 2 |
gradio>=6.18
|
| 3 |
spaces
|
| 4 |
|
| 5 |
+
# --- VLM (Qwen2.5-VL-7B, auto-downloads from HF Hub at first run) ---
|
| 6 |
torch
|
| 7 |
+
torchvision # required by Qwen2.5-VL's processor (Qwen2VLVideoProcessor)
|
| 8 |
transformers>=4.49
|
| 9 |
accelerate
|
| 10 |
qwen-vl-utils
|
| 11 |
|
| 12 |
+
# --- OpenVINO face models (IR auto-downloaded at boot from OMZ storage) ---
|
| 13 |
openvino>=2024.4
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
# --- media / utils ---
|
| 16 |
opencv-python-headless
|
| 17 |
pillow
|
tts.py
DELETED
|
@@ -1,233 +0,0 @@
|
|
| 1 |
-
"""tts.py — TTS integration for Marquee.
|
| 2 |
-
|
| 3 |
-
Supports two models selectable at runtime:
|
| 4 |
-
chatterbox (default) — 0.5B, MIT, fast CPU inference, emotion exaggeration
|
| 5 |
-
orpheus — 3B, Apache 2.0, more expressive but slower
|
| 6 |
-
|
| 7 |
-
Collision fix:
|
| 8 |
-
After all lines are generated, if line[i].t + line[i].duration > line[i+1].t
|
| 9 |
-
we trim line[i]'s text to the number of words that fit in the available slot,
|
| 10 |
-
then re-generate that line's audio. This guarantees no two voices ever overlap.
|
| 11 |
-
|
| 12 |
-
WAV encoding uses stdlib `wave` + numpy — no torchaudio / torchcodec needed.
|
| 13 |
-
"""
|
| 14 |
-
|
| 15 |
-
import io
|
| 16 |
-
import logging
|
| 17 |
-
import wave
|
| 18 |
-
|
| 19 |
-
import numpy as np
|
| 20 |
-
import spaces
|
| 21 |
-
|
| 22 |
-
log = logging.getLogger(__name__)
|
| 23 |
-
|
| 24 |
-
# ── Vibe → Chatterbox params ──────────────────────────────────────────────────
|
| 25 |
-
VIBE_PARAMS_CHATTERBOX: dict[str, dict] = {
|
| 26 |
-
"football": {"exaggeration": 0.85, "cfg_weight": 0.45},
|
| 27 |
-
"diva": {"exaggeration": 0.90, "cfg_weight": 0.40},
|
| 28 |
-
"wildlife": {"exaggeration": 0.20, "cfg_weight": 0.65},
|
| 29 |
-
"boxing": {"exaggeration": 0.80, "cfg_weight": 0.45},
|
| 30 |
-
"masterchef": {"exaggeration": 0.70, "cfg_weight": 0.50},
|
| 31 |
-
}
|
| 32 |
-
_CB_DEFAULT = {"exaggeration": 0.55, "cfg_weight": 0.50}
|
| 33 |
-
|
| 34 |
-
# ── Vibe → Orpheus emotion tags ───────────────────────────────────────────────
|
| 35 |
-
# Orpheus uses inline tags: <laugh>, <chuckle>, <sigh>, <cough>, <gasp>, <groan>
|
| 36 |
-
VIBE_ORPHEUS_PREFIX: dict[str, str] = {
|
| 37 |
-
"football": "", # already hyped by pacing
|
| 38 |
-
"diva": "",
|
| 39 |
-
"wildlife": "",
|
| 40 |
-
"boxing": "",
|
| 41 |
-
"masterchef": "",
|
| 42 |
-
}
|
| 43 |
-
ORPHEUS_VOICE = "tara" # options: tara, dan, emma, josh
|
| 44 |
-
|
| 45 |
-
# ── Model singletons ──────────────────────────────────────────────────────────
|
| 46 |
-
_cb_model = None
|
| 47 |
-
_orpheus_model = None
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def _get_chatterbox():
|
| 51 |
-
global _cb_model
|
| 52 |
-
if _cb_model is None:
|
| 53 |
-
from chatterbox.tts import ChatterboxTTS
|
| 54 |
-
log.info("[tts] Loading Chatterbox on CPU…")
|
| 55 |
-
_cb_model = ChatterboxTTS.from_pretrained(device="cpu")
|
| 56 |
-
log.info("[tts] Chatterbox ready.")
|
| 57 |
-
return _cb_model
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def _get_orpheus():
|
| 61 |
-
global _orpheus_model
|
| 62 |
-
if _orpheus_model is None:
|
| 63 |
-
try:
|
| 64 |
-
from orpheus_tts import OrpheusModel
|
| 65 |
-
log.info("[tts] Loading Orpheus 3B (first call — slow)…")
|
| 66 |
-
_orpheus_model = OrpheusModel(
|
| 67 |
-
model_name="canopylabs/orpheus-tts-0.1-finetune-prod",
|
| 68 |
-
)
|
| 69 |
-
log.info("[tts] Orpheus ready.")
|
| 70 |
-
except ImportError:
|
| 71 |
-
raise RuntimeError(
|
| 72 |
-
"orpheus-speech is not installed. "
|
| 73 |
-
"Add 'orpheus-speech' to requirements.txt and redeploy.")
|
| 74 |
-
return _orpheus_model
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
# ── WAV helpers ───────────────────────────────────────────────────────────────
|
| 78 |
-
|
| 79 |
-
def _tensor_to_wav_bytes(wav_tensor, sample_rate: int) -> bytes:
|
| 80 |
-
pcm = wav_tensor.squeeze().detach().cpu().numpy()
|
| 81 |
-
pcm = np.clip(pcm, -1.0, 1.0)
|
| 82 |
-
pcm_i16 = (pcm * 32767).astype(np.int16)
|
| 83 |
-
buf = io.BytesIO()
|
| 84 |
-
with wave.open(buf, "wb") as wf:
|
| 85 |
-
wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(sample_rate)
|
| 86 |
-
wf.writeframes(pcm_i16.tobytes())
|
| 87 |
-
buf.seek(0)
|
| 88 |
-
return buf.read()
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
def _pcm_iter_to_wav_bytes(pcm_chunks, sample_rate: int = 24000) -> bytes:
|
| 92 |
-
"""For Orpheus which yields int16 numpy chunks."""
|
| 93 |
-
all_pcm = np.concatenate([c.astype(np.int16) for c in pcm_chunks])
|
| 94 |
-
buf = io.BytesIO()
|
| 95 |
-
with wave.open(buf, "wb") as wf:
|
| 96 |
-
wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(sample_rate)
|
| 97 |
-
wf.writeframes(all_pcm.tobytes())
|
| 98 |
-
buf.seek(0)
|
| 99 |
-
return buf.read()
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def _wav_duration(wav_bytes: bytes) -> float:
|
| 103 |
-
with wave.open(io.BytesIO(wav_bytes)) as wf:
|
| 104 |
-
return wf.getnframes() / wf.getframerate()
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
def _estimate_duration_sec(text: str, words_per_sec: float = 2.8) -> float:
|
| 108 |
-
"""Rough duration estimate from word count — used before audio is generated."""
|
| 109 |
-
return len(text.split()) / words_per_sec
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
# ── Single-line generation ────────────────────────────────────────────────────
|
| 113 |
-
|
| 114 |
-
def _generate_chatterbox(text: str, vibe: str) -> bytes:
|
| 115 |
-
import time
|
| 116 |
-
model = _get_chatterbox()
|
| 117 |
-
params = VIBE_PARAMS_CHATTERBOX.get(vibe, _CB_DEFAULT)
|
| 118 |
-
t0 = time.time()
|
| 119 |
-
wav = model.generate(text=text,
|
| 120 |
-
exaggeration=params["exaggeration"],
|
| 121 |
-
cfg_weight=params["cfg_weight"])
|
| 122 |
-
wav_bytes = _tensor_to_wav_bytes(wav, model.sr)
|
| 123 |
-
log.info(f"[tts/cb] '{text[:40]}' → {time.time()-t0:.1f}s")
|
| 124 |
-
return wav_bytes
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
@spaces.GPU(duration=60)
|
| 128 |
-
def _generate_orpheus(text: str, vibe: str) -> bytes:
|
| 129 |
-
model = _get_orpheus()
|
| 130 |
-
chunks = list(model.generate_speech(prompt=text, voice=ORPHEUS_VOICE))
|
| 131 |
-
return _pcm_iter_to_wav_bytes(chunks)
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def generate_line(text: str, vibe: str, voice_model: str = "chatterbox") -> bytes:
|
| 135 |
-
if voice_model == "orpheus":
|
| 136 |
-
return _generate_orpheus(text, vibe)
|
| 137 |
-
return _generate_chatterbox(text, vibe)
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
# ── Collision fix ─────────────────────────────────────────────────────────────
|
| 141 |
-
|
| 142 |
-
def _trim_text_to_duration(text: str, max_sec: float,
|
| 143 |
-
words_per_sec: float = 2.8) -> str:
|
| 144 |
-
"""Trim text to fit within max_sec at expected speaking rate."""
|
| 145 |
-
max_words = max(2, int(max_sec * words_per_sec))
|
| 146 |
-
words = text.split()
|
| 147 |
-
if len(words) <= max_words:
|
| 148 |
-
return text
|
| 149 |
-
trimmed = " ".join(words[:max_words])
|
| 150 |
-
# prefer a natural break
|
| 151 |
-
for punct in (".", "!", "?", "—", ","):
|
| 152 |
-
idx = trimmed.rfind(punct)
|
| 153 |
-
if idx > len(trimmed) // 2:
|
| 154 |
-
return trimmed[:idx + 1]
|
| 155 |
-
return trimmed + "…"
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
def _fix_collisions(lines: list[dict], vibe: str,
|
| 159 |
-
voice_model: str, gap_sec: float = 0.15) -> list[dict]:
|
| 160 |
-
"""Re-generate audio for any line that overlaps the next one's start time.
|
| 161 |
-
|
| 162 |
-
gap_sec: minimum silence required between end of one line and start of next.
|
| 163 |
-
"""
|
| 164 |
-
import base64
|
| 165 |
-
|
| 166 |
-
for i in range(len(lines) - 1):
|
| 167 |
-
cur = lines[i]
|
| 168 |
-
nxt = lines[i + 1]
|
| 169 |
-
dur = cur.get("duration", 0)
|
| 170 |
-
if dur == 0:
|
| 171 |
-
continue
|
| 172 |
-
available = nxt["t"] - cur["t"] - gap_sec
|
| 173 |
-
if dur <= available:
|
| 174 |
-
continue
|
| 175 |
-
|
| 176 |
-
# Need to trim and re-generate
|
| 177 |
-
log.info(f"[tts] Collision at t={cur['t']}: dur={dur:.2f}s "
|
| 178 |
-
f"available={available:.2f}s — trimming")
|
| 179 |
-
new_text = _trim_text_to_duration(cur["text"], available)
|
| 180 |
-
if new_text == cur["text"] and available < 0.5:
|
| 181 |
-
# slot too short — skip this line's audio entirely
|
| 182 |
-
cur.pop("audio_b64", None)
|
| 183 |
-
cur["duration"] = 0
|
| 184 |
-
continue
|
| 185 |
-
try:
|
| 186 |
-
wav_bytes = generate_line(new_text, vibe, voice_model)
|
| 187 |
-
new_dur = _wav_duration(wav_bytes)
|
| 188 |
-
cur["text"] = new_text
|
| 189 |
-
cur["audio_b64"] = base64.b64encode(wav_bytes).decode()
|
| 190 |
-
cur["duration"] = round(new_dur, 3)
|
| 191 |
-
except Exception as e:
|
| 192 |
-
log.warning(f"[tts] Re-gen failed: {e}")
|
| 193 |
-
|
| 194 |
-
return lines
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
# ── Public API ────────────────────────────────────────────────────────────────
|
| 198 |
-
|
| 199 |
-
def generate_script_audio(script: list[dict], vibe: str,
|
| 200 |
-
voice_model: str = "chatterbox") -> list[dict]:
|
| 201 |
-
import time
|
| 202 |
-
log.info(f"[tts] generate_script_audio: {len(script)} lines, "
|
| 203 |
-
f"vibe={vibe}, model={voice_model}")
|
| 204 |
-
_t0 = time.time()
|
| 205 |
-
"""Generate TTS for every line, then fix any audio collisions.
|
| 206 |
-
|
| 207 |
-
Args:
|
| 208 |
-
script: list of {"t": float, "text": str}
|
| 209 |
-
vibe: football / diva / wildlife / boxing / masterchef
|
| 210 |
-
voice_model: "chatterbox" (default) or "orpheus"
|
| 211 |
-
|
| 212 |
-
Returns:
|
| 213 |
-
list of {"t", "text", "audio_b64"?, "duration"?}
|
| 214 |
-
"""
|
| 215 |
-
import base64
|
| 216 |
-
|
| 217 |
-
result = []
|
| 218 |
-
for line in script:
|
| 219 |
-
entry = {"t": line["t"], "text": line["text"]}
|
| 220 |
-
try:
|
| 221 |
-
wav_bytes = generate_line(line["text"], vibe, voice_model)
|
| 222 |
-
entry["audio_b64"] = base64.b64encode(wav_bytes).decode()
|
| 223 |
-
entry["duration"] = round(_wav_duration(wav_bytes), 3)
|
| 224 |
-
except Exception as e:
|
| 225 |
-
log.warning(f"[tts] Skipping line ('{line['text'][:30]}…'): {e}")
|
| 226 |
-
result.append(entry)
|
| 227 |
-
|
| 228 |
-
# Post-process: fix any overlapping lines
|
| 229 |
-
result = _fix_collisions(result, vibe, voice_model)
|
| 230 |
-
audio_ok = sum(1 for r in result if r.get("audio_b64"))
|
| 231 |
-
log.info(f"[tts] All done in {time.time()-_t0:.1f}s — "
|
| 232 |
-
f"{audio_ok}/{len(result)} lines have audio")
|
| 233 |
-
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|