Files changed (11) hide show
  1. Marquee.html +181 -245
  2. README.md +61 -78
  3. app.py +45 -298
  4. commentary.py +36 -106
  5. faces.py +123 -291
  6. marquee.css +453 -331
  7. marquee.js +307 -870
  8. marquee_inlined_preview.html +1093 -0
  9. ov_models.py +39 -26
  10. requirements.txt +4 -24
  11. tts.py +0 -233
Marquee.html CHANGED
@@ -1,284 +1,220 @@
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.0">
6
- <title>Marquee | Live AI Broadcast Studio</title>
7
-
8
- <!-- Premium Font Integrations -->
9
- <link rel="preconnect" href="https://fonts.googleapis.com">
10
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
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
- <!-- Dynamic Vector Definitions -->
18
- <svg width="0" height="0" style="position:absolute" aria-hidden="true">
19
- <defs>
20
- <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>
21
- <symbol id="i-play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></symbol>
22
- <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>
23
- <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>
24
- <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>
25
- <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>
26
- <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>
27
- <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>
28
- <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>
29
- <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>
30
- <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>
31
- <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>
32
- <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>
33
- <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>
34
- <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>
35
- <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>
36
- <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>
37
- <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>
38
- <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>
39
- <symbol id="i-chef" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M6 13.87A4 4 0 0 1 7.41 6a5.11 5.11 0 0 1 1.05-1.54 5 5 0 0 1 7.08 0A5.11 5.11 0 0 1 16.59 6 4 4 0 0 1 18 13.87V21H6Z"/><line x1="6" y1="17" x2="18" y2="17"/></symbol>
40
- <symbol id="i-film" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M7 4v16M17 4v16M2 12h20M2 8h5M2 16h5M17 8h5M17 16h5"/></symbol>
41
- <symbol id="i-arrow-r" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></symbol>
42
- <symbol id="i-loader" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></symbol>
43
- </defs>
44
- </svg>
45
-
46
- <!-- ============================================================
47
- LANDING — Vibe Picker Card Matrix
48
- ============================================================ -->
49
- <div class="landing" id="landingView">
50
- <div class="landing-inner">
51
- <div class="landing-brand">
52
- <div class="sign">
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
- </div>
113
 
114
  <!-- ============================================================
115
- MAIN APP SHELL
116
  ============================================================ -->
117
- <div class="shell" id="appShell" hidden>
118
-
119
- <!-- MASTHEAD -->
120
- <header class="masthead">
121
- <div class="brand">
122
- <div class="sign">
123
- <div class="bulbs t"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
124
- <h1 class="disp">MARQUEE</h1>
125
- <div class="bulbs b"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
126
- </div>
127
- <div class="tag" id="mastheadTag">Everyone's a headliner.<small>Turn any clip into a live broadcast.</small></div>
128
- </div>
129
- <div class="broadcast-meta">
130
- <button class="change-vibe-btn" id="changeVibeBtn" title="Change style">Change broadcast</button>
131
- <span class="clock" id="clock">--:--:--</span>
132
- <span class="status" id="status"><span class="led"></span><span id="statusTxt">Standby</span></span>
133
- </div>
134
- </header>
135
-
136
- <!-- VIEW 1 — SETUP CONSOLE -->
137
- <section class="view active" id="setupView">
138
-
139
- <nav class="rundown" id="rundown" aria-label="Show rundown">
140
- <div class="cue on" data-step="1"><div class="node">01</div><div class="ttl" id="step1Ttl">Roll Tape</div><div class="sub" id="step1Sub">Drop a clip</div></div>
141
- <div class="cue" data-step="2"><div class="node">02</div><div class="ttl" id="step2Ttl">Casting</div><div class="sub" id="step2Sub">Find the faces</div></div>
142
- <div class="cue" data-step="3"><div class="node">03</div><div class="ttl" id="step3Ttl">Star Select</div><div class="sub" id="step3Sub">Name the stars</div></div>
143
- <div class="cue" data-step="4"><div class="node">04</div><div class="ttl" id="step4Ttl">Showtime</div><div class="sub" id="step4Sub">Go live</div></div>
144
- </nav>
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
- <input type="file" id="fileInput" accept="video/*" hidden>
180
- <button class="btn cyan mt" id="castBtn" disabled>
181
- <svg class="i"><use href="#i-spot"/></svg>
182
- <span id="castBtnLabel">Find the stars</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  </button>
184
- <div class="sweep"></div>
185
  </div>
186
 
187
- <!-- Voice Selector -->
188
- <div class="card voice-card" id="voiceCard">
189
- <div class="card-h"><svg class="i ic"><use href="#i-mic"/></svg><h2>Voice Model</h2><span class="meta">TTS Engine</span></div>
190
- <div class="voice-opts" id="voiceOpts" role="group" aria-label="TTS voice model">
191
- <button class="voice-opt active" data-model="chatterbox" aria-pressed="true">
192
- <span class="vt">Chatterbox</span>
193
- <span class="vd">Fast | expressive | 0.5B</span>
194
- </button>
195
- <button class="voice-opt" data-model="orpheus" aria-pressed="false">
196
- <span class="vt">Orpheus</span>
197
- <span class="vd">Richer | slower | 3B</span>
198
- </button>
199
  </div>
200
  </div>
 
 
 
 
201
 
202
- <!-- Showtime -->
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
- <!-- RIGHT: Roster Stage -->
211
- <div class="stage-panel" id="stagePanel">
212
- <div class="stage-h">
213
- <svg class="i ic"><use href="#i-spot"/></svg>
214
- <h2 id="stageTitle">Star Select</h2>
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
- <p class="note">
228
- <svg class="i lock"><use href="#i-lock"/></svg>
229
- <span id="noteText">Your clip stays on <b>your machine</b>. Faces are detected locally and never uploaded | Marquee only writes the captions you see.</span>
230
- </p>
231
- </section>
 
 
232
 
233
- <!-- VIEW 2 — BROADCAST PLAYER -->
234
- <section class="view" id="broadcastView">
235
- <div class="booth-top">
236
- <button class="back" id="backBtn"><svg class="i"><use href="#i-back"/></svg> <span id="backBtnLabel">Back to console</span></button>
 
 
 
 
 
 
237
  </div>
 
238
 
239
- <div class="booth" id="booth">
240
- <div>
241
- <div class="screen-wrap">
242
- <div class="screen" id="screen">
243
- <!-- 1:1 aspect-ratio is hardcoded in CSS — DO NOT modify -->
244
- <video id="vid" class="screen-vid" playsinline preload="metadata"></video>
245
- <div class="ph"><svg class="i"><use href="#i-cam"/></svg>YOUR CLIP PLAYS HERE</div>
246
- <div class="bug"><span class="dot"></span> ON AIR</div>
247
- <div class="tc" id="tc">00:00</div>
248
- <div class="chyron" id="chyron">
249
- <div class="bar"></div>
250
- <div class="row"><span class="who" id="chyWho">COCO</span><span class="line" id="chyLine">Standing by…</span></div>
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
- <div class="pbp">
262
- <div class="pbp-h"><svg class="i ic"><use href="#i-list"/></svg><h3 id="pbpTitle">Play-by-play</h3><span class="ct" id="pbpCt">| lines</span></div>
263
- <div class="lines" id="lines"></div>
264
- <div class="export">
265
- <button class="btn-srt" id="srtBtn"><svg class="i ic"><use href="#i-download"/></svg> Download captions (.srt)</button>
266
- <div class="hint">Drops straight into <b>CapCut</b>, Premiere &amp; DaVinci | timed to your clip.</div>
267
- <button class="btn-export" id="exportBtn"><svg class="i ic"><use href="#i-download"/></svg> Export broadcast (.mp4)</button>
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
- </section>
 
277
 
278
- </div><!-- /.shell -->
279
 
280
- <div class="toast" id="toast"><svg class="i ic"><use href="#i-check"/></svg><span id="toastTxt">Done</span></div>
281
 
282
- <script type="module" src="marquee.js"></script>
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 &amp; 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 detects who's in it, you give each person a name, pick a commentary style, and an AI commentator calls the action play-by-play — by name, in sync — as a broadcast you can play back and export as an MP4.
 
 
21
 
22
- ## What it does
23
 
24
- 1. **Scan** Upload a clip (≤60s). OpenVINO detects and clusters people into tracks. You get a roster of face crops to name.
25
- 2. **Generate** Qwen2.5-VL-7B watches key moments from the clip and writes commentary lines timed to the action, in whichever style you picked.
26
- 3. **Voice** Each line gets spoken by a TTS model. Two options: Chatterbox (fast, runs on CPU) or Orpheus (slower, more expressive, requires GPU and a HuggingFace token).
27
- 4. **Export** — ffmpeg mixes the TTS audio over the original clip audio, burns in subtitles, and gives you a download.
 
 
 
 
 
 
28
 
29
- ## Setup
30
 
31
- ### Hardware
 
 
 
 
 
32
 
33
- Must be **ZeroGPU**. Qwen2.5-VL-7B and Orpheus both need a GPU. Set it under **Settings → Hardware → ZeroGPU** in your Space. On a CPU-only Space the VLM is unusable.
34
-
35
- ### Using Orpheus TTS
36
-
37
- Orpheus uses `canopylabs/orpheus-3b-0.1-ft`, which is a **gated model** on HuggingFace. Two steps to unlock it:
38
-
39
- 1. Go to https://huggingface.co/canopylabs/orpheus-3b-0.1-ft and accept the access request. Takes about a minute to be approved.
40
-
41
- 2. Add your HuggingFace token as a **Space secret** (not a repo file):
42
- - Go to **Settings Variables and Secrets → New Secret**
43
- - Name: `HF_TOKEN`
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
- ### Demo
51
- https://youtu.be/f2I8m6ardkU
 
52
 
53
- ### Local dev
 
 
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
- # → http://localhost:7860
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, BackgroundTasks
35
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
36
 
37
- from commentary import STYLES, VIBE_TO_STYLE, generate_commentary
38
  from events import events_to_frames, pick_key_events
39
- from faces import annotate_event_frame, frame_at, scan_video, write_names_to_jsonl
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 = Path(__file__).parent
46
- SESSIONS: dict[str, dict] = {}
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
- log.warning(f"[boot] OV model download failed: {e}")
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
- t0 = time.time()
89
- log.info("[scan] Request received")
90
  try:
91
- form = await request.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
- data = await upload.read()
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
- log.info(f"[scan] Normalised in {time.time()-t1:.1f}s {w}x{h}")
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
- "norm": norm_path,
124
- "tracks": tracks,
125
- "jsonl_path": jsonl_path,
126
- "motion": motion,
127
- "meta": meta,
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
- log.info(f"[video] Serving video for session {sid}")
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
- t0 = time.time()
354
- log.info(f"[generate] session={session_id} vibe={vibe} names={names}")
355
-
 
356
  sess = SESSIONS.get(session_id)
357
  if not sess:
358
- log.error(f"[generate] Session not found: {session_id}")
359
- return {"ok": False, "error": "Session expired — re-cast"}
360
 
361
- tracks = sess.get("tracks", [])
362
- id_to_name = {}
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["jsonl_path"], ts)))
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 = "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 judge who treats every moment like a "
16
- "high-stakes elimination round — stern, intense, occasionally impressed",
17
  "Nature documentary":
18
  "a hushed, awestruck nature narrator observing these humans like rare "
19
- "wildlife in their natural habitat",
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
- Voice rules:
52
- - React to what is ACTUALLY in each frame never recycle a phrase.
53
- - Vary rhythm: short punchy lines for action, longer for atmosphere.
54
- - Drop names only when it lands not in every line.
 
 
 
55
  - Stay fully in character as {persona} from first line to last.
56
- - CAPS and exclamation marks only for the BIG beats overuse kills the punch.
57
- - Keep every line under {max_words} words — it must be spoken before the next beat.
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, vibe=None):
109
- """event_frames: [(timestamp_sec, PIL.Image), ...] (annotated key events)
110
- roster: list of names the user assigned
111
- style_key: key into STYLES dict
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
- # Merge events that are too close — prevents audio collision at gen time
119
- event_frames = _merge_close_events(event_frames)
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 = STYLES.get(style_key, list(STYLES.values())[0])
133
  roster_str = ", ".join(roster) if roster else "nobody named yet"
134
-
135
  messages = [
136
  {"role": "system",
137
- "content": SYSTEM_PROMPT.format(
138
- persona=persona, roster=roster_str,
139
- n_frames=n, max_words=max_words)},
140
  {"role": "user", "content": content},
141
  ]
142
 
143
- text = _processor.apply_chat_template(messages, tokenize=False,
144
- add_generation_prompt=True)
145
  image_inputs, _ = process_vision_info(messages)
146
- inputs = _processor(text=[text], images=image_inputs,
147
- return_tensors="pt").to(_model.device)
148
 
149
- out = _model.generate(**inputs, max_new_tokens=600, do_sample=True,
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, max_words)
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], max_words: int = 14) -> list[dict]:
172
  raw = re.sub(r"```(?:json)?", "", raw).strip()
173
- m = re.search(r"\[.*\]", raw, re.DOTALL)
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 = float(it.get("time", valid_times[min(i, len(valid_times)-1)]))
184
  except (KeyError, TypeError, ValueError):
185
  continue
186
- t = min(valid_times, key=lambda v: abs(v - t)) if valid_times else 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
- log = logging.getLogger(__name__)
22
-
23
- PERSON_CONF = 0.50
24
- FACE_CONF = 0.55
25
- SAMPLE_FPS = 2.0
26
- IOU_THRESHOLD = 0.35
27
- MAX_MISS = 4
28
 
 
 
 
 
 
29
 
30
- _person_det = None
31
- _face_det = None
32
 
33
 
34
  def _load():
35
- global _person_det, _face_det
36
- if _person_det is None:
37
- log.info("[faces] Loading OpenVINO models (person + face detection)…")
38
- t0 = time.time()
39
- _person_det = OVModel("person-detection-retail-0013")
40
- _face_det = OVModel("face-detection-retail-0004")
41
- log.info(f"[faces] Models loaded in {time.time()-t0:.1f}s")
42
- return _person_det, _face_det
43
 
44
 
45
- def _detect_persons(frame_bgr, det) -> list[list[int]]:
 
46
  h, w = frame_bgr.shape[:2]
47
- out = det.infer(frame_bgr).reshape(-1, 7)
48
  boxes = []
49
  for _, _, conf, x1, y1, x2, y2 in out:
50
- if conf < PERSON_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 _detect_faces_once(frame_bgr, fdet) -> list[list[int]]:
58
- """Run face detection ONCE per frame reuse results for all person boxes."""
59
- h, w = frame_bgr.shape[:2]
60
- out = fdet.infer(frame_bgr).reshape(-1, 7)
61
- boxes = []
62
- for _, _, conf, x1, y1, x2, y2 in out:
63
- if conf < FACE_CONF:
64
- continue
65
- boxes.append([max(0,int(x1*w)), max(0,int(y1*h)),
66
- min(w,int(x2*w)), min(h,int(y2*h))])
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
- self.tracks = [t for t in self.tracks if t.miss <= MAX_MISS]
153
- # keep id_map in sync
154
- alive = {t.id for t in self.tracks}
155
- self._id_map = {k: v for k, v in self._id_map.items() if k in alive}
 
 
156
 
157
- return [(t.id, t.box, t) for t in self.tracks if t.miss == 0]
 
 
 
 
158
 
159
- def get_by_id(self, tid: int) -> "_Track | None":
160
- return self._id_map.get(tid)
161
 
162
 
163
  def scan_video(video_path: str, max_seconds: float = 600.0):
164
- """Person tracking scan with progress logging.
165
-
166
- Returns:
167
- tracks: [{id, crop (PIL), sightings: [{t, bbox}]}] — strong tracks only
168
- motion: {frame_idx: float}
169
- meta: {"fps", "n_frames"}
170
- jsonl_path: str
171
  """
172
- t_start = time.time()
173
- log.info(f"[scan] Starting scan: {video_path}")
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
- log.info(f"[scan] Video: {total} frames @ {fps:.1f}fps, "
184
- f"sampling every {stride} frames ({SAMPLE_FPS}fps)")
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
- t_sec = round(idx / fps, 3)
201
- sampled += 1
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
- # detect persons + faces (ONE face inference per frame, not per person)
210
- p_boxes = _detect_persons(frame, pdet)
211
- f_boxes = _detect_faces_once(frame, fdet) if p_boxes else []
212
- persons_seen += len(p_boxes)
213
-
214
- # build face crops per person
215
- crops = []
216
- for pb in p_boxes:
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
- elapsed = time.time() - t_start
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
- log.info(f"[scan] Done in {elapsed:.1f}s — "
261
- f"sampled {sampled} frames, "
262
- f"total tracks={len(all_tracks)}, "
263
- f"strong tracks={len(strong)}, "
264
- f"total person detections={persons_seen}")
265
-
266
- # write JSONL
267
- jsonl_path = str(Path(video_path).with_suffix(".tracks.jsonl"))
268
- rows_written = 0
269
- with open(jsonl_path, "w") as f:
270
- for tid, entry in strong.items():
271
- for sight in entry["sightings"]:
272
- f.write(json.dumps({
273
- "track_id": tid,
274
- "t": sight["t"],
275
- "bbox": sight["bbox"],
276
- "name": "",
277
- }) + "\n")
278
- rows_written += 1
279
- log.info(f"[scan] JSONL written: {jsonl_path} ({rows_written} rows)")
280
-
281
- tracks_list = [
282
- {"id": v["id"], "crop": v["crop"], "sightings": v["sightings"]}
283
- for v in strong.values()
284
- ]
285
- return tracks_list, motion, {"fps": fps, "n_frames": idx}, jsonl_path
286
-
287
-
288
- def write_names_to_jsonl(jsonl_path: str, id_to_name: dict[int, str]):
289
- log.info(f"[faces] Writing names to JSONL: {id_to_name}")
290
- rows = []
291
- with open(jsonl_path) as f:
292
- for line in f:
293
- row = json.loads(line)
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
- try:
308
- entries = []
309
- with open(jsonl_path) as f:
310
- for line in f:
311
- row = json.loads(line)
312
- if abs(row["t"] - t_sec) <= tol and row.get("name","").strip():
313
- entries.append(row)
314
- if entries:
315
- log.debug(f"[annotate] t={t_sec:.2f}s — burning {len(entries)} names")
316
- except (FileNotFoundError, json.JSONDecodeError) as e:
317
- log.warning(f"[annotate] Could not read JSONL: {e}")
318
- return Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
319
-
320
- for row in entries:
321
- name = row["name"].strip()
322
- if not name: continue
323
- x1,y1,x2,y2 = row["bbox"]
324
- scale = max(0.5,(x2-x1)/200.0)
325
- thick = max(1,int(scale*2))
326
- (tw,th),_ = cv2.getTextSize(name, cv2.FONT_HERSHEY_DUPLEX, scale, thick)
327
- tx=x1; ty=max(th+6,y1-8)
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
- if not ok:
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 STUDIO STYLESHEET
3
- marquee.css
 
 
4
  ============================================================ */
5
 
6
- :root {
7
- --fx: cubic-bezier(0.15, 0.85, 0.3, 1.0);
8
- --pop: cubic-bezier(0.2, 1.7, 0.4, 1.0);
9
- --primary: #B98BFF;
10
- --secondary: #FF3D8B;
11
- --accent: #3DE7FF;
12
- --gold: #FFC94D;
13
- --night: #120821;
14
- --night-2: #0a0414;
15
- --stage: #241241;
16
- --stage-2: #2E1A52;
17
- --raise: #382060;
18
- --line: #3A2A5E;
19
- --line-soft: #2C1E4C;
20
- --cream: #FFF3E9;
21
- --muted: #A98FC9;
22
- --muted-2: #7C66A6;
23
- --star-gold: #FFC94D;
24
- --star-hot: #FF4D9A;
25
- --star-cyan: #3DE7FF;
26
- --star-violet: #C18BFF;
27
- --star-lime: #9BE85B;
28
- --star-tang: #FF8A4D;
 
 
 
 
 
 
 
 
 
29
  }
30
 
31
- [data-vibe="football"] {
32
- --primary: #F5C842; --secondary: #4CAF50; --accent: #8BC34A;
33
- --night: #0a1a0f; --night-2: #050c07; --stage: #0f2415; --stage-2: #14351c; --raise: #1e4d2a;
34
- --line: rgba(76, 175, 80, 0.25); --line-soft: rgba(76, 175, 80, 0.12);
35
- --cream: #f0f4e8; --muted: #8aaa7a; --muted-2: #5f7a53;
 
 
 
 
 
 
 
 
 
 
36
  }
37
- [data-vibe="diva"] {
38
- --primary: #F4C2E1; --secondary: #D4AF37; --accent: #E91E8C;
39
- --night: #1a0a14; --night-2: #0e050b; --stage: #240f1d; --stage-2: #38152c; --raise: #4c1a3b;
40
- --line: rgba(233, 30, 140, 0.2); --line-soft: rgba(233, 30, 140, 0.1);
41
- --cream: #fdf0f7; --muted: #b88aa8; --muted-2: #865f79;
42
  }
43
- [data-vibe="wildlife"] {
44
- --primary: #C8A84B; --secondary: #2E7D32; --accent: #8D6E3C;
45
- --night: #0d1a0a; --night-2: #060c04; --stage: #141f0f; --stage-2: #1f3017; --raise: #2b4520;
46
- --line: rgba(200, 168, 75, 0.3); --line-soft: rgba(200, 168, 75, 0.15);
47
- --cream: #e8dcc8; --muted: #8a9e78; --muted-2: #5c6e50;
 
 
 
 
 
 
 
 
48
  }
49
- [data-vibe="boxing"] {
50
- --primary: #E53935; --secondary: #FFB300; --accent: #FF6F00;
51
- --night: #0f0505; --night-2: #080202; --stage: #1a0808; --stage-2: #2b0c0c; --raise: #3d1111;
52
- --line: rgba(229, 57, 53, 0.3); --line-soft: rgba(229, 57, 53, 0.15);
53
- --cream: #fdf0ee; --muted: #aa8080; --muted-2: #7c5858;
 
 
 
 
 
54
  }
55
- [data-vibe="masterchef"] {
56
- --primary: #FF8F00; --secondary: #ECEFF1; --accent: #F57F17;
57
- --night: #080808; --night-2: #020202; --stage: #101010; --stage-2: #1b1b1b; --raise: #262626;
58
- --line: rgba(255, 255, 255, 0.07); --line-soft: rgba(255, 255, 255, 0.03);
59
- --cream: #f5f5f5; --muted: #9e9e9e; --muted-2: #616161;
60
  }
 
 
 
 
 
 
 
 
 
 
 
61
 
62
- /* ============================================================ BASE ============================================================ */
63
- * { box-sizing: border-box; margin: 0; padding: 0; }
64
- html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
65
- body {
66
- font-family: 'Nunito', system-ui, sans-serif;
67
- color: var(--cream); background-color: var(--night); min-height: 100vh; line-height: 1.5;
68
- transition: background-color 0.8s var(--fx), color 0.8s var(--fx); overflow-x: hidden;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  }
70
- body::before {
71
- content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 99; opacity: 0.015;
72
- background: repeating-linear-gradient(0deg, #000 0px, #000 1px, transparent 1px, transparent 3px);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  }
74
- body::after {
75
- content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 80; opacity: .03; mix-blend-mode: overlay;
76
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.95' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  }
78
- .disp { font-family: 'Unbounded', sans-serif; }
79
- .marq { font-family: 'Bebas Neue', sans-serif; letter-spacing: .06em; }
80
- svg.i { width: 1em; height: 1em; display: block; flex: none; }
81
- .shell { max-width: 1240px; margin: 0 auto; padding: clamp(20px,4vw,40px) clamp(16px,3vw,32px) 60px; position: relative; z-index: 1; }
82
-
83
- /* ============================================================ MASTHEAD ============================================================ */
84
- .masthead { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding-bottom: 24px; margin-bottom: 12px; flex-wrap: wrap; border-bottom: 1px solid var(--line); }
85
- .brand { display: flex; align-items: center; gap: 20px; }
86
- .sign {
87
- position: relative; padding: 14px 24px 15px; border-radius: 12px;
88
- border: 2px solid rgba(255,201,77,0.3);
89
- background: radial-gradient(120% 140% at 50% 0%, rgba(255,201,77,0.1), transparent 75%), linear-gradient(180deg, rgba(21,21,37,0.4), rgba(11,11,20,0.8));
90
- box-shadow: inset 0 0 20px rgba(255,201,77,0.06), 0 10px 40px rgba(0,0,0,0.5);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  }
92
- .sign h1 { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: clamp(22px,3vw,32px); letter-spacing: .06em; line-height: 1; color: var(--cream); text-shadow: 0 0 14px rgba(255,201,77,0.35); }
93
- .sign .bulbs { position: absolute; left: 16px; right: 16px; display: flex; justify-content: space-between; }
94
- .sign .bulbs.t { top: -5px; }
95
- .sign .bulbs.b { bottom: -5px; }
96
- .sign .bulbs i { width: 6px; height: 6px; border-radius: 50%; background: var(--gold); box-shadow: 0 0 8px var(--gold), 0 0 2px #fff; animation: tw 2s infinite; }
97
- .sign .bulbs i:nth-child(2n) { animation-delay: .5s; background: var(--secondary); box-shadow: 0 0 8px var(--secondary), 0 0 2px #fff; }
98
- .sign .bulbs i:nth-child(3n) { animation-delay: 1.2s; background: var(--accent); box-shadow: 0 0 8px var(--accent), 0 0 2px #fff; }
99
- @keyframes tw { 0%,100% { opacity: 1; filter: brightness(1.2); } 50% { opacity: .2; filter: brightness(0.6); } }
100
- .tag { font-family: 'Unbounded', sans-serif; font-weight: 500; font-size: 13px; color: var(--primary); letter-spacing: .02em; }
101
- .tag small { display: block; font-family: 'Nunito', sans-serif; font-weight: 600; color: var(--muted); font-size: 12px; margin-top: 4px; letter-spacing: 0; }
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 STUDIO JAVASCRIPT
3
- marquee.js (type="module")
 
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
- $('#changeVibeBtn')?.addEventListener('click', () => {
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 = await fetch(`${BASE}/api/scan`, { method: 'POST', body: fd });
303
  const data = await res.json();
304
  if (!data.ok) throw new Error(data.error || 'scan failed');
305
  return data;
306
  }
307
 
308
- async function apiGenerate(sessionId, names, vibe) {
309
- if (isOfflineMode) {
310
- await new Promise(r => setTimeout(r, 1500));
311
- const baseScript = MOCK_SCRIPTS[vibe] || MOCK_SCRIPTS.football;
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 apiTts(sessionId, vibe, script, voiceModel = 'chatterbox') {
330
- if (isOfflineMode) {
331
- return script.map(l => ({ ...l, audio_b64: null, duration: 4 }));
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 = await res.json();
340
- if (!data.ok) throw new Error(data.error || 'tts failed');
341
- return data.lines;
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
- const AUDIO = new AudioEngine();
 
452
 
453
- /* ============================================================
454
- VIBE CHANGE SOUND FX
455
- ============================================================ */
456
- let soundCtx = null;
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) { return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; }
509
- setInterval(() => { const t = fmtClock(new Date()); clocks.forEach(c => c.textContent = t); }, 1000);
510
- clocks.forEach(c => c.textContent = fmtClock(new Date()));
511
-
512
- /* ============================================================
513
- STATUS BAR
514
- ============================================================ */
515
- const status = $('#status'), statusTxt = $('#statusTxt');
516
- function setStatus(txt, live) { statusTxt.textContent = txt; status.classList.toggle('live', !!live); }
517
-
518
- /* ============================================================
519
- RUNDOWN TRACKER
520
- ============================================================ */
521
- const cues = $$('#rundown .cue');
522
- function setStep(n) {
523
- cues.forEach(c => {
524
- const s = +c.dataset.step;
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
- ROLL TAPE (File Upload)
534
- ============================================================ */
535
- const drop = $('#drop'), castBtn = $('#castBtn'), liveBtn = $('#liveBtn'), rollCard = $('#rollCard');
536
- const fileInput = $('#fileInput'), fileName = $('#fileName'), fileDur = $('#fileDur');
 
 
 
 
 
 
 
 
 
 
 
 
 
537
 
538
- function handleFile(file) {
539
- if (!file) return;
540
- selectedFile = file;
 
 
 
 
541
  drop.classList.add('loaded');
542
- fileName.textContent = file.name;
543
- fileDur.textContent = 'reading…';
544
- const url = URL.createObjectURL(file);
545
- const probe = document.createElement('video');
546
- probe.preload = 'metadata';
547
- probe.onloadedmetadata = () => { fileDur.textContent = fmt(probe.duration) + ' | ready to cast'; URL.revokeObjectURL(url); };
548
- probe.onerror = () => { fileDur.textContent = 'ready to cast'; };
549
- probe.src = url;
550
- castBtn.disabled = false;
551
- setStatus('Tape loaded'); toast('Tape loaded | ready to cast');
552
  }
553
- drop.addEventListener('click', () => fileInput.click());
554
- drop.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } });
555
- fileInput.addEventListener('change', e => handleFile(e.target.files[0]));
556
- ['dragover','dragenter'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor = 'var(--primary)'; }));
557
- ['dragleave','drop'].forEach(ev => drop.addEventListener(ev, e => {
558
- e.preventDefault(); drop.style.borderColor = '';
559
- if (ev === 'drop' && e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
560
  }));
561
 
562
- /* ============================================================
563
- SCAN PROCESS
564
- ============================================================ */
565
- const stagePanel = $('#stagePanel'), stageEmpty = $('#stageEmpty'), roster = $('#roster'), starCount = $('#starCount');
566
- castBtn.addEventListener('click', startCasting);
567
 
568
- async function startCasting() {
569
- if (castBtn.disabled || (!selectedFile && !isOfflineMode)) return;
570
- castBtn.disabled = true; rollCard.classList.add('casting');
571
  setStep(2); setStatus('Casting…');
572
- castBtn.innerHTML = '<svg class="i"><use href="#i-spot"/></svg> Scanning the tape…';
573
- sessionStorage.removeItem('mq_scan');
574
- try {
575
  toast('Uploading tape…');
576
  const data = await apiScan(selectedFile);
577
- SESSION = data.session_id; VIDEO_URL = data.video_url; DUR = data.duration || 0;
578
- const v = $('#vid');
579
- if (VIDEO_URL) { v.src = VIDEO_URL; $('#screen').classList.add('has-video'); }
580
- sessionStorage.setItem('mq_scan', JSON.stringify({
581
- session_id: data.session_id, video_url: data.video_url,
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: ' + (err.message || err));
588
  rollCard.classList.remove('casting');
589
- castBtn.disabled = false;
590
- castBtn.innerHTML = '<svg class="i"><use href="#i-spot"/></svg> Find the stars';
591
  }
592
  }
593
 
594
- function revealCast(starData) {
595
  rollCard.classList.remove('casting');
596
- castBtn.innerHTML = '<svg class="i"><use href="#i-spot"/></svg> Re-cast';
597
- castBtn.disabled = false;
598
- setStep(3); setStatus(starData.length + ' stars found');
599
-
600
- stars = starData.map((s, i) => ({ name:'', tier: s.tier || 'STAR', color: COLORS[i % COLORS.length], crop: s.crop || null }));
601
- stageEmpty.style.display = 'none';
602
- roster.hidden = false; roster.innerHTML = '';
603
- starCount.textContent = stars.length + ' STARS FOUND';
604
- stars.forEach((s, i) => roster.appendChild(buildStar(s, i)));
605
-
606
- if (!stars.length) {
607
- stageEmpty.style.display = '';
608
- stageEmpty.querySelector('.t').textContent = 'No stars spotted';
609
- stageEmpty.querySelector('.s').innerHTML = 'No faces found | but the show still goes on. Pick a vibe and go live for nameless narration.';
610
- roster.hidden = true;
611
  }
612
- toast(stars.length ? stars.length + ' stars found | give them stage names' : 'No faces found | you can still go live');
613
  refreshLive();
614
  }
615
 
616
- /* ============================================================
617
- STAR CARD BUILDER
618
- ============================================================ */
619
- const COLORS = ['--star-gold','--star-hot','--star-cyan','--star-violet','--star-lime','--star-tang'];
620
- function buildStar(s, i) {
621
- const el = document.createElement('div');
622
- el.className = 'star';
623
- el.style.setProperty('--c', `var(${s.color})`);
624
- if (!reduce) {
625
  el.animate(
626
- [{ opacity:0, transform:'translateY(14px) scale(.98)' }, { opacity:1, transform:'none' }],
627
- { duration:480, delay: i * 90, easing:'cubic-bezier(.2,.9,.25,1)', fill:'backwards' }
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" value="${s.name}" maxlength="22" autocomplete="off">
645
  </div>`;
646
- const input = el.querySelector('input');
647
- input.addEventListener('input', () => {
648
- const v = input.value.trim();
649
- s.name = v;
650
- const wasNamed = el.classList.contains('named');
651
- el.classList.toggle('named', !!v);
652
- if (v && !wasNamed && !reduce) popCrown(el);
653
  refreshLive();
654
  });
655
  return el;
656
  }
657
-
658
- function popCrown(el) {
659
- const c = el.querySelector('.crown');
660
- c.style.animation = 'none'; void c.offsetWidth;
661
- c.animate(
662
- [{ transform:'translateX(-50%) translateY(10px) scale(.4) rotate(-12deg)', opacity:0 },
663
- { transform:'translateX(-50%) scale(1.25) rotate(8deg)', opacity:1, offset:.6 },
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
- VOICE SELECTORS
671
- ============================================================ */
672
- const voiceOpts = $('#voiceOpts');
673
- if (voiceOpts) {
674
- voiceOpts.addEventListener('click', e => {
675
- const btn = e.target.closest('.voice-opt');
676
- if (!btn) return;
677
- $$('.voice-opt', voiceOpts).forEach(b => b.setAttribute('aria-pressed', 'false'));
678
- btn.setAttribute('aria-pressed', 'true');
679
- currentVoiceModel = btn.dataset.model;
680
- });
 
 
 
 
 
 
 
 
 
 
681
  }
682
 
683
- /* ============================================================
684
- SHOWTIME VALIDATORS
685
- ============================================================ */
686
- function namedCount() { return stars.filter(s => s.name).length; }
687
- function refreshLive() {
688
- liveBtn.disabled = !(currentVibe && (SESSION || isOfflineMode));
689
- if (stars.length) {
690
- const n = namedCount();
691
- starCount.textContent = n ? `${n}/${stars.length} NAMED` : `${stars.length} STARS FOUND`;
692
  }
693
  }
694
 
695
- /* ============================================================
696
- GENERATION SEQUENCE
697
- ============================================================ */
698
- liveBtn.addEventListener('click', async () => {
699
- if (liveBtn.disabled) return;
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 = stars.map(s => s.name || '');
705
- const data = await apiGenerate(SESSION, names, currentVibe);
706
- REAL_SCRIPT = data.script || [];
707
-
708
- setStatus('Generating voice…');
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(() => { switchView('broadcast'); }, reduce ? 0 : 450);
723
- } catch(err) {
724
  setStatus('Showtime failed');
725
- toast('Showtime failed: ' + (err.message || err));
726
- } finally {
727
- liveBtn.disabled = false;
728
- liveBtn.innerHTML = '<svg class="i"><use href="#i-star"/></svg> Showtime | go live';
729
  }
730
  });
731
 
732
- /* ============================================================
733
- VIEW CONTROLLERS
734
- ============================================================ */
735
- const setupView = $('#setupView'), broadcastView = $('#broadcastView');
736
- function switchView(which) {
737
- const toBroadcast = which === 'broadcast';
738
- setupView.classList.toggle('active', !toBroadcast);
739
- broadcastView.classList.toggle('active', toBroadcast);
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 MONITOR PLAYER
752
  ============================================================ */
753
- let script = [];
754
- const vid = $('#vid'), screen = $('#screen'), chyron = $('#chyron'), chyWho = $('#chyWho'), chyLine = $('#chyLine'),
755
- linesEl = $('#lines'), pbpCt = $('#pbpCt'), tcEl = $('#tc'), fill = $('#fill'), knob = $('#knob'),
756
- pp = $('#pp'), scrub = $('#scrub'), tcDur = $('#tcDur');
757
- const ICON_PLAY = '<svg class="i"><use href="#i-play"/></svg>';
758
- const ICON_PAUSE = '<svg class="i"><use href="#i-pause"/></svg>';
759
-
760
- function fmt(s) { s = isFinite(s) ? s : 0; return pad(Math.floor(s / 60)) + ':' + pad(Math.floor(s % 60)); }
761
-
762
- function buildBroadcast() {
763
- const d = VIBES[currentVibe] || { vc:'--primary', who:'COCO' };
764
- const who = (d.who || 'COCO').split(' ')[0];
765
- script = (REAL_SCRIPT || []).map(l => ({ t: +l.t || 0, text: l.text || '', who, cc: d.vc }));
766
- linesEl.innerHTML = '';
767
- pbpCt.textContent = script.length + ' LINES';
768
- if (!script.length) {
769
- linesEl.innerHTML = '<div class="pl"><span class="tx">No commentary generated | try another clip.</span></div>';
770
  }
771
- script.forEach((l, i) => {
772
- const row = document.createElement('div');
773
- row.className = 'pl'; row.dataset.i = i;
774
- row.style.setProperty('--cc', `var(${l.cc})`);
775
- const hasAudio = TTS_LINES[i]?.audio_b64 ? 'Voice: ' : '';
776
- row.innerHTML = `<span class="t">${fmt(l.t)}</span><span class="tx"><b>${l.who}</b> | ${hasAudio}${l.text}</span>`;
777
- row.addEventListener('click', () => seek(l.t));
778
  linesEl.appendChild(row);
779
  });
780
- if (vid) { try { vid.currentTime = 0; } catch(e) {} }
781
- activeLine = -1; renderPlayhead();
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
- let activeLine = -1, rafP = null;
791
- function dur() { return DUR || (vid && vid.duration) || 30; }
792
- function renderPlayhead() {
793
- const D = dur(), cur = (vid && vid.currentTime) || 0;
794
- const pct = D ? (cur / D) * 100 : 0;
795
- fill.style.width = pct + '%'; knob.style.left = pct + '%'; tcEl.textContent = fmt(cur);
796
- let idx = -1;
797
- for (let i = 0; i < script.length; i++) { if (script[i].t <= cur) idx = i; else break; }
798
- if (idx !== activeLine) {
799
- activeLine = idx;
800
- $$('.pl', linesEl).forEach((r, i) => r.classList.toggle('now', i === idx));
 
801
  showChyron(idx);
802
- if (idx >= 0) {
803
- const r = linesEl.children[idx];
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 (idx < 0) { chyron.classList.remove('in'); return; }
811
- const l = script[idx];
812
  chyron.classList.remove('in');
813
- requestAnimationFrame(() => {
814
- chyron.style.setProperty('--vibe-primary', `var(${l.cc})`);
815
- chyWho.textContent = l.who; chyLine.textContent = l.text;
816
- requestAnimationFrame(() => chyron.classList.add('in'));
817
  });
818
  }
819
- function loop() { renderPlayhead(); if (vid && !vid.paused && !vid.ended) rafP = requestAnimationFrame(loop); }
820
- function seek(t) {
821
- if (vid) vid.currentTime = Math.max(0, Math.min(dur(), t));
822
- if (isOfflineMode) renderPlayhead();
823
- }
824
-
825
- if (vid) {
826
- vid.addEventListener('loadedmetadata', () => { if (vid.duration && isFinite(vid.duration)) DUR = vid.duration; tcDur.textContent = fmt(dur()); renderPlayhead(); });
827
- vid.addEventListener('play', () => { pp.innerHTML = ICON_PAUSE; loop(); if (AUDIO.ctx?.state === 'suspended') AUDIO.ctx.resume(); AUDIO.scheduleAll(vid, TTS_LINES); });
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
- // Offline studio timing handler
835
- let mockPlayTimer = null;
836
- pp.addEventListener('click', () => {
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
- AUDIO VOLUME CONTROLLERS
861
- ============================================================ */
862
- function buildAudioControls() {
863
- const existing = $('#audioControls');
864
- if (existing) existing.remove();
865
- const bar = document.createElement('div');
866
- bar.id = 'audioControls';
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
- SRT EXPORT
883
- ============================================================ */
884
- $('#srtBtn').addEventListener('click', () => {
885
- if (!script.length) { toast('Go live first'); return; }
886
- const D = dur();
887
- const srt = script.map((l, i) => {
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 = new Blob([srt], { type: 'text/plain' });
892
- try {
893
- const a = document.createElement('a');
894
- a.href = URL.createObjectURL(blob);
895
- a.download = `marquee_${currentVibe || 'show'}_captions.srt`;
896
- a.click(); URL.revokeObjectURL(a.href);
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
- function srtTime(s) {
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
- EXPORT MP4 FLOW
910
- ============================================================ */
911
- const exportBtn = $('#exportBtn');
912
- if (exportBtn) {
913
- exportBtn.addEventListener('click', async () => {
914
- if (!isOfflineMode && (!SESSION || !TTS_LINES.length)) { toast('Go live first'); return; }
915
-
916
- const progress = $('#exportProgress'), fill2 = $('#exportFill'), label = $('#exportLabel'), hint = $('#exportHint');
917
- exportBtn.disabled = true;
918
- exportBtn.innerHTML = '<svg class="i ic"><use href="#i-download"/></svg> Mixing…';
919
- progress.hidden = false; hint.hidden = true; fill2.style.width = '0%';
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(() => c.remove(), 3600);
964
  }
965
  }
966
 
967
- /* ============================================================
968
- TOAST ALERTS
969
- ============================================================ */
970
- const toastEl = $('#toast'), toastTxt = $('#toastTxt'); let toastT;
971
- function toast(msg) {
972
- toastTxt.textContent = msg; toastEl.classList.add('show');
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 &amp; 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 Marquee.
2
-
3
- Models used (all CPU, all from Open Model Zoo):
4
- - face-detection-retail-0004 : SSD face detector (for star card crops)
5
- - person-detection-retail-0013 : SSD person detector (for tracking)
6
-
7
- Dropped from original:
8
- - landmarks-regression-retail-0009 (only needed for re-id alignment)
9
- - face-reidentification-retail-0095 (replaced by IoU tracker in faces.py)
10
-
11
- [OpenVINO 101] Models ship as .xml (graph) + .bin (weights). Compile once
12
- for CPU, infer many times. Never touches GPU — no ZeroGPU contention.
 
 
 
 
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 = "FP16"
24
 
25
- # Models we actually need now
26
  MODEL_NAMES = [
27
- "face-detection-retail-0004", # face crops for star card UI
28
- "person-detection-retail-0013", # person bboxes for IoU tracking
 
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 = MODELS_DIR / name
40
- xml = dst / f"{name}.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 = f"{_OMZ_BASE}/{name}/{PRECISION}/{target.name}"
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 = _core.read_model(_find_xml(name))
66
- self.compiled = _core.compile_model(model, "CPU")
67
- self.input = self.compiled.input(0)
 
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
- return img.transpose(2, 0, 1)[None].astype(np.float32) # HWC->NCHW
 
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