Spaces:
Running on Zero
Running on Zero
agharsallah commited on
Commit ·
7c81add
1
Parent(s): 846eb30
feat(feed): update media rendering to use native components with badges instead of inline tags
Browse files- src/ui/fishbowl/app.py +97 -3
- src/ui/fishbowl/assets/styles.css +45 -2
- src/ui/fishbowl/render/feed.py +15 -15
- src/ui/fishbowl/show.py +30 -0
- tests/test_commentator.py +8 -4
src/ui/fishbowl/app.py
CHANGED
|
@@ -479,14 +479,96 @@ def render_show_html(
|
|
| 479 |
)
|
| 480 |
|
| 481 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
def _render_at(
|
| 483 |
session: FishbowlSession | None, k: int, *, layout: str, mind_reader: bool, thinking: str | None = None
|
| 484 |
-
) -> tuple
|
| 485 |
"""Render the Show at play-head *k* (pure prefix view), or empty if no session.
|
| 486 |
|
|
|
|
|
|
|
| 487 |
``thinking`` (a "who's thinking…" label) overlays the stage while a model call runs."""
|
| 488 |
vm = session.snapshot(k) if session is not None else _empty_vm()
|
| 489 |
-
|
|
|
|
| 490 |
|
| 491 |
|
| 492 |
def advance_one_tick(session: FishbowlSession | None, k: int, ticks: int, *, max_auto_ticks: int = _MAX_AUTO_TICKS):
|
|
@@ -702,7 +784,19 @@ def _wire(
|
|
| 702 |
feed_out = _h(show_handles, "feed", "feed_html")
|
| 703 |
meters_out = _h(show_handles, "meters", "meters_html")
|
| 704 |
verdict_out = _h(show_handles, "verdict", "verdict_html")
|
| 705 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 706 |
|
| 707 |
# Transport controls (looked up early so output lists can include the timer + the
|
| 708 |
# single Play/Pause hero button — its label travels in the tails so it reverts to
|
|
|
|
| 479 |
)
|
| 480 |
|
| 481 |
|
| 482 |
+
_FILE_PREFIX = "/file="
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def _media_local_path(src: str | None, kind: str) -> str | None:
|
| 486 |
+
"""Turn a feed media ``src`` into something a native gr.Image/gr.Audio can load.
|
| 487 |
+
|
| 488 |
+
Live refs are ``/file=<abs path>`` — strip the prefix to the real filesystem path
|
| 489 |
+
(Gradio 5+ serves it under ``/gradio_api/file=`` automatically, so the hand-built
|
| 490 |
+
``/file=`` URL the HTML feed used would 404). Offline/stub refs are ``data:`` URIs,
|
| 491 |
+
which native components can't take directly: decode once to a stable, content-hashed
|
| 492 |
+
file under the media dir (idempotent across ticks → no churn or flicker). An http(s)
|
| 493 |
+
URL passes through untouched."""
|
| 494 |
+
if not src:
|
| 495 |
+
return None
|
| 496 |
+
if src.startswith(_FILE_PREFIX):
|
| 497 |
+
return src[len(_FILE_PREFIX) :]
|
| 498 |
+
if src.startswith("data:"):
|
| 499 |
+
return _data_uri_to_file(src, kind)
|
| 500 |
+
return src
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def _data_uri_to_file(src: str, kind: str) -> str | None:
|
| 504 |
+
"""Decode a ``data:<mime>;base64,<payload>`` URI to a content-hashed file; path or None."""
|
| 505 |
+
import base64
|
| 506 |
+
import hashlib
|
| 507 |
+
|
| 508 |
+
from src.media.inference import media_output_dir
|
| 509 |
+
|
| 510 |
+
try:
|
| 511 |
+
header, payload = src.split(",", 1)
|
| 512 |
+
mime = header[5:].split(";")[0]
|
| 513 |
+
ext = {
|
| 514 |
+
"image/png": "png",
|
| 515 |
+
"image/jpeg": "jpg",
|
| 516 |
+
"image/webp": "webp",
|
| 517 |
+
"audio/wav": "wav",
|
| 518 |
+
"audio/x-wav": "wav",
|
| 519 |
+
"audio/mpeg": "mp3",
|
| 520 |
+
}.get(mime, "bin")
|
| 521 |
+
raw = base64.b64decode(payload)
|
| 522 |
+
out_dir = media_output_dir() / "_native"
|
| 523 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 524 |
+
path = out_dir / f"{kind}-{hashlib.sha1(raw).hexdigest()[:16]}.{ext}"
|
| 525 |
+
if not path.exists():
|
| 526 |
+
path.write_bytes(raw)
|
| 527 |
+
return str(path)
|
| 528 |
+
except Exception: # noqa: BLE001 — media is garnish; never break a render
|
| 529 |
+
return None
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def _rafters_updates(vm: dict) -> tuple:
|
| 533 |
+
"""Updates for the native "FROM THE RAFTERS" cutaway (box, image, audio, caption).
|
| 534 |
+
|
| 535 |
+
Reflects the latest ``commentate`` beat carrying media in the current play-head view —
|
| 536 |
+
so scrubbing before the first beat hides the box again. Returns four ``gr.update``
|
| 537 |
+
values aligned to the rafters outputs appended to ``show_outs``."""
|
| 538 |
+
import html as _html
|
| 539 |
+
|
| 540 |
+
latest = None
|
| 541 |
+
for item in vm.get("feed") or []:
|
| 542 |
+
if item.get("kind") != "commentate":
|
| 543 |
+
continue
|
| 544 |
+
if (item.get("image") or {}).get("src") or (item.get("audio") or {}).get("src"):
|
| 545 |
+
latest = item # keep the most recent illustrated/voiced beat
|
| 546 |
+
if latest is None:
|
| 547 |
+
return (gr.update(visible=False), gr.update(value=None), gr.update(value=None), gr.update(value=""))
|
| 548 |
+
|
| 549 |
+
img_path = _media_local_path((latest.get("image") or {}).get("src"), "img")
|
| 550 |
+
audio_path = _media_local_path((latest.get("audio") or {}).get("src"), "tts")
|
| 551 |
+
caption = _html.escape(latest.get("text") or "")
|
| 552 |
+
cap_html = f'<p class="rafters-cap">{caption}</p>' if caption else ""
|
| 553 |
+
return (
|
| 554 |
+
gr.update(visible=True),
|
| 555 |
+
gr.update(value=img_path),
|
| 556 |
+
gr.update(value=audio_path),
|
| 557 |
+
gr.update(value=cap_html),
|
| 558 |
+
)
|
| 559 |
+
|
| 560 |
+
|
| 561 |
def _render_at(
|
| 562 |
session: FishbowlSession | None, k: int, *, layout: str, mind_reader: bool, thinking: str | None = None
|
| 563 |
+
) -> tuple:
|
| 564 |
"""Render the Show at play-head *k* (pure prefix view), or empty if no session.
|
| 565 |
|
| 566 |
+
Returns the four HTML panes (stage, feed, meters, verdict) followed by the four
|
| 567 |
+
native rafters-cutaway updates (box, image, audio, caption) — aligned to ``show_outs``.
|
| 568 |
``thinking`` (a "who's thinking…" label) overlays the stage while a model call runs."""
|
| 569 |
vm = session.snapshot(k) if session is not None else _empty_vm()
|
| 570 |
+
panes = render_show_html(vm, layout=layout, mind_reader=mind_reader, thinking=thinking)
|
| 571 |
+
return (*panes, *_rafters_updates(vm))
|
| 572 |
|
| 573 |
|
| 574 |
def advance_one_tick(session: FishbowlSession | None, k: int, ticks: int, *, max_auto_ticks: int = _MAX_AUTO_TICKS):
|
|
|
|
| 784 |
feed_out = _h(show_handles, "feed", "feed_html")
|
| 785 |
meters_out = _h(show_handles, "meters", "meters_html")
|
| 786 |
verdict_out = _h(show_handles, "verdict", "verdict_html")
|
| 787 |
+
# The native "FROM THE RAFTERS" cutaway (box visibility + image + audio + caption),
|
| 788 |
+
# appended AFTER the four HTML panes so the existing positional pane indices
|
| 789 |
+
# (verdict == panes[3]) are untouched. _render_at returns matching trailing values;
|
| 790 |
+
# _pad_values trims to len(show_outs), so a build without these handles still works.
|
| 791 |
+
rafters_box = _h(show_handles, "rafters_box")
|
| 792 |
+
rafters_img = _h(show_handles, "rafters_img")
|
| 793 |
+
rafters_audio = _h(show_handles, "rafters_audio")
|
| 794 |
+
rafters_cap = _h(show_handles, "rafters_cap")
|
| 795 |
+
show_outs = [
|
| 796 |
+
c
|
| 797 |
+
for c in (stage_out, feed_out, meters_out, verdict_out, rafters_box, rafters_img, rafters_audio, rafters_cap)
|
| 798 |
+
if c is not None
|
| 799 |
+
]
|
| 800 |
|
| 801 |
# Transport controls (looked up early so output lists can include the timer + the
|
| 802 |
# single Play/Pause hero button — its label travels in the tails so it reverts to
|
src/ui/fishbowl/assets/styles.css
CHANGED
|
@@ -754,8 +754,51 @@ footer { display: none !important; }
|
|
| 754 |
.fishbowl .fe.commentate { border: 1px solid var(--ac, var(--cyan)); border-radius: var(--r); padding: 10px 12px; background: var(--glass); box-shadow: 0 0 16px color-mix(in oklab, var(--ac, var(--cyan)) 22%, transparent); }
|
| 755 |
.fishbowl .cm-tag { color: var(--ac, var(--cyan)); border-color: color-mix(in oklab, var(--ac, var(--cyan)) 42%, transparent); }
|
| 756 |
.fishbowl .cm-text { margin: 0 0 8px; font-size: 12.5px; line-height: 1.5; color: var(--ink); font-style: italic; }
|
| 757 |
-
|
| 758 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 759 |
|
| 760 |
/* ---- meters ---- */
|
| 761 |
.fishbowl .meters { margin: 0 14px 14px; padding: 13px 15px; flex: none; display: flex; flex-direction: column; gap: 12px; }
|
|
|
|
| 754 |
.fishbowl .fe.commentate { border: 1px solid var(--ac, var(--cyan)); border-radius: var(--r); padding: 10px 12px; background: var(--glass); box-shadow: 0 0 16px color-mix(in oklab, var(--ac, var(--cyan)) 22%, transparent); }
|
| 755 |
.fishbowl .cm-tag { color: var(--ac, var(--cyan)); border-color: color-mix(in oklab, var(--ac, var(--cyan)) 42%, transparent); }
|
| 756 |
.fishbowl .cm-text { margin: 0 0 8px; font-size: 12.5px; line-height: 1.5; color: var(--ink); font-style: italic; }
|
| 757 |
+
/* The image + audio themselves render in the native "FROM THE RAFTERS" cutaway (see
|
| 758 |
+
#rafters-box below); the feed card keeps small badges noting a beat carried media. */
|
| 759 |
+
.fishbowl .cm-badges { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
|
| 760 |
+
.fishbowl .cm-badge { font-family: var(--font-display); font-size: 10px; letter-spacing: .06em; text-transform: uppercase; color: var(--ac, var(--cyan)); padding: 3px 8px; border-radius: 999px; border: 1px solid color-mix(in oklab, var(--ac, var(--cyan)) 40%, transparent); background: color-mix(in oklab, var(--ac, var(--cyan)) 10%, transparent); }
|
| 761 |
+
|
| 762 |
+
/* ============================================================
|
| 763 |
+
FROM THE RAFTERS — the heckler's native illustrated + spoken cutaway.
|
| 764 |
+
Native gr.Image / gr.Audio live OUTSIDE the .fishbowl HTML islands, so these
|
| 765 |
+
rules are global (the stylesheet is injected app-wide via gr.Blocks(css=...)).
|
| 766 |
+
A vintage broadcast "now showing" box in the critic's hot magenta (hue 320).
|
| 767 |
+
============================================================ */
|
| 768 |
+
#rafters-box {
|
| 769 |
+
--cm: #ff5cc8; /* the heckler's hot magenta */
|
| 770 |
+
position: relative; margin: 0 0 12px; padding: 11px 11px 9px;
|
| 771 |
+
border-radius: var(--r-lg, 8px);
|
| 772 |
+
border: 1px solid color-mix(in oklab, var(--cm) 55%, transparent);
|
| 773 |
+
background:
|
| 774 |
+
radial-gradient(130% 90% at 50% -15%, color-mix(in oklab, var(--cm) 20%, transparent), transparent 62%),
|
| 775 |
+
var(--panel, rgba(8, 26, 33, .9));
|
| 776 |
+
box-shadow: 0 0 24px color-mix(in oklab, var(--cm) 26%, transparent),
|
| 777 |
+
inset 0 0 0 1px color-mix(in oklab, var(--cm) 12%, transparent);
|
| 778 |
+
overflow: hidden; animation: rafters-in .45s ease both;
|
| 779 |
+
}
|
| 780 |
+
@keyframes rafters-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
|
| 781 |
+
#rafters-box::before { /* a sweeping stage-light bar across the top edge */
|
| 782 |
+
content: ""; position: absolute; inset: 0 0 auto 0; height: 2px;
|
| 783 |
+
background: linear-gradient(90deg, transparent, var(--cm), transparent); opacity: .85;
|
| 784 |
+
}
|
| 785 |
+
#rafters-box .rafters-head { display: flex; align-items: center; gap: 8px; margin-bottom: 9px; font-family: var(--font-display, ui-monospace); font-size: 11px; letter-spacing: .18em; text-transform: uppercase; color: var(--cm); text-shadow: 0 0 10px color-mix(in oklab, var(--cm) 60%, transparent); }
|
| 786 |
+
#rafters-box .rafters-quill { font-size: 13px; }
|
| 787 |
+
#rafters-box .rafters-head::after { /* blinking "on air" dot */
|
| 788 |
+
content: ""; margin-left: auto; width: 7px; height: 7px; border-radius: 50%;
|
| 789 |
+
background: var(--cm); box-shadow: 0 0 9px var(--cm); animation: rafters-blink 1.5s steps(1) infinite;
|
| 790 |
+
}
|
| 791 |
+
@keyframes rafters-blink { 0%, 62% { opacity: 1; } 63%, 100% { opacity: .25; } }
|
| 792 |
+
#rafters-box .rafters-img img {
|
| 793 |
+
width: 100% !important; border-radius: var(--r, 4px) !important;
|
| 794 |
+
border: 1px solid color-mix(in oklab, var(--cm) 45%, transparent) !important;
|
| 795 |
+
box-shadow: 0 0 18px color-mix(in oklab, var(--cm) 22%, transparent);
|
| 796 |
+
animation: rafters-fade .5s ease both;
|
| 797 |
+
}
|
| 798 |
+
@keyframes rafters-fade { from { opacity: 0; transform: scale(.985); } to { opacity: 1; transform: none; } }
|
| 799 |
+
#rafters-box .rafters-cap { margin: 9px 2px 5px; font-family: var(--font-body, ui-monospace); font-style: italic; font-size: 12.5px; line-height: 1.5; color: var(--ink, #d2f7f0); }
|
| 800 |
+
#rafters-box .rafters-cap::before { content: "\201C"; } #rafters-box .rafters-cap::after { content: "\201D"; }
|
| 801 |
+
#rafters-box .rafters-audio { margin-top: 4px; }
|
| 802 |
|
| 803 |
/* ---- meters ---- */
|
| 804 |
.fishbowl .meters { margin: 0 14px 14px; padding: 13px 15px; flex: none; display: flex; flex-direction: column; gap: 12px; }
|
src/ui/fishbowl/render/feed.py
CHANGED
|
@@ -72,26 +72,26 @@ def _verdict_line(item: dict) -> str:
|
|
| 72 |
|
| 73 |
|
| 74 |
def _commentate_line(item: dict) -> str:
|
| 75 |
-
"""A color-commentary card:
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
| 81 |
text = html.escape(item.get("text") or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
parts = [
|
| 83 |
f'<span class="poke-tag cm-tag">{_QUILL} FROM THE RAFTERS</span>',
|
| 84 |
f'<p class="cm-text">{text}</p>',
|
|
|
|
| 85 |
]
|
| 86 |
-
image = item.get("image") or {}
|
| 87 |
-
if image.get("src"):
|
| 88 |
-
alt = html.escape(image.get("alt") or "the critic's vision")
|
| 89 |
-
parts.append(f'<img class="cm-img" src="{html.escape(image["src"], quote=True)}" alt="{alt}" loading="lazy">')
|
| 90 |
-
audio = item.get("audio") or {}
|
| 91 |
-
if audio.get("src"):
|
| 92 |
-
parts.append(
|
| 93 |
-
f'<audio class="cm-audio" controls preload="none" src="{html.escape(audio["src"], quote=True)}"></audio>'
|
| 94 |
-
)
|
| 95 |
# Wear the critic's own phosphor (its manifest hue), like every other coloured feed line.
|
| 96 |
hue = item.get("hue")
|
| 97 |
style = f' style="--ac:{agent_color(int(hue))};--acd:{agent_color_dim(int(hue))}"' if hue is not None else ""
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
def _commentate_line(item: dict) -> str:
|
| 75 |
+
"""A color-commentary card: the critic's funny line, with badges for any media.
|
| 76 |
+
|
| 77 |
+
The image and audio themselves render as NATIVE gr.Image / gr.Audio in the "FROM THE
|
| 78 |
+
RAFTERS" cutaway (app shell), not as inline ``<img>``/``<audio>`` here — in Gradio 5+
|
| 79 |
+
the static-file route moved to ``/gradio_api/file=``, so the old hand-built ``/file=``
|
| 80 |
+
tags 404. The feed keeps the text beat plus small ``🎨 illustrated`` / ``🔊 voiced``
|
| 81 |
+
badges so the transcript still records that a beat carried media; a text-only beat
|
| 82 |
+
(offline, or a live media failure) renders as just the tagged line."""
|
| 83 |
text = html.escape(item.get("text") or "")
|
| 84 |
+
badges = []
|
| 85 |
+
if (item.get("image") or {}).get("src"):
|
| 86 |
+
badges.append('<span class="cm-badge">🎨 illustrated</span>')
|
| 87 |
+
if (item.get("audio") or {}).get("src"):
|
| 88 |
+
badges.append('<span class="cm-badge">🔊 voiced</span>')
|
| 89 |
+
badge_html = f'<div class="cm-badges">{"".join(badges)}</div>' if badges else ""
|
| 90 |
parts = [
|
| 91 |
f'<span class="poke-tag cm-tag">{_QUILL} FROM THE RAFTERS</span>',
|
| 92 |
f'<p class="cm-text">{text}</p>',
|
| 93 |
+
badge_html,
|
| 94 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
# Wear the critic's own phosphor (its manifest hue), like every other coloured feed line.
|
| 96 |
hue = item.get("hue")
|
| 97 |
style = f' style="--ac:{agent_color(int(hue))};--acd:{agent_color_dim(int(hue))}"' if hue is not None else ""
|
src/ui/fishbowl/show.py
CHANGED
|
@@ -72,6 +72,36 @@ def build_show() -> dict[str, object]:
|
|
| 72 |
elem_classes=["stage-panel"],
|
| 73 |
)
|
| 74 |
with gr.Column(scale=2, min_width=300, elem_classes=["rail"]):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
handles["feed_html"] = gr.HTML(
|
| 76 |
value="<div class='feed scroll' aria-label='narrator feed'></div>",
|
| 77 |
elem_id="feed-html",
|
|
|
|
| 72 |
elem_classes=["stage-panel"],
|
| 73 |
)
|
| 74 |
with gr.Column(scale=2, min_width=300, elem_classes=["rail"]):
|
| 75 |
+
# ---- FROM THE RAFTERS : the critic's illustrated, spoken cutaway ----
|
| 76 |
+
# Native gr.Image / gr.Audio (NOT raw <img>/<audio> in the feed HTML): in
|
| 77 |
+
# Gradio 5+ the static-file route is /gradio_api/file=, so hand-built
|
| 78 |
+
# /file= URLs 404. The native components emit the correct URL automatically
|
| 79 |
+
# and auto-allow the served file. The whole box is hidden until the
|
| 80 |
+
# rafters-critic lands its first illustrated/voiced beat (app shell toggles
|
| 81 |
+
# visibility), then shows the latest beat as a broadcast-style cutaway.
|
| 82 |
+
with gr.Column(visible=False, elem_id="rafters-box", elem_classes=["rafters-box"]) as rafters_box:
|
| 83 |
+
gr.HTML(
|
| 84 |
+
'<div class="rafters-marquee"><span class="rafters-quill">✎</span>'
|
| 85 |
+
" FROM THE RAFTERS</div>",
|
| 86 |
+
elem_classes=["rafters-head"],
|
| 87 |
+
)
|
| 88 |
+
handles["rafters_img"] = gr.Image(
|
| 89 |
+
value=None,
|
| 90 |
+
interactive=False,
|
| 91 |
+
show_label=False,
|
| 92 |
+
container=False,
|
| 93 |
+
elem_classes=["rafters-img"],
|
| 94 |
+
)
|
| 95 |
+
handles["rafters_cap"] = gr.HTML("", elem_classes=["rafters-cap-wrap"])
|
| 96 |
+
handles["rafters_audio"] = gr.Audio(
|
| 97 |
+
value=None,
|
| 98 |
+
interactive=False,
|
| 99 |
+
show_label=False,
|
| 100 |
+
container=False,
|
| 101 |
+
elem_classes=["rafters-audio"],
|
| 102 |
+
)
|
| 103 |
+
handles["rafters_box"] = rafters_box
|
| 104 |
+
|
| 105 |
handles["feed_html"] = gr.HTML(
|
| 106 |
value="<div class='feed scroll' aria-label='narrator feed'></div>",
|
| 107 |
elem_id="feed-html",
|
tests/test_commentator.py
CHANGED
|
@@ -136,7 +136,9 @@ class TestFeedCard:
|
|
| 136 |
scenario = default_registry().build_scenario("thousand-token-wood", tools=default_tool_registry())
|
| 137 |
return [a.manifest for a in scenario.agents]
|
| 138 |
|
| 139 |
-
def
|
|
|
|
|
|
|
| 140 |
events = (
|
| 141 |
_ev("run.started", "conductor", turn=0, seed="s", goal="g"),
|
| 142 |
_ev(
|
|
@@ -153,9 +155,11 @@ class TestFeedCard:
|
|
| 153 |
assert "fe commentate" in html
|
| 154 |
assert "FROM THE RAFTERS" in html
|
| 155 |
assert "bold choice, unionising the mushrooms" in html
|
| 156 |
-
|
| 157 |
-
assert "
|
| 158 |
-
|
|
|
|
|
|
|
| 159 |
|
| 160 |
def test_degrades_to_text_only(self):
|
| 161 |
events = (
|
|
|
|
| 136 |
scenario = default_registry().build_scenario("thousand-token-wood", tools=default_tool_registry())
|
| 137 |
return [a.manifest for a in scenario.agents]
|
| 138 |
|
| 139 |
+
def test_renders_media_badges_not_inline_tags(self):
|
| 140 |
+
"""Media now plays in the native gr.Image/gr.Audio cutaway, so the feed card shows
|
| 141 |
+
badges — not inline <img>/<audio> (those used a /file= route dead in Gradio 5+)."""
|
| 142 |
events = (
|
| 143 |
_ev("run.started", "conductor", turn=0, seed="s", goal="g"),
|
| 144 |
_ev(
|
|
|
|
| 155 |
assert "fe commentate" in html
|
| 156 |
assert "FROM THE RAFTERS" in html
|
| 157 |
assert "bold choice, unionising the mushrooms" in html
|
| 158 |
+
# Badges note the media; the media itself is rendered by the native cutaway.
|
| 159 |
+
assert "cm-badge" in html and "illustrated" in html and "voiced" in html
|
| 160 |
+
# No dead inline media tags / hand-built /file= URLs.
|
| 161 |
+
assert "<img" not in html and "<audio" not in html
|
| 162 |
+
assert "/file=" not in html
|
| 163 |
|
| 164 |
def test_degrades_to_text_only(self):
|
| 165 |
events = (
|