hmb HF Staff Claude Opus 4.6 (1M context) commited on
Commit
752b527
Β·
1 Parent(s): 247b81b

Redesign: dark neural observatory aesthetic

Browse files

Syne + DM Mono typography, charcoal background, amber accent,
glassmorphic zone cards, stat grid, radial glow header.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +398 -160
app.py CHANGED
@@ -26,29 +26,20 @@ def get_model():
26
  return _model
27
 
28
  # fsaverage5 cortical region mapping (approximate vertex index ranges)
29
- # These map TRIBE's ~20k output vertices to broad cortical zones.
30
  ZONE_VERTEX_RANGES = {
31
- "Frontal zone": (0, 3500),
32
- "Action zone": (3500, 6500), # motor / premotor cortex
33
- "Attention zone": (6500, 10000), # parietal / dorsal attention
34
- "Speech & recognition zone": (10000, 14500), # temporal / auditory
35
- "Visual zone": (14500, 20484), # occipital / visual cortex
36
  }
37
 
38
- ZONE_DESCRIPTIONS = {
39
- "Frontal zone": "Evaluates meaning, intent, and what matters most in the frame.",
40
- "Action zone": "Tracks movement, gestures, physical action, and body dynamics.",
41
- "Attention zone": "Relates to how attention spreads across the frame and where the viewer is likely to look.",
42
- "Speech & recognition zone": "Supports speech, sound, face, and familiar-object recognition.",
43
- "Visual zone": "Processes the image itself: shape, contrast, color, motion, and detail.",
44
- }
45
-
46
- ZONE_COLORS = {
47
- "Frontal zone": "#d4af37",
48
- "Action zone": "#e07830",
49
- "Attention zone": "#3cb371",
50
- "Speech & recognition zone": "#cd5c5c",
51
- "Visual zone": "#4682b4",
52
  }
53
 
54
 
@@ -63,37 +54,32 @@ def normalize(arr):
63
 
64
  @spaces.GPU
65
  def analyze_video(video_path):
66
- """Run TRIBE v2 inference and extract zone signals + composite engagement."""
67
  m = get_model()
68
  df = m.get_events_dataframe(video_path=video_path)
69
  preds, segments = m.predict(events=df)
70
- # preds shape: (n_timesteps, n_vertices)
71
 
72
  n_steps = preds.shape[0]
73
  if n_steps < 2:
74
  raise gr.Error("Video too short to analyze.")
75
 
76
- # Build timestamps from segments (each step ~1s with 5s hemodynamic offset)
77
  if hasattr(segments, "start_time"):
78
  timestamps = [float(s.start_time) for s in segments]
79
  else:
80
  timestamps = list(np.arange(n_steps) * 1.0)
81
  duration = timestamps[-1] if timestamps else n_steps
82
 
83
- # Extract per-zone activation over time
84
  zones = {}
85
  for zone_name, (start, end) in ZONE_VERTEX_RANGES.items():
86
  end = min(end, preds.shape[1])
87
  zone_signal = np.mean(preds[:, start:end], axis=1)
88
  zones[zone_name] = normalize(zone_signal)
89
 
90
- # Composite engagement = weighted average of all zones
91
  engagement = normalize(
92
- 0.25 * zones["Frontal zone"]
93
- + 0.20 * zones["Action zone"]
94
- + 0.25 * zones["Attention zone"]
95
- + 0.15 * zones["Speech & recognition zone"]
96
- + 0.15 * zones["Visual zone"]
97
  )
98
 
99
  return timestamps, engagement, zones, duration
@@ -103,36 +89,51 @@ def analyze_video(video_path):
103
 
104
  def make_engagement_chart(timestamps, engagement):
105
  fig = go.Figure()
 
 
106
  fig.add_trace(go.Scatter(
107
  x=timestamps, y=engagement,
108
  mode="lines",
109
- line=dict(color="#c8a84e", width=2),
110
  fill="tozeroy",
111
- fillcolor="rgba(200,168,78,0.08)",
112
- hovertemplate="Time: %{x:.1f}s<br>Response: %{y:.0f}<extra></extra>",
113
  ))
 
114
  fig.update_layout(
115
  template="plotly_dark",
116
- paper_bgcolor="#1a1a1a",
117
- plot_bgcolor="#1a1a1a",
118
- margin=dict(l=40, r=20, t=10, b=40),
119
- height=220,
120
- xaxis=dict(title="", showgrid=False, color="#888"),
121
- yaxis=dict(title="", showgrid=False, range=[0, 105], color="#888"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  )
123
  return fig
124
 
125
 
126
  # ── Feedback generation ─────────────────────────────────────────────────────
127
 
128
- def zone_activity_label(mean_val):
129
- if mean_val >= 70:
130
- return "more active now"
131
- if mean_val >= 40:
132
- return "active now"
133
- return "low activity"
134
-
135
-
136
  def generate_feedback(engagement, zones):
137
  avg = float(np.mean(engagement))
138
  mx = float(np.max(engagement))
@@ -146,91 +147,115 @@ def generate_feedback(engagement, zones):
146
  end_avg = float(np.mean(engagement[-hook_len:]))
147
 
148
  if avg >= 72:
149
- strength = "Strong"
150
  elif avg >= 50:
151
- strength = "Average"
152
  else:
153
- strength = "Weak"
154
 
155
  zone_avgs = {name: float(np.mean(sig)) for name, sig in zones.items()}
156
  weakest_zone = min(zone_avgs, key=zone_avgs.get)
157
 
158
  recs = []
159
  if hook_avg < 55:
160
- recs.append("**Weak hook.** The first few seconds don't grab attention. Start with your strongest visual or a surprising moment.")
161
  if end_avg < 45:
162
- recs.append("**Flat ending.** Viewers drop off at the end. Add a payoff, callback, or cliffhanger in the final seconds.")
163
  if weak_pct > 30:
164
- recs.append(f"**{weak_pct:.0f}% dead air.** Too many flat moments. Cut or speed up the low-engagement stretches.")
165
 
166
  zone_recs = {
167
- "Action zone": "Add more movement, gestures, or physical action to keep the body-dynamics signal alive.",
168
- "Attention zone": "Vary your shots more β€” the visual pace is too uniform. Add cuts, zooms, or angle changes.",
169
- "Speech & recognition zone": "More face time or recognizable elements. Faces and familiar objects are powerful anchors.",
170
- "Visual zone": "The visual texture is flat. Play with contrast, color grading, or compositional variety.",
171
- "Frontal zone": "The content lacks a clear narrative thread. Give the viewer something to follow or anticipate.",
172
  }
173
  if zone_avgs[weakest_zone] < 55:
174
- recs.append(f"**Weakest zone: {weakest_zone}.** {zone_recs.get(weakest_zone, '')}")
175
 
176
  if not recs:
177
- recs.append("Looking solid. Minor gains possible from tightening pacing in the middle third.")
178
-
179
- summary = f"""### Overall: {strength} (avg {avg:.0f}/100)
180
-
181
- | Metric | Value |
182
- |--------|-------|
183
- | Average | {avg:.0f} |
184
- | Peak | {mx:.0f} |
185
- | Floor | {mn:.0f} |
186
- | Hook (first 10%) | {hook_avg:.0f} |
187
- | Ending (last 10%) | {end_avg:.0f} |
188
- | Dead air | {weak_pct:.0f}% |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
- ### Recommendations
191
-
192
- """
193
- for r in recs:
194
- summary += f"- {r}\n"
195
-
196
- return summary, zone_avgs, strength
197
-
198
-
199
- # ── Zone bars HTML ──────────────────────────────────────────────────────────
200
 
201
  def make_zone_html(zone_avgs):
202
- html = '<div style="display:flex;flex-direction:column;gap:16px;">'
203
- for zone_name, desc in ZONE_DESCRIPTIONS.items():
204
  val = zone_avgs.get(zone_name, 0)
205
- color = ZONE_COLORS[zone_name]
206
- label = zone_activity_label(val)
207
- html += f"""
208
- <div style="background:#fafaf7;border-radius:12px;padding:16px 18px;border:1px solid #e8e5dd;">
209
- <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
210
- <span style="width:10px;height:10px;border-radius:50%;background:{color};display:inline-block;"></span>
211
- <strong style="font-size:14px;">{zone_name}</strong>
 
 
 
 
 
 
 
 
 
212
  </div>
213
- <div style="font-size:12px;color:#888;margin-bottom:2px;">{label}</div>
214
- <div style="font-size:12px;color:#666;margin-bottom:8px;">{desc}</div>
215
- <div style="background:#e8e5dd;border-radius:4px;height:6px;overflow:hidden;">
216
- <div style="background:{color};height:100%;width:{val:.0f}%;border-radius:4px;"></div>
217
  </div>
 
218
  </div>"""
219
- html += "</div>"
220
- return html
221
 
222
 
223
  # ── Main handlers ───────────────────────────────────────────────────────────
224
 
225
  def process_video(video):
226
  if video is None:
227
- raise gr.Error("Please upload a video.")
228
  timestamps, engagement, zones, duration = analyze_video(video)
229
  chart = make_engagement_chart(timestamps, engagement)
230
- feedback, zone_avgs, strength = generate_feedback(engagement, zones)
 
231
  zone_html = make_zone_html(zone_avgs)
232
- stats_text = f"Avg: {np.mean(engagement):.0f} Β· Max: {np.max(engagement):.0f} Β· Min: {np.min(engagement):.0f} Β· Duration: {duration:.1f}s"
233
- return chart, zone_html, feedback, stats_text
234
 
235
 
236
  def process_comparison(video_a, video_b):
@@ -243,93 +268,309 @@ def process_comparison(video_a, video_b):
243
  fig = go.Figure()
244
  fig.add_trace(go.Scatter(
245
  x=ts_a, y=eng_a, mode="lines", name="Video A",
246
- line=dict(color="#c8a84e", width=2),
247
  ))
248
  fig.add_trace(go.Scatter(
249
  x=ts_b, y=eng_b, mode="lines", name="Video B",
250
- line=dict(color="#4682b4", width=2),
251
  ))
252
  fig.update_layout(
253
  template="plotly_dark",
254
- paper_bgcolor="#1a1a1a", plot_bgcolor="#1a1a1a",
255
- margin=dict(l=40, r=20, t=10, b=40),
256
- height=280,
257
- xaxis=dict(title="Time (s)", showgrid=False, color="#888"),
258
- yaxis=dict(title="Response", showgrid=False, range=[0, 105], color="#888"),
259
- legend=dict(font=dict(color="#ccc")),
 
 
 
 
 
 
260
  )
261
 
262
  avg_a, avg_b = np.mean(eng_a), np.mean(eng_b)
263
  winner = "A" if avg_a > avg_b else "B"
264
  diff = abs(avg_a - avg_b)
265
 
266
- summary = f"""### A/B Comparison
267
-
268
- | | Video A | Video B |
269
  |--|---------|---------|
270
- | **Avg response** | {avg_a:.0f} | {avg_b:.0f} |
271
  | **Peak** | {np.max(eng_a):.0f} | {np.max(eng_b):.0f} |
272
  | **Floor** | {np.min(eng_a):.0f} | {np.min(eng_b):.0f} |
273
  | **Duration** | {dur_a:.1f}s | {dur_b:.1f}s |
274
 
275
- **Video {winner}** has stronger predicted engagement (+{diff:.0f} avg).
276
  """
277
  return fig, summary
278
 
279
 
280
- # ── UI ──────────────────────────────────────────────────────────────────────
281
 
282
- HEADER_TEXT = (
283
- "The model shows where the video seems to affect the viewer more or less strongly. "
284
- "It is based on an average viewer model, not one real person. "
285
- "Treat each timestamp as an approximate editing zone and inspect the nearby window around it."
286
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
 
288
  theme = gr.themes.Base(
289
  primary_hue=gr.themes.colors.amber,
290
- secondary_hue=gr.themes.colors.stone,
291
- neutral_hue=gr.themes.colors.stone,
292
- font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
293
- font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "monospace"],
294
  ).set(
295
- body_background_fill="#f5f3ee",
296
- block_background_fill="#ffffff",
297
- block_border_width="1px",
298
- block_border_color="#e8e5dd",
299
- block_radius="12px",
300
- block_shadow="0 1px 3px rgba(0,0,0,0.04)",
301
- input_background_fill="#fafaf7",
 
 
 
302
  )
303
 
304
- with gr.Blocks(theme=theme, title="TRIBE2 – Video Engagement Analyzer") as demo:
305
- gr.Markdown(f"# TRIBE2\n\n{HEADER_TEXT}")
306
-
307
- with gr.Tab("Workspace"):
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  with gr.Row():
309
- video_input = gr.Video(label="Video", height=400)
310
-
311
- with gr.Column(scale=2):
312
- stats = gr.Textbox(label="Stats", interactive=False, lines=1)
313
- chart = gr.Plot(label="Predicted response over time")
314
-
315
  with gr.Column(scale=1):
316
- zone_html = gr.HTML(label="Brain zones")
317
-
318
- analyze_btn = gr.Button("Analyze", variant="primary", size="lg")
319
- feedback = gr.Markdown(label="Feedback")
320
 
321
  analyze_btn.click(
322
  fn=process_video,
323
  inputs=[video_input],
324
- outputs=[chart, zone_html, feedback, stats],
325
  )
326
 
327
- with gr.Tab("Compare (A/B)"):
328
  with gr.Row():
329
  vid_a = gr.Video(label="Video A")
330
  vid_b = gr.Video(label="Video B")
331
  compare_btn = gr.Button("Compare", variant="primary", size="lg")
332
- compare_chart = gr.Plot(label="Response comparison")
333
  compare_md = gr.Markdown()
334
 
335
  compare_btn.click(
@@ -338,24 +579,21 @@ with gr.Blocks(theme=theme, title="TRIBE2 – Video Engagement Analyzer") as dem
338
  outputs=[compare_chart, compare_md],
339
  )
340
 
341
- with gr.Tab("Details"):
342
- gr.Markdown("""### How it works
343
-
344
- **TRIBE2** uses Meta's [TRIBE v2](https://huggingface.co/facebook/tribev2) brain-encoding foundation model to predict fMRI-level cortical responses to your video.
345
 
346
- The model combines **V-JEPA2** (vision), **Wav2Vec-BERT 2.0** (audio), and **LLaMA 3.2** (language) to predict activation across ~20,000 cortical vertices on the fsaverage5 brain surface.
 
347
 
348
- We aggregate those vertex-level predictions into five broad cortical zones:
349
 
350
- - **Frontal zone** β€” prefrontal cortex: meaning, intent, narrative comprehension
351
- - **Action zone** β€” motor / premotor cortex: movement, gestures, body dynamics
352
- - **Attention zone** β€” parietal / dorsal attention: gaze, spatial focus, visual salience
353
- - **Speech & recognition zone** β€” temporal cortex: speech, sound, face and object recognition
354
- - **Visual zone** β€” occipital cortex: shape, contrast, color, motion, detail
355
 
356
- These are combined into a single **predicted response curve** that estimates where viewers engage or tune out.
 
357
 
358
- Note: predictions include a ~5s hemodynamic offset (inherent to fMRI). Treat timestamps as approximate editing zones.
359
  """)
360
 
361
 
 
26
  return _model
27
 
28
  # fsaverage5 cortical region mapping (approximate vertex index ranges)
 
29
  ZONE_VERTEX_RANGES = {
30
+ "Frontal": (0, 3500),
31
+ "Action": (3500, 6500),
32
+ "Attention": (6500, 10000),
33
+ "Speech": (10000, 14500),
34
+ "Visual": (14500, 20484),
35
  }
36
 
37
+ ZONE_META = {
38
+ "Frontal": {"desc": "Meaning, intent, narrative focus", "color": "#D4A017", "icon": "01"},
39
+ "Action": {"desc": "Movement, gestures, body dynamics", "color": "#E8651A", "icon": "02"},
40
+ "Attention": {"desc": "Gaze direction, spatial salience", "color": "#22C55E", "icon": "03"},
41
+ "Speech": {"desc": "Voice, faces, object recognition", "color": "#EF4444", "icon": "04"},
42
+ "Visual": {"desc": "Shape, contrast, color, motion", "color": "#3B82F6", "icon": "05"},
 
 
 
 
 
 
 
 
43
  }
44
 
45
 
 
54
 
55
  @spaces.GPU
56
  def analyze_video(video_path):
 
57
  m = get_model()
58
  df = m.get_events_dataframe(video_path=video_path)
59
  preds, segments = m.predict(events=df)
 
60
 
61
  n_steps = preds.shape[0]
62
  if n_steps < 2:
63
  raise gr.Error("Video too short to analyze.")
64
 
 
65
  if hasattr(segments, "start_time"):
66
  timestamps = [float(s.start_time) for s in segments]
67
  else:
68
  timestamps = list(np.arange(n_steps) * 1.0)
69
  duration = timestamps[-1] if timestamps else n_steps
70
 
 
71
  zones = {}
72
  for zone_name, (start, end) in ZONE_VERTEX_RANGES.items():
73
  end = min(end, preds.shape[1])
74
  zone_signal = np.mean(preds[:, start:end], axis=1)
75
  zones[zone_name] = normalize(zone_signal)
76
 
 
77
  engagement = normalize(
78
+ 0.25 * zones["Frontal"]
79
+ + 0.20 * zones["Action"]
80
+ + 0.25 * zones["Attention"]
81
+ + 0.15 * zones["Speech"]
82
+ + 0.15 * zones["Visual"]
83
  )
84
 
85
  return timestamps, engagement, zones, duration
 
89
 
90
  def make_engagement_chart(timestamps, engagement):
91
  fig = go.Figure()
92
+
93
+ # Subtle grid area
94
  fig.add_trace(go.Scatter(
95
  x=timestamps, y=engagement,
96
  mode="lines",
97
+ line=dict(color="#D4A017", width=2.5),
98
  fill="tozeroy",
99
+ fillcolor="rgba(212,160,23,0.06)",
100
+ hovertemplate="<b>%{x:.1f}s</b><br>Response: %{y:.0f}/100<extra></extra>",
101
  ))
102
+
103
  fig.update_layout(
104
  template="plotly_dark",
105
+ paper_bgcolor="rgba(0,0,0,0)",
106
+ plot_bgcolor="rgba(0,0,0,0)",
107
+ margin=dict(l=45, r=15, t=15, b=40),
108
+ height=260,
109
+ xaxis=dict(
110
+ title="",
111
+ showgrid=True,
112
+ gridcolor="rgba(255,255,255,0.04)",
113
+ zeroline=False,
114
+ color="#666",
115
+ tickfont=dict(size=11, family="DM Mono, monospace"),
116
+ ),
117
+ yaxis=dict(
118
+ title="",
119
+ showgrid=True,
120
+ gridcolor="rgba(255,255,255,0.04)",
121
+ zeroline=False,
122
+ range=[0, 105],
123
+ color="#666",
124
+ tickfont=dict(size=11, family="DM Mono, monospace"),
125
+ ),
126
+ hoverlabel=dict(
127
+ bgcolor="#1a1a1a",
128
+ bordercolor="#D4A017",
129
+ font=dict(color="#fff", family="DM Mono, monospace", size=12),
130
+ ),
131
  )
132
  return fig
133
 
134
 
135
  # ── Feedback generation ─────────────────────────────────────────────────────
136
 
 
 
 
 
 
 
 
 
137
  def generate_feedback(engagement, zones):
138
  avg = float(np.mean(engagement))
139
  mx = float(np.max(engagement))
 
147
  end_avg = float(np.mean(engagement[-hook_len:]))
148
 
149
  if avg >= 72:
150
+ strength, strength_color = "STRONG", "#22C55E"
151
  elif avg >= 50:
152
+ strength, strength_color = "AVERAGE", "#D4A017"
153
  else:
154
+ strength, strength_color = "WEAK", "#EF4444"
155
 
156
  zone_avgs = {name: float(np.mean(sig)) for name, sig in zones.items()}
157
  weakest_zone = min(zone_avgs, key=zone_avgs.get)
158
 
159
  recs = []
160
  if hook_avg < 55:
161
+ recs.append("**Weak hook** β€” first seconds don't grab. Lead with your strongest visual or a surprise.")
162
  if end_avg < 45:
163
+ recs.append("**Flat ending** β€” viewers drop off. Add a payoff or cliffhanger in the final seconds.")
164
  if weak_pct > 30:
165
+ recs.append(f"**{weak_pct:.0f}% dead air** β€” too many flat stretches. Cut or accelerate the lows.")
166
 
167
  zone_recs = {
168
+ "Action": "More movement, gestures, or physical dynamics.",
169
+ "Attention": "Vary shots β€” add cuts, zooms, angle changes.",
170
+ "Speech": "More face time or recognizable elements.",
171
+ "Visual": "Flat texture. Try contrast, color grading, composition.",
172
+ "Frontal": "No clear thread. Give viewers something to follow.",
173
  }
174
  if zone_avgs[weakest_zone] < 55:
175
+ recs.append(f"**{weakest_zone} zone is dragging** β€” {zone_recs.get(weakest_zone, '')}")
176
 
177
  if not recs:
178
+ recs.append("Solid cut. Minor gains from tightening the middle third.")
179
+
180
+ rec_lines = "\n".join(f"- {r}" for r in recs)
181
+
182
+ return zone_avgs, strength, strength_color, avg, mx, mn, hook_avg, end_avg, weak_pct, rec_lines
183
+
184
+
185
+ # ── HTML builders ───────────────────────────────────────────────────────────
186
+
187
+ def make_stats_html(strength, strength_color, avg, mx, mn, hook_avg, end_avg, weak_pct):
188
+ return f"""
189
+ <div class="stats-grid">
190
+ <div class="stat-card stat-verdict" style="border-color: {strength_color}40;">
191
+ <div class="stat-label">Verdict</div>
192
+ <div class="stat-value" style="color: {strength_color};">{strength}</div>
193
+ </div>
194
+ <div class="stat-card">
195
+ <div class="stat-label">Average</div>
196
+ <div class="stat-value">{avg:.0f}</div>
197
+ </div>
198
+ <div class="stat-card">
199
+ <div class="stat-label">Peak</div>
200
+ <div class="stat-value">{mx:.0f}</div>
201
+ </div>
202
+ <div class="stat-card">
203
+ <div class="stat-label">Floor</div>
204
+ <div class="stat-value">{mn:.0f}</div>
205
+ </div>
206
+ <div class="stat-card">
207
+ <div class="stat-label">Hook</div>
208
+ <div class="stat-value">{hook_avg:.0f}</div>
209
+ </div>
210
+ <div class="stat-card">
211
+ <div class="stat-label">Dead air</div>
212
+ <div class="stat-value">{weak_pct:.0f}%</div>
213
+ </div>
214
+ </div>
215
+ """
216
 
 
 
 
 
 
 
 
 
 
 
217
 
218
  def make_zone_html(zone_avgs):
219
+ cards = ""
220
+ for zone_name, meta in ZONE_META.items():
221
  val = zone_avgs.get(zone_name, 0)
222
+ color = meta["color"]
223
+ idx = meta["icon"]
224
+
225
+ if val >= 70:
226
+ activity = "HIGH"
227
+ elif val >= 40:
228
+ activity = "MID"
229
+ else:
230
+ activity = "LOW"
231
+
232
+ cards += f"""
233
+ <div class="zone-card">
234
+ <div class="zone-header">
235
+ <span class="zone-idx" style="color: {color};">{idx}</span>
236
+ <span class="zone-name">{zone_name}</span>
237
+ <span class="zone-activity" style="color: {color};">{activity}</span>
238
  </div>
239
+ <div class="zone-desc">{meta['desc']}</div>
240
+ <div class="zone-bar-track">
241
+ <div class="zone-bar-fill" style="width:{val:.0f}%;background:{color};"></div>
 
242
  </div>
243
+ <div class="zone-val" style="color: {color};">{val:.0f}</div>
244
  </div>"""
245
+ return f'<div class="zone-stack">{cards}</div>'
 
246
 
247
 
248
  # ── Main handlers ───────────────────────────────────────────────────────────
249
 
250
  def process_video(video):
251
  if video is None:
252
+ raise gr.Error("Upload a video first.")
253
  timestamps, engagement, zones, duration = analyze_video(video)
254
  chart = make_engagement_chart(timestamps, engagement)
255
+ zone_avgs, strength, strength_color, avg, mx, mn, hook_avg, end_avg, weak_pct, recs = generate_feedback(engagement, zones)
256
+ stats_html = make_stats_html(strength, strength_color, avg, mx, mn, hook_avg, end_avg, weak_pct)
257
  zone_html = make_zone_html(zone_avgs)
258
+ return chart, stats_html, zone_html, recs
 
259
 
260
 
261
  def process_comparison(video_a, video_b):
 
268
  fig = go.Figure()
269
  fig.add_trace(go.Scatter(
270
  x=ts_a, y=eng_a, mode="lines", name="Video A",
271
+ line=dict(color="#D4A017", width=2.5),
272
  ))
273
  fig.add_trace(go.Scatter(
274
  x=ts_b, y=eng_b, mode="lines", name="Video B",
275
+ line=dict(color="#3B82F6", width=2.5),
276
  ))
277
  fig.update_layout(
278
  template="plotly_dark",
279
+ paper_bgcolor="rgba(0,0,0,0)",
280
+ plot_bgcolor="rgba(0,0,0,0)",
281
+ margin=dict(l=45, r=15, t=15, b=40),
282
+ height=300,
283
+ xaxis=dict(showgrid=True, gridcolor="rgba(255,255,255,0.04)", zeroline=False, color="#666",
284
+ tickfont=dict(size=11, family="DM Mono, monospace")),
285
+ yaxis=dict(showgrid=True, gridcolor="rgba(255,255,255,0.04)", zeroline=False, range=[0, 105], color="#666",
286
+ tickfont=dict(size=11, family="DM Mono, monospace")),
287
+ legend=dict(font=dict(color="#999", family="DM Mono, monospace", size=11),
288
+ bgcolor="rgba(0,0,0,0)", orientation="h", y=1.08),
289
+ hoverlabel=dict(bgcolor="#1a1a1a", bordercolor="#D4A017",
290
+ font=dict(color="#fff", family="DM Mono, monospace", size=12)),
291
  )
292
 
293
  avg_a, avg_b = np.mean(eng_a), np.mean(eng_b)
294
  winner = "A" if avg_a > avg_b else "B"
295
  diff = abs(avg_a - avg_b)
296
 
297
+ summary = f"""| | Video A | Video B |
 
 
298
  |--|---------|---------|
299
+ | **Average** | {avg_a:.0f} | {avg_b:.0f} |
300
  | **Peak** | {np.max(eng_a):.0f} | {np.max(eng_b):.0f} |
301
  | **Floor** | {np.min(eng_a):.0f} | {np.min(eng_b):.0f} |
302
  | **Duration** | {dur_a:.1f}s | {dur_b:.1f}s |
303
 
304
+ **Video {winner}** wins by **+{diff:.0f}** avg response.
305
  """
306
  return fig, summary
307
 
308
 
309
+ # ── Custom CSS ──────────────────────────────────────────────────────────────
310
 
311
+ CSS = """
312
+ @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@400;600;700;800&display=swap');
313
+
314
+ /* Global overrides */
315
+ .gradio-container {
316
+ max-width: 1400px !important;
317
+ font-family: 'Syne', sans-serif !important;
318
+ background: #0A0A0B !important;
319
+ }
320
+ .dark .gradio-container { background: #0A0A0B !important; }
321
+
322
+ /* Header */
323
+ .hero-header {
324
+ text-align: center;
325
+ padding: 48px 20px 32px;
326
+ position: relative;
327
+ }
328
+ .hero-header::before {
329
+ content: '';
330
+ position: absolute;
331
+ top: 0; left: 50%;
332
+ transform: translateX(-50%);
333
+ width: 400px; height: 400px;
334
+ background: radial-gradient(circle, rgba(212,160,23,0.08) 0%, transparent 70%);
335
+ pointer-events: none;
336
+ }
337
+ .hero-title {
338
+ font-family: 'Syne', sans-serif;
339
+ font-size: 56px;
340
+ font-weight: 800;
341
+ letter-spacing: -2px;
342
+ color: #FAFAFA;
343
+ margin: 0;
344
+ line-height: 1;
345
+ }
346
+ .hero-title span { color: #D4A017; }
347
+ .hero-sub {
348
+ font-family: 'DM Mono', monospace;
349
+ font-size: 13px;
350
+ color: #666;
351
+ margin-top: 12px;
352
+ letter-spacing: 0.5px;
353
+ line-height: 1.6;
354
+ max-width: 600px;
355
+ margin-left: auto;
356
+ margin-right: auto;
357
+ }
358
+
359
+ /* Tabs */
360
+ .tab-nav { border: none !important; }
361
+ .tab-nav button {
362
+ font-family: 'DM Mono', monospace !important;
363
+ font-size: 12px !important;
364
+ letter-spacing: 1px !important;
365
+ text-transform: uppercase !important;
366
+ color: #555 !important;
367
+ border: none !important;
368
+ background: transparent !important;
369
+ padding: 10px 20px !important;
370
+ }
371
+ .tab-nav button.selected {
372
+ color: #D4A017 !important;
373
+ border-bottom: 2px solid #D4A017 !important;
374
+ background: transparent !important;
375
+ }
376
+
377
+ /* Stat cards */
378
+ .stats-grid {
379
+ display: grid;
380
+ grid-template-columns: repeat(6, 1fr);
381
+ gap: 10px;
382
+ margin-top: 8px;
383
+ }
384
+ .stat-card {
385
+ background: rgba(255,255,255,0.03);
386
+ border: 1px solid rgba(255,255,255,0.06);
387
+ border-radius: 10px;
388
+ padding: 14px 16px;
389
+ text-align: center;
390
+ }
391
+ .stat-card.stat-verdict {
392
+ border-width: 2px;
393
+ }
394
+ .stat-label {
395
+ font-family: 'DM Mono', monospace;
396
+ font-size: 10px;
397
+ text-transform: uppercase;
398
+ letter-spacing: 1.5px;
399
+ color: #555;
400
+ margin-bottom: 6px;
401
+ }
402
+ .stat-value {
403
+ font-family: 'Syne', sans-serif;
404
+ font-size: 22px;
405
+ font-weight: 700;
406
+ color: #FAFAFA;
407
+ }
408
+
409
+ /* Zone cards */
410
+ .zone-stack {
411
+ display: flex;
412
+ flex-direction: column;
413
+ gap: 8px;
414
+ }
415
+ .zone-card {
416
+ background: rgba(255,255,255,0.02);
417
+ border: 1px solid rgba(255,255,255,0.06);
418
+ border-radius: 10px;
419
+ padding: 14px 16px;
420
+ }
421
+ .zone-header {
422
+ display: flex;
423
+ align-items: center;
424
+ gap: 10px;
425
+ margin-bottom: 4px;
426
+ }
427
+ .zone-idx {
428
+ font-family: 'DM Mono', monospace;
429
+ font-size: 11px;
430
+ font-weight: 500;
431
+ opacity: 0.7;
432
+ }
433
+ .zone-name {
434
+ font-family: 'Syne', sans-serif;
435
+ font-size: 14px;
436
+ font-weight: 600;
437
+ color: #E0E0E0;
438
+ flex: 1;
439
+ }
440
+ .zone-activity {
441
+ font-family: 'DM Mono', monospace;
442
+ font-size: 10px;
443
+ letter-spacing: 1px;
444
+ font-weight: 500;
445
+ }
446
+ .zone-desc {
447
+ font-family: 'DM Mono', monospace;
448
+ font-size: 11px;
449
+ color: #555;
450
+ margin-bottom: 10px;
451
+ line-height: 1.4;
452
+ }
453
+ .zone-bar-track {
454
+ height: 4px;
455
+ background: rgba(255,255,255,0.06);
456
+ border-radius: 2px;
457
+ overflow: hidden;
458
+ margin-bottom: 6px;
459
+ }
460
+ .zone-bar-fill {
461
+ height: 100%;
462
+ border-radius: 2px;
463
+ transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1);
464
+ }
465
+ .zone-val {
466
+ font-family: 'DM Mono', monospace;
467
+ font-size: 12px;
468
+ font-weight: 500;
469
+ text-align: right;
470
+ }
471
+
472
+ /* Plot container */
473
+ .plot-container {
474
+ border-radius: 12px;
475
+ overflow: hidden;
476
+ }
477
+
478
+ /* Blocks overrides */
479
+ .block { background: transparent !important; border: none !important; box-shadow: none !important; }
480
+ .label-wrap { display: none !important; }
481
+ .prose { color: #999 !important; }
482
+ .prose h3 { color: #E0E0E0 !important; font-family: 'Syne', sans-serif !important; }
483
+ .prose strong { color: #FAFAFA !important; }
484
+ .prose table { border-color: rgba(255,255,255,0.08) !important; }
485
+ .prose th, .prose td { border-color: rgba(255,255,255,0.08) !important; color: #999 !important; }
486
+ .prose th { color: #ccc !important; }
487
+ .prose a { color: #D4A017 !important; }
488
+
489
+ /* Video component */
490
+ .video-container { border-radius: 12px; overflow: hidden; }
491
+
492
+ /* Button */
493
+ button.primary {
494
+ background: #D4A017 !important;
495
+ border: none !important;
496
+ color: #0A0A0B !important;
497
+ font-family: 'Syne', sans-serif !important;
498
+ font-weight: 700 !important;
499
+ letter-spacing: 0.5px !important;
500
+ border-radius: 10px !important;
501
+ }
502
+ button.primary:hover {
503
+ background: #E8B12A !important;
504
+ }
505
+
506
+ /* Markdown feedback section */
507
+ .feedback-section .prose {
508
+ font-family: 'DM Mono', monospace !important;
509
+ font-size: 13px !important;
510
+ line-height: 1.7 !important;
511
+ }
512
+
513
+ /* Responsive */
514
+ @media (max-width: 768px) {
515
+ .stats-grid { grid-template-columns: repeat(3, 1fr); }
516
+ .hero-title { font-size: 36px; }
517
+ }
518
+ """
519
+
520
+
521
+ # ── UI ──────────────────────────────────────────────────────────────────────
522
 
523
  theme = gr.themes.Base(
524
  primary_hue=gr.themes.colors.amber,
525
+ neutral_hue=gr.themes.colors.zinc,
 
 
 
526
  ).set(
527
+ body_background_fill="#0A0A0B",
528
+ body_text_color="#999999",
529
+ block_background_fill="transparent",
530
+ block_border_width="0px",
531
+ block_shadow="none",
532
+ input_background_fill="#111113",
533
+ input_border_color="rgba(255,255,255,0.08)",
534
+ input_border_width="1px",
535
+ button_primary_background_fill="#D4A017",
536
+ button_primary_text_color="#0A0A0B",
537
  )
538
 
539
+ with gr.Blocks(theme=theme, css=CSS, title="TRIBE2") as demo:
540
+
541
+ gr.HTML("""
542
+ <div class="hero-header">
543
+ <h1 class="hero-title">TRIBE<span>2</span></h1>
544
+ <p class="hero-sub">
545
+ Predict where your video loses the viewer. Powered by Meta's brain-encoding
546
+ model β€” maps cortical response across 20,000 vertices in real time.
547
+ </p>
548
+ </div>
549
+ """)
550
+
551
+ with gr.Tab("Analyze"):
552
+ video_input = gr.Video(label="", height=360)
553
+ analyze_btn = gr.Button("Run analysis", variant="primary", size="lg")
554
+ stats_html = gr.HTML()
555
+ chart = gr.Plot()
556
  with gr.Row():
557
+ with gr.Column(scale=2, elem_classes=["feedback-section"]):
558
+ feedback = gr.Markdown()
 
 
 
 
559
  with gr.Column(scale=1):
560
+ zone_html = gr.HTML()
 
 
 
561
 
562
  analyze_btn.click(
563
  fn=process_video,
564
  inputs=[video_input],
565
+ outputs=[chart, stats_html, zone_html, feedback],
566
  )
567
 
568
+ with gr.Tab("A/B Compare"):
569
  with gr.Row():
570
  vid_a = gr.Video(label="Video A")
571
  vid_b = gr.Video(label="Video B")
572
  compare_btn = gr.Button("Compare", variant="primary", size="lg")
573
+ compare_chart = gr.Plot()
574
  compare_md = gr.Markdown()
575
 
576
  compare_btn.click(
 
579
  outputs=[compare_chart, compare_md],
580
  )
581
 
582
+ with gr.Tab("How it works"):
583
+ gr.Markdown("""### The model
 
 
584
 
585
+ **TRIBE v2** is Meta's brain-encoding foundation model. It predicts fMRI-level cortical
586
+ activation from video, audio, and text using three extractors:
587
 
588
+ V-JEPA2 (vision) + Wav2Vec-BERT 2.0 (audio) + LLaMA 3.2 (language)
589
 
590
+ The output is a prediction across ~20,000 cortical vertices on the fsaverage5 surface.
591
+ We aggregate those into five zones and derive a composite engagement signal.
 
 
 
592
 
593
+ Predictions carry a ~5s hemodynamic offset inherent to fMRI.
594
+ Treat each timestamp as an approximate editing window.
595
 
596
+ [Model card](https://huggingface.co/facebook/tribev2)
597
  """)
598
 
599