| """Gradio UI builders for the TRIBE v2 brain-score Space (Cortical Observatory). |
| |
| Mirrors the qwen-image-editor shape (PLAN.md §6 "Code shape"): per-mode |
| ``build_*_tab()`` builders each return a ``dict[str, gr.components.Component]`` |
| so ``app.py`` can wire ``.click()`` / ``.change()`` handlers without reaching |
| into local scopes. **No event wiring lives here** -- app.py owns it. |
| |
| The three modes (PLAN.md §2) all feed the *same* metric-timeline pipeline, so |
| each tab differs only in its input widget (+ an ``audio_only`` debug toggle on |
| the two media tabs). The shared right-hand readout is built once by |
| :func:`build_results`. |
| |
| Help text flows through Gradio's native ``info=`` parameter (a dim subtitle |
| under each label). Components that don't support ``info=`` (``gr.Video``, |
| ``gr.HTML``) carry their guidance in the label or adjacent markdown instead. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import gradio as gr |
|
|
| |
| |
| |
| |
| |
| METRICS_DEFAULT_ON: tuple[str, ...] = ( |
| "Attention", |
| "Engagement / arousal", |
| "Virality (proxy)", |
| ) |
| METRICS_OPTIONAL: tuple[str, ...] = ( |
| "Language / semantic load", |
| "Self-relevance / DMN", |
| ) |
| METRIC_CHOICES: tuple[str, ...] = METRICS_DEFAULT_ON + METRICS_OPTIONAL |
|
|
| |
| _METRIC_INFO = ( |
| "Brain-derived curves over time. Attention (dorsal/ventral + control), " |
| "Engagement (sensory + STS), Virality (vmPFC/mOFC value proxy) are on by " |
| "default; Language and Self-relevance/DMN are optional." |
| ) |
|
|
| |
| _VIDEO_SUFFIXES = [".mp4", ".mov", ".mkv", ".avi", ".webm"] |
| _AUDIO_SUFFIXES = [".wav", ".mp3", ".flac", ".ogg"] |
|
|
| |
| _PROXY_CAVEAT = ( |
| '<div class="co-caveat"><strong>Virality is a research proxy</strong>, not ' |
| "a guarantee. <code>facebook/tribev2</code> is cortical-only (no ventral " |
| "striatum / NAcc), so this is the validated vmPFC/mPFC <em>complement</em>. " |
| "The model's target was per-sample z-scored, so only <strong>relative " |
| "temporal dynamics</strong> are interpretable — absolute scores are " |
| "meaningless.</div>" |
| ) |
|
|
|
|
| def _metric_toggles() -> dict[str, gr.components.Component]: |
| """Metric-selection checkboxes + the proxy caveat, shared by every tab. |
| |
| Returns ``{metrics, proxy_note}``: a single ``gr.CheckboxGroup`` whose value |
| is the list of selected metric display names (defaults = the three ON |
| metrics), and the caveat HTML beneath it. |
| """ |
| with gr.Group(): |
| gr.HTML('<div class="co-section-title">Metrics</div>') |
| metrics = gr.CheckboxGroup( |
| choices=list(METRIC_CHOICES), |
| value=list(METRICS_DEFAULT_ON), |
| label="Brain metrics to plot", |
| info=_METRIC_INFO, |
| ) |
| proxy_note = gr.HTML(_PROXY_CAVEAT) |
| return dict(metrics=metrics, proxy_note=proxy_note) |
|
|
|
|
| def _audio_only_toggle() -> gr.components.Component: |
| """The ``audio_only`` debug switch (Video/Audio tabs only, PLAN.md §11.6). |
| |
| Skips ASR (whisperx) + the gated Llama text path -> much faster, and lets |
| the heavy pipeline be validated on-Space before Meta approval. |
| """ |
| return gr.Checkbox( |
| value=True, |
| label="⚡ Fast mode — skip speech-to-text (recommended)", |
| info=( |
| "On (default): scores the video + audio brain response — fast and " |
| "reliable. Uncheck for full multimodal incl. spoken-text features " |
| "(much slower: downloads a large ASR stack on first use)." |
| ), |
| ) |
|
|
|
|
| def _run_button(label: str = "Score") -> gr.components.Component: |
| """The primary action button (activation-hot, per the theme).""" |
| return gr.Button(label, variant="primary", elem_classes=["co-run"]) |
|
|
|
|
| |
| |
| |
| def build_video_tab() -> dict[str, gr.components.Component]: |
| """Build the Video mode (primary) input column. |
| |
| Keys: ``video, sample_btn, metrics, proxy_note, audio_only, run_btn``. |
| Full multimodal path: V-JEPA2 + DINOv2 + extracted-audio W2V-BERT + Llama. |
| """ |
| with gr.Column(): |
| gr.HTML('<div class="co-section-title">Video · full multimodal</div>') |
| video = gr.Video( |
| label="Video (.mp4 .mov .mkv .avi .webm, up to 5 min)", |
| sources=["upload"], |
| height=200, |
| ) |
| sample_btn = gr.Button( |
| "Try a sample clip", variant="secondary", size="sm", |
| elem_classes=["co-sample"], |
| ) |
| toggles = _metric_toggles() |
| audio_only = _audio_only_toggle() |
| run_btn = _run_button("Score video") |
| return dict( |
| video=video, |
| sample_btn=sample_btn, |
| audio_only=audio_only, |
| run_btn=run_btn, |
| **toggles, |
| ) |
|
|
|
|
| def build_audio_tab() -> dict[str, gr.components.Component]: |
| """Build the Audio mode input column. |
| |
| Keys: ``audio, metrics, proxy_note, audio_only, run_btn``. |
| Path: W2V-BERT + Llama (ASR word context). |
| """ |
| with gr.Column(): |
| gr.HTML('<div class="co-section-title">Audio</div>') |
| audio = gr.Audio( |
| label="Audio (.wav .mp3 .flac .ogg, up to 5 min)", |
| sources=["upload"], |
| type="filepath", |
| ) |
| toggles = _metric_toggles() |
| audio_only = _audio_only_toggle() |
| run_btn = _run_button("Score audio") |
| return dict( |
| audio=audio, |
| audio_only=audio_only, |
| run_btn=run_btn, |
| **toggles, |
| ) |
|
|
|
|
| def build_text_tab() -> dict[str, gr.components.Component]: |
| """Build the Text mode input column. |
| |
| Keys: ``text, metrics, proxy_note, run_btn``. |
| Path: gTTS-synthesized speech -> W2V-BERT + Llama. (No ``audio_only`` here: |
| text *is* the ASR-equivalent input, so the debug toggle doesn't apply.) |
| """ |
| with gr.Column(): |
| gr.HTML('<div class="co-section-title">Text</div>') |
| text = gr.Textbox( |
| label="Text", |
| info=( |
| "Synthesized to speech (gTTS), then scored over the spoken " |
| "length. English narrative works best." |
| ), |
| lines=6, |
| placeholder=( |
| "Paste a script or passage. It is read aloud and the average " |
| "brain's response to that narration is plotted over time." |
| ), |
| ) |
| toggles = _metric_toggles() |
| run_btn = _run_button("Score text") |
| return dict( |
| text=text, |
| run_btn=run_btn, |
| **toggles, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| |
| _EMPTY_HTML = """ |
| <div class="co-state" id="co-empty"> |
| <div class="co-state-icon">🧠</div> |
| <div class="co-state-title">Read a clip, watch the brain</div> |
| <div class="co-state-body"> |
| Pick a mode on the left, drop in a clip (or text), choose your metrics, and |
| press <strong>Score</strong>. You'll get a synchronized timeline of how the |
| <em>average</em> brain would respond — scrub the media, watch the |
| curves; click a spike to jump there. |
| </div> |
| </div> |
| """.strip() |
|
|
| |
| |
| _VIDEO_PLACEHOLDER = """ |
| <div class="co-video-wrap"> |
| <div class="co-video-empty">media appears here on Score</div> |
| </div> |
| """.strip() |
|
|
|
|
| def build_results() -> dict[str, gr.components.Component]: |
| """Build the shared results readout (right hero zone). |
| |
| Keys: |
| ``empty, loading, error, result_grp, media_html, timeline, summary, |
| seek_hint``. |
| |
| Layout (one zone, four mutually-exclusive states app.py toggles ``visible``): |
| * ``empty`` -- inviting empty state (visible at start). |
| * ``loading`` -- real per-window progress + a scan-line bar. |
| * ``error`` -- plain-voice, actionable error panel. |
| * ``result_grp`` -- the media + timeline + summary (the win state): |
| - ``media_html`` : custom ``<video id="tm-video">`` (gr.HTML) so a |
| Plotly click can seek it (the §6 fallback the timeline drives). |
| - ``timeline`` : the ``gr.Plot`` Plotly figure (elem_id |
| ``co-timeline`` so ``plotting.seek_js`` can find it). |
| - ``summary`` : the per-metric peak/mean strip (gr.HTML). |
| - ``seek_hint`` : the "click a spike to jump" affordance. |
| """ |
| with gr.Column(elem_classes=["co-readout"]): |
| |
| empty = gr.HTML(_EMPTY_HTML, visible=True) |
|
|
| |
| |
| |
| |
| with gr.Column(visible=False) as loading: |
| loading_body = gr.HTML(loading_card_html()) |
|
|
| |
| |
| error = gr.HTML(visible=False) |
|
|
| |
| with gr.Column(visible=False) as result_grp: |
| media_html = gr.HTML(_VIDEO_PLACEHOLDER) |
| timeline = gr.Plot( |
| label="Synchronized brain-metric timeline", |
| show_label=False, |
| elem_id="co-timeline", |
| ) |
| seek_hint = gr.HTML( |
| '<div class="co-seek-hint">click a spike to ' |
| '<kbd>seek</kbd> the media to that moment</div>' |
| ) |
| summary = gr.HTML(elem_classes=["co-summary-wrap"]) |
|
|
| return dict( |
| empty=empty, |
| loading=loading, |
| loading_body=loading_body, |
| error=error, |
| result_grp=result_grp, |
| media_html=media_html, |
| timeline=timeline, |
| summary=summary, |
| seek_hint=seek_hint, |
| ) |
|
|
|
|
| |
| |
| |
| |
| def loading_card_html(snap: dict | None = None) -> str: |
| """Render the 'Scoring…' card with a determinate in-card progress bar from a |
| live progress snapshot (``tribescore.progress.snapshot()``). Pure string |
| builder. The native ``gr.Progress`` bar does NOT render in this layout, so a |
| ``gr.Timer`` (app.py) drives this HTML directly every ~0.3 s (Option T).""" |
| snap = snap or {} |
| n = max(1, int(snap.get("n_pass", 1) or 1)) |
| total = int(snap.get("total", 0) or 0) |
| done = int(snap.get("done", 0) or 0) |
| pass_idx = int(snap.get("pass_idx", 0) or 0) |
| within = (done / total) if total else 0.0 |
| frac = min(1.0, max(0.0, (pass_idx + within) / n)) |
| pct = int(round(frac * 100)) |
| |
| |
| |
| pass_no = min(pass_idx + 1, n) |
| if total: |
| label = f"Encoding video · clip {done}/{total} · pass {pass_no}/{n}" |
| else: |
| label = "Extracting features and predicting cortical activity…" |
| return ( |
| '<div class="co-state">' |
| '<div class="co-state-icon">🔬</div>' |
| '<div class="co-state-title">Scoring…</div>' |
| f'<div class="co-state-body" id="co-progress">{label}</div>' |
| '<div class="co-prog-track">' |
| f'<div class="co-prog-fill" style="width:{pct}%"></div></div>' |
| f'<div class="co-prog-pct">{pct}%</div>' |
| '</div>' |
| ) |
|
|
|
|
| def error_html(message: str) -> str: |
| """Wrap an actionable, plain-voice message in the error-panel markup.""" |
| return ( |
| '<div class="co-state co-state-error">' |
| '<div class="co-state-icon">⚠️</div>' |
| '<div class="co-state-title">Couldn\'t score that</div>' |
| f'<div class="co-state-body">{message}</div>' |
| "</div>" |
| ) |
|
|
|
|
| def video_html(src: str, *, mime: str = "video/mp4") -> str: |
| """Render the custom ``<video id="tm-video">`` the timeline seeks (§6). |
| |
| ``src`` is a URL or a Gradio-served file path (e.g. ``/file=...``). The |
| element id is fixed (``tm-video``) so ``plotting.seek_js`` can target it. |
| """ |
| return ( |
| '<div class="co-video-wrap">' |
| '<video id="tm-video" controls preload="metadata" playsinline>' |
| f'<source src="{src}" type="{mime}">' |
| "Your browser can't play this clip." |
| "</video></div>" |
| ) |
|
|
|
|
| def summary_html(stats: dict[str, dict]) -> str: |
| """Render the per-metric summary strip from ``metrics.summary`` output. |
| |
| ``stats`` maps metric name -> ``{"peak": float, "peak_t": float|int, |
| "mean": float, ...}`` (extra keys ignored). Values render in tabular mono |
| (z-units); ``peak_t`` is the second of the peak, for the click-to-seek cue. |
| """ |
| cards = [] |
| for name, s in stats.items(): |
| peak = s.get("peak") |
| mean = s.get("mean") |
| peak_t = s.get("peak_t") |
| peak_str = f"{peak:+.2f} z" if isinstance(peak, (int, float)) else "—" |
| sub_bits = [] |
| if isinstance(peak_t, (int, float)): |
| sub_bits.append(f"@ {round(peak_t)} s") |
| if isinstance(mean, (int, float)): |
| sub_bits.append(f"mean {mean:+.2f}") |
| sub = " · ".join(sub_bits) if sub_bits else " " |
| cards.append( |
| '<div class="co-stat">' |
| f'<div class="co-stat-name">{name}</div>' |
| f'<div class="co-stat-val co-tabnum">{peak_str}</div>' |
| f'<div class="co-stat-sub">{sub}</div>' |
| "</div>" |
| ) |
| return '<div class="co-summary">' + "".join(cards) + "</div>" |
|
|