Redesign: dark neural observatory aesthetic
Browse filesSyne + 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>
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
|
| 32 |
-
"Action
|
| 33 |
-
"Attention
|
| 34 |
-
"Speech
|
| 35 |
-
"Visual
|
| 36 |
}
|
| 37 |
|
| 38 |
-
|
| 39 |
-
"Frontal
|
| 40 |
-
"Action
|
| 41 |
-
"Attention
|
| 42 |
-
"Speech
|
| 43 |
-
"Visual
|
| 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
|
| 93 |
-
+ 0.20 * zones["Action
|
| 94 |
-
+ 0.25 * zones["Attention
|
| 95 |
-
+ 0.15 * zones["Speech
|
| 96 |
-
+ 0.15 * zones["Visual
|
| 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="#
|
| 110 |
fill="tozeroy",
|
| 111 |
-
fillcolor="rgba(
|
| 112 |
-
hovertemplate="
|
| 113 |
))
|
|
|
|
| 114 |
fig.update_layout(
|
| 115 |
template="plotly_dark",
|
| 116 |
-
paper_bgcolor="
|
| 117 |
-
plot_bgcolor="
|
| 118 |
-
margin=dict(l=
|
| 119 |
-
height=
|
| 120 |
-
xaxis=dict(
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = "
|
| 150 |
elif avg >= 50:
|
| 151 |
-
strength = "
|
| 152 |
else:
|
| 153 |
-
strength = "
|
| 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
|
| 161 |
if end_avg < 45:
|
| 162 |
-
recs.append("**Flat ending
|
| 163 |
if weak_pct > 30:
|
| 164 |
-
recs.append(f"**{weak_pct:.0f}% dead air
|
| 165 |
|
| 166 |
zone_recs = {
|
| 167 |
-
"Action
|
| 168 |
-
"Attention
|
| 169 |
-
"Speech
|
| 170 |
-
"Visual
|
| 171 |
-
"Frontal
|
| 172 |
}
|
| 173 |
if zone_avgs[weakest_zone] < 55:
|
| 174 |
-
recs.append(f"**
|
| 175 |
|
| 176 |
if not recs:
|
| 177 |
-
recs.append("
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 203 |
-
for zone_name,
|
| 204 |
val = zone_avgs.get(zone_name, 0)
|
| 205 |
-
color =
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
</div>
|
| 213 |
-
<div
|
| 214 |
-
<div
|
| 215 |
-
|
| 216 |
-
<div style="background:{color};height:100%;width:{val:.0f}%;border-radius:4px;"></div>
|
| 217 |
</div>
|
|
|
|
| 218 |
</div>"""
|
| 219 |
-
|
| 220 |
-
return html
|
| 221 |
|
| 222 |
|
| 223 |
# ββ Main handlers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 224 |
|
| 225 |
def process_video(video):
|
| 226 |
if video is None:
|
| 227 |
-
raise gr.Error("
|
| 228 |
timestamps, engagement, zones, duration = analyze_video(video)
|
| 229 |
chart = make_engagement_chart(timestamps, engagement)
|
| 230 |
-
|
|
|
|
| 231 |
zone_html = make_zone_html(zone_avgs)
|
| 232 |
-
|
| 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="#
|
| 247 |
))
|
| 248 |
fig.add_trace(go.Scatter(
|
| 249 |
x=ts_b, y=eng_b, mode="lines", name="Video B",
|
| 250 |
-
line=dict(color="#
|
| 251 |
))
|
| 252 |
fig.update_layout(
|
| 253 |
template="plotly_dark",
|
| 254 |
-
paper_bgcolor="
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"""
|
| 267 |
-
|
| 268 |
-
| | Video A | Video B |
|
| 269 |
|--|---------|---------|
|
| 270 |
-
| **
|
| 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}**
|
| 276 |
"""
|
| 277 |
return fig, summary
|
| 278 |
|
| 279 |
|
| 280 |
-
# ββ
|
| 281 |
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
theme = gr.themes.Base(
|
| 289 |
primary_hue=gr.themes.colors.amber,
|
| 290 |
-
|
| 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="#
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
|
|
|
|
|
|
|
|
|
| 302 |
)
|
| 303 |
|
| 304 |
-
with gr.Blocks(theme=theme, title="TRIBE2
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
with gr.Row():
|
| 309 |
-
|
| 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(
|
| 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,
|
| 325 |
)
|
| 326 |
|
| 327 |
-
with gr.Tab("
|
| 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(
|
| 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("
|
| 342 |
-
gr.Markdown("""###
|
| 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 |
-
|
|
|
|
| 347 |
|
| 348 |
-
|
| 349 |
|
| 350 |
-
|
| 351 |
-
|
| 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 |
-
|
|
|
|
| 357 |
|
| 358 |
-
|
| 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 |
|