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 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[str, str, str, str]:
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
- return render_show_html(vm, layout=layout, mind_reader=mind_reader, thinking=thinking)
 
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
- show_outs = [c for c in (stage_out, feed_out, meters_out, verdict_out) if c is not None]
 
 
 
 
 
 
 
 
 
 
 
 
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
- .fishbowl .cm-img { display: block; width: 100%; max-width: 360px; border-radius: var(--r); border: 1px solid var(--line); margin: 8px 0 4px; box-shadow: 0 0 20px color-mix(in oklab, var(--ac, var(--cyan)) 18%, transparent); }
758
- .fishbowl .cm-audio { width: 100%; max-width: 360px; height: 32px; margin-top: 6px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: a funny line plus an optional image and audio clip.
76
-
77
- Degrades gracefully with neither image nor audio it is just the tagged line, so a
78
- text-only beat (offline before media is wired, or a live media failure) still renders.
79
- ``src`` is a ``/file=`` URL or a ``data:`` URI: it is attribute-escaped (``quote=True``),
80
- NEVER body-escaped; the caption and ``alt`` are body-escaped."""
 
 
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">&#127912; illustrated</span>')
87
+ if (item.get("audio") or {}).get("src"):
88
+ badges.append('<span class="cm-badge">&#128266; 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">&#9998;</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 test_renders_image_and_audio(self):
 
 
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
- assert "<img" in html and "cm-img" in html
157
- assert "/file=runs/media/r/003-img.png" in html
158
- assert "<audio" in html and "cm-audio" in html
 
 
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 = (