techfreakworm commited on
Commit
03fb6f3
·
unverified ·
1 Parent(s): ef94760

feat(ui): in-card determinate progress bar via gr.Timer (Option T)

Browse files

The native gr.Progress bar does not render in this loading-card layout
(smoke-test: the sink fires every clip but Gradio's status-tracker stays
empty), so drive the card's OWN HTML instead. The encode sink writes per-clip
counters into a module-global state dict; a gr.Timer(0.3) reads it and
re-renders the loading card with a determinate amber bar + live
"clip k/N · window p/P" label.

Live-verified: the Timer ticks in parallel with the concurrency_limit=1 Score
(per-concurrency-id queue) — card showed "Encoding video · clip 4/30" at 13%
advancing per clip; after the Score the card hides, no stuck bar, timeline +
summary render cleanly (Timer -> gr.skip when idle).

- progress.py: rewrite to a dict-based live state (begin/sink/snapshot/end +
encode_frac); the 0.3s Timer is the throttle (no separate per-clip throttle).
- ui.py: loading_card_html renderer + addressable loading_body gr.HTML.
- theme.py: .co-prog-track/fill/pct amber bar styles (drop dead native CSS).
- app.py: gr.Timer + _progress_tick (own concurrency lane, gr.skip when idle,
updates only loading_body value); sink set/clear in finally; gate active
local-only so the Space keeps the coarse stage-progress fallback.
- tests/test_progress.py: dict-state tests (monotonic across windows, pass
detection, active flag, never-raise sink).

tribe-brain-reviewed (Option T over the worker-thread generator: no manual MPS
thread, no generator into the Gradio-6.11 visibility chain). Known cosmetic
wart: with the dedup cache OFF the encode double-extracts, so a 1-window clip
pins the bar at 100% during the 2nd pass; the parity-gated dedup-cache fix
makes it single-pass.

app.py CHANGED
@@ -112,9 +112,9 @@ from tribescore.inference import (
112
  )
113
  from tribescore.metrics import build_roi_masks, summary, to_metrics
114
  from tribescore.plotting import seek_js, timeline_figure
115
- from tribescore.progress import make_clip_sink
116
  from tribescore.windowing import plan_windows, stitch
117
  from tribescore import fast_encode
 
118
 
119
  logger = logging.getLogger("tribescore.app")
120
  logging.basicConfig(level=logging.INFO)
@@ -374,6 +374,18 @@ def _reveal_result(ok: bool):
374
  return gr.update(visible=bool(ok))
375
 
376
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  def _fail(message: str):
378
  """Error update tuple (same 7 outputs + trailing ok=False for _reveal_result)."""
379
  return (
@@ -430,13 +442,18 @@ def _score_impl(mode, src_path, selected_names, audio_only, progress):
430
  n_pass = max(1, len(plan_windows(dur))) if dur and dur > 0 else 1
431
  except Exception:
432
  n_pass = 1
433
- fast_encode.set_progress_sink(make_clip_sink(progress, n_pass))
 
 
 
434
  _sink_set = True
 
435
  try:
436
  preds, abs_times, text_media, no_speech = _gpu_infer(mode, src_path, bool(audio_only))
437
  finally:
438
  if _sink_set:
439
  fast_encode.clear_progress_sink()
 
440
 
441
  if preds.shape[0] == 0:
442
  return _fail(
@@ -548,12 +565,24 @@ def build_demo() -> gr.Blocks:
548
  score_outputs = result_outputs + [ok_state]
549
  js_seek = seek_js()
550
 
 
 
 
 
 
 
551
  # Sample clip → fill the Video component.
552
  v["sample_btn"].click(fn=lambda: SAMPLE_VIDEO_URL, inputs=None, outputs=[v["video"]])
553
 
554
  # --- Video ---
 
 
 
 
 
 
555
  v["run_btn"].click(_enter_loading, None, loading_outputs).then(
556
- lambda vid, metrics, ao, pr=gr.Progress(): _score_impl("video", vid, metrics, ao, pr),
557
  inputs=[v["video"], v["metrics"], v["audio_only"]],
558
  outputs=score_outputs,
559
  ).then(_reveal_result, inputs=[ok_state], outputs=[r["result_grp"]]).then(fn=None, js=js_seek)
 
112
  )
113
  from tribescore.metrics import build_roi_masks, summary, to_metrics
114
  from tribescore.plotting import seek_js, timeline_figure
 
115
  from tribescore.windowing import plan_windows, stitch
116
  from tribescore import fast_encode
117
+ from tribescore import progress as progress_state
118
 
119
  logger = logging.getLogger("tribescore.app")
120
  logging.basicConfig(level=logging.INFO)
 
374
  return gr.update(visible=bool(ok))
375
 
376
 
377
+ def _progress_tick():
378
+ """gr.Timer handler (Option T): while a video encode is active, re-render the
379
+ loading card with the live per-clip determinate bar; otherwise ``gr.skip()``
380
+ (no DOM churn when idle / on the Space). Runs in its own concurrency lane, so
381
+ it ticks in parallel with the limit-1 Score event (which alone runs the GPU
382
+ encode — the Timer never encodes, so two encodes can't overlap)."""
383
+ snap = progress_state.snapshot()
384
+ if not snap.get("active"):
385
+ return gr.skip()
386
+ return ui.loading_card_html(snap)
387
+
388
+
389
  def _fail(message: str):
390
  """Error update tuple (same 7 outputs + trailing ok=False for _reveal_result)."""
391
  return (
 
442
  n_pass = max(1, len(plan_windows(dur))) if dur and dur > 0 else 1
443
  except Exception:
444
  n_pass = 1
445
+ # Option T: the encode sink writes per-clip counters into the live
446
+ # state dict; a gr.Timer reads it and re-renders the loading card.
447
+ progress_state.begin(n_pass)
448
+ fast_encode.set_progress_sink(progress_state.sink)
449
  _sink_set = True
450
+ logger.info("registered per-clip progress sink (n_pass=%d, on_spaces=%s)", n_pass, on_spaces())
451
  try:
452
  preds, abs_times, text_media, no_speech = _gpu_infer(mode, src_path, bool(audio_only))
453
  finally:
454
  if _sink_set:
455
  fast_encode.clear_progress_sink()
456
+ progress_state.end()
457
 
458
  if preds.shape[0] == 0:
459
  return _fail(
 
565
  score_outputs = result_outputs + [ok_state]
566
  js_seek = seek_js()
567
 
568
+ # Live in-card progress bar (Option T): a Timer reads the per-clip encode
569
+ # state ~3x/s and re-renders the loading card. Its own concurrency lane →
570
+ # ticks in parallel with the limit-1 Score; idle/Space → gr.skip().
571
+ prog_timer = gr.Timer(0.3)
572
+ prog_timer.tick(_progress_tick, inputs=None, outputs=[r["loading_body"]])
573
+
574
  # Sample clip → fill the Video component.
575
  v["sample_btn"].click(fn=lambda: SAMPLE_VIDEO_URL, inputs=None, outputs=[v["video"]])
576
 
577
  # --- Video ---
578
+ # def (not lambda) keeps gr.Progress injected for the Space's coarse stage
579
+ # bar (the Space fallback). Locally the native bar doesn't render, so the
580
+ # in-card determinate bar is driven by the gr.Timer below (Option T).
581
+ def _run_video(vid, metrics, ao, progress=gr.Progress()):
582
+ return _score_impl("video", vid, metrics, ao, progress)
583
+
584
  v["run_btn"].click(_enter_loading, None, loading_outputs).then(
585
+ _run_video,
586
  inputs=[v["video"], v["metrics"], v["audio_only"]],
587
  outputs=score_outputs,
588
  ).then(_reveal_result, inputs=[ok_state], outputs=[r["result_grp"]]).then(fn=None, js=js_seek)
docs/superpowers/specs/2026-06-24-progress-bar-design.md CHANGED
@@ -1,6 +1,61 @@
1
  # Design: real per-clip progress bar (replace the indeterminate "Scoring…" loader)
2
 
3
- **Branch:** `feat/local-mps-quality-video` · **Status:** DECIDED → implemented; pending live smoke-test.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  ## DECISION (tribe-brain-reviewed, operator-confirmed)
6
 
 
1
  # Design: real per-clip progress bar (replace the indeterminate "Scoring…" loader)
2
 
3
+ **Branch:** `feat/local-mps-quality-video` · **Status:** ✅ RESOLVED — Option T (gr.Timer-driven in-card bar) implemented + live-verified.
4
+
5
+ ## ✅ FINAL: Option T (gr.Timer) — implemented + verified (2026-06-24)
6
+
7
+ Native bar (X) was dead (see below); tribe-brain ruled **Option T** over the worker-thread generator (Y).
8
+ Implemented + live-smoke-tested on the 15 s clip:
9
+ - Encode sink writes per-clip counters into `tribescore.progress._STATE`; a `gr.Timer(0.3)` reads it and
10
+ re-renders the `loading_body` card HTML (`ui.loading_card_html`).
11
+ - **Timer ticks in parallel with the `concurrency_limit=1` Score** (per-concurrency-id queue) — VERIFIED:
12
+ card showed `Encoding video · clip 4/30 · window 1/1` at 13%, advancing per clip; bar amber, determinate.
13
+ - After the Score: card hidden, no stuck bar, timeline + summary render cleanly (Timer → `gr.skip()` when idle).
14
+ - Must-fixes applied: own concurrency lane, local-only `active` gate, `gr.skip()` when idle, Timer updates
15
+ only `loading_body` value (never container visibility → `.then(_reveal_result)`/6.11 workaround untouched),
16
+ no manual thread, sink reused (throttle is the 0.3 s Timer).
17
+ - **Known cosmetic wart:** with the cache OFF the encode double-extracts, so on a 1-window clip the bar fills
18
+ 0→100% then sits at 100% during the 2nd pass. On ≥2-window clips the 2 extract-passes map onto `n_pass`;
19
+ the parity-gated dedup-cache fix makes it single-pass regardless. Not blocking.
20
+
21
+ ---
22
+
23
+
24
+ ## ⚠️ SMOKE-TEST RESULT (2026-06-24): native `gr.Progress` does NOT render here
25
+
26
+ Live smoke-test (15 s clip, restarted app) — **Option X cannot show a visible bar in this app**:
27
+ - Sink CONFIRMED firing: logs show `registered per-clip progress sink (n_pass=1, on_spaces=False)` and
28
+ `PROGRESS_SINK fired (clip 1/30)` → `progress(frac, desc)` IS being called every clip.
29
+ - Yet the DOM has **zero** `.progress-bar`/`.progress-level` elements, **zero** "Encoding video · clip k/N"
30
+ text, and Gradio's `[data-testid="status-tracker"]` stays `class="...hide"` with **empty** innerHTML.
31
+ - Salvage attempt FAILED: converting the handler lambda→`def _run_video(..., progress=gr.Progress())`
32
+ **and** adding explicit `show_progress="full"` did NOT make the determinate bar render. The custom
33
+ indeterminate `co-loading-bar` (which Option X removed) was the only thing ever visible.
34
+
35
+ **Conclusion:** the native determinate bar doesn't render in this loading-card layout regardless of
36
+ lambda/def or show_progress. To get a VISIBLE determinate bar we must drive the **card's own HTML**.
37
+
38
+ ## Architecture fork for the card-driven bar (for tribe-brain)
39
+
40
+ The encode (`_gpu_infer`) is a single BLOCKING call; the per-clip signal comes from deep inside it (the
41
+ sink). Two ways to push that into the visible card HTML:
42
+
43
+ - **(Y) generator + worker thread:** `_score_impl` becomes a generator; run `_gpu_infer` in a
44
+ `threading.Thread`; poll the sink's shared counter; `yield` updated loading-card HTML every ~0.3 s.
45
+ Risk tribe-brain previously flagged: threading×MPS, and a generator feeding the existing
46
+ `.then(_reveal_result)` 6.11 visibility-drop chain.
47
+ - **(T) gr.Timer poll:** the sink writes a module-global `{active,done,total,pass,n_pass}`; a
48
+ `gr.Timer(~0.3s)` handler reads it and returns updated loading-card HTML. No manual thread. BUT
49
+ `run_local.py` sets `.queue(default_concurrency_limit=1)` → the Timer event can't run while the Score
50
+ event holds the only worker UNLESS the Timer gets its own concurrency lane; need to confirm a
51
+ lightweight Timer event can run concurrently with the limit-1 Score without letting two Scores overlap
52
+ (GPU-memory invariant: never two encodes at once).
53
+
54
+ Both are pure-app changes (no model/data change). Decision + must-fixes needed to one-shot it.
55
+
56
+ ---
57
+
58
+ **(superseded) Prior status:** DECIDED → implemented Option X; the live smoke-test then disproved X.
59
 
60
  ## DECISION (tribe-brain-reviewed, operator-confirmed)
61
 
src/tribescore/progress.py CHANGED
@@ -1,73 +1,62 @@
1
- """Pure helpers for the determinate per-clip encode progress bar (Option X).
2
-
3
- The Gradio app (``app.py``) builds a per-clip *sink* here and registers it on
4
- :mod:`tribescore.fast_encode`; the V-JEPA2 encode loop then calls it once per
5
- clip with ``(done, total)``. The sink maps that into the overall progress bar
6
- and drives the native ``gr.Progress`` widget.
7
-
8
- These helpers are pure (the ``progress`` callback and the clock are injected),
9
- so they unit-test without importing the heavy Gradio app. Design + rationale:
10
  ``docs/superpowers/specs/2026-06-24-progress-bar-design.md``.
11
 
12
- LOCAL-ONLY: the sink is registered only on the in-process (Apple-silicon MPS)
13
- path. On the HF Space ``_gpu_infer`` runs in a forked ``@spaces.GPU`` subprocess
14
- that cannot see a parent-set sink, so the coarse stage progress is the Space
15
- fallback there (and :func:`fast_encode._emit_progress` no-ops when unset).
16
  """
17
  from __future__ import annotations
18
 
19
- import time as _time
 
 
 
20
 
21
- # The V-JEPA2 encode occupies the 0.15->0.70 slice of the overall progress bar;
22
- # the stitch/metrics/render stages keep their existing 0.75/0.92/1.0 marks.
23
- ENCODE_LO: float = 0.15
24
- ENCODE_HI: float = 0.70
25
 
 
 
 
26
 
27
- def encode_frac(pass_idx: int, done: int, total: int, n_pass: int) -> float:
28
- """Monotonic map of (window pass, within-window clip) into the encode band.
29
 
30
- With the dedup output-cache OFF, tribev2 re-runs the encode loop once per
31
- window, so the bar advances across ``n_pass`` windows without resetting:
32
- ``frac = LO + (HI-LO) * (pass_idx + done/total) / n_pass``.
33
 
34
- ``pass_idx`` is the 0-based window index; ``done``/``total`` are clips within
35
- the current window; ``n_pass`` is the window count from ``plan_windows()``.
36
- Clamped to ``[ENCODE_LO, ENCODE_HI]`` so an under-/over-estimated ``n_pass``
37
- can never overshoot into the stitch band. Degrades cleanly to a single
38
- 0->100% sweep when ``n_pass == 1`` (e.g. once the output cache lands).
39
- """
40
- n_pass = max(1, n_pass)
41
- within = (done / total) if total else 0.0
42
- frac = ENCODE_LO + (ENCODE_HI - ENCODE_LO) * ((pass_idx + within) / n_pass)
43
- return min(ENCODE_HI, max(ENCODE_LO, frac))
44
 
 
 
 
 
 
 
 
 
 
 
45
 
46
- def make_clip_sink(progress, n_pass: int, *, clock=None, min_interval: float = 0.4):
47
- """Build a per-clip ``sink(done, total)`` for ``fast_encode.set_progress_sink``.
48
 
49
- Tracks window passes by the per-window counter restart (``done`` returning to
50
- a value <= the last seen), maps monotonically via :func:`encode_frac`, and
51
- throttles UI updates to ``min_interval`` seconds — except the final clip of a
52
- window (``done == total``), which always emits. ``progress`` is the
53
- ``gr.Progress`` callable; ``clock`` is injectable for tests.
54
- """
55
- clock = clock or _time.monotonic
56
- st = {"pass": 0, "last": 0, "t": -1e9}
57
 
58
- def _sink(done: int, total: int) -> None:
59
- # New extraction pass (next window) when the per-window counter restarts.
60
- if done <= st["last"]:
61
- st["pass"] += 1
62
- st["last"] = done
63
- now = clock()
64
- if now - st["t"] < min_interval and done != total:
65
- return # throttle the UI update only; pass tracking already advanced
66
- st["t"] = now
67
- window = min(st["pass"] + 1, max(1, n_pass))
68
- progress(
69
- encode_frac(st["pass"], done, total, n_pass),
70
- desc=f"Encoding video · clip {done}/{total} · window {window}/{max(1, n_pass)}",
71
- )
72
 
73
- return _sink
 
 
 
 
 
 
 
 
1
+ """Live per-clip progress state for the determinate in-card bar (Option T).
2
+
3
+ The native ``gr.Progress`` bar does NOT render in this app's loading-card layout
4
+ (smoke-test confirmed: the sink fires but Gradio's status-tracker stays empty).
5
+ So we drive the card's OWN HTML instead: the V-JEPA2 encode sink writes the
6
+ per-clip counters into a module-global dict here, and a ``gr.Timer`` in app.py
7
+ reads it ~3x/s and re-renders the loading card. tribe-brain-reviewed (Option T
8
+ over the worker-thread generator: no manual MPS thread, no generator into the
9
+ Gradio-6.11 visibility chain). Design doc:
10
  ``docs/superpowers/specs/2026-06-24-progress-bar-design.md``.
11
 
12
+ LOCAL-ONLY: ``begin()`` is called only off-Space, so on the HF Space ``active``
13
+ stays False, the Timer reads inactive → ``gr.skip()``, and the coarse stage
14
+ ``progress()`` calls remain the Space fallback. Plain int writes are GIL-atomic,
15
+ so no lock is needed for the single-writer (encode) / single-reader (Timer) case.
16
  """
17
  from __future__ import annotations
18
 
19
+ #: Live state. ``active`` gates the Timer; ``done/total`` are clips within the
20
+ #: current window; ``pass_idx`` is the 0-based window; ``n_pass`` the window
21
+ #: count from ``plan_windows()``; ``_last`` tracks the per-window counter restart.
22
+ _STATE = {"active": False, "done": 0, "total": 0, "pass_idx": 0, "n_pass": 1, "_last": 0}
23
 
 
 
 
 
24
 
25
+ def begin(n_pass: int) -> None:
26
+ """Mark a video encode as starting and reset counters (call before _gpu_infer)."""
27
+ _STATE.update(active=True, done=0, total=0, pass_idx=0, n_pass=max(1, int(n_pass)), _last=0)
28
 
 
 
29
 
30
+ def end() -> None:
31
+ """Mark the encode finished (call in a ``finally``)."""
32
+ _STATE["active"] = False
33
 
 
 
 
 
 
 
 
 
 
 
34
 
35
+ def sink(done: int, total: int) -> None:
36
+ """Per-clip sink registered on ``fast_encode``. Detects a new window pass by
37
+ the per-window counter restart (``done`` returning to <= the last seen), so
38
+ the bar advances monotonically across windows with no reset. No throttle here
39
+ — the 0.3 s Timer is the throttle (it just samples whatever the last write was)."""
40
+ if done <= _STATE["_last"]:
41
+ _STATE["pass_idx"] += 1
42
+ _STATE["_last"] = int(done)
43
+ _STATE["done"] = int(done)
44
+ _STATE["total"] = int(total)
45
 
 
 
46
 
47
+ def snapshot() -> dict:
48
+ """Return a shallow copy of the live state (for the Timer handler)."""
49
+ return dict(_STATE)
50
+
 
 
 
 
51
 
52
+ def encode_frac(snap: dict | None = None) -> float:
53
+ """0..1 fraction of the whole multi-window encode from a snapshot.
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
+ ``frac = (pass_idx + done/total) / n_pass`` — monotonic across windows,
56
+ clamped to [0, 1]. Degrades to a single 0→1 sweep when ``n_pass == 1``.
57
+ """
58
+ s = snap if snap is not None else _STATE
59
+ n = max(1, int(s.get("n_pass", 1) or 1))
60
+ total = int(s.get("total", 0) or 0)
61
+ within = (int(s.get("done", 0) or 0) / total) if total else 0.0
62
+ return min(1.0, max(0.0, (int(s.get("pass_idx", 0) or 0) + within) / n))
tests/test_progress.py CHANGED
@@ -1,98 +1,72 @@
1
- """Unit tests for the determinate per-clip encode progress bar (Option X).
2
 
3
  Covers tribe-brain's must-fixes: monotonic full-band mapping across P windows
4
- (no reset), throttle (final clip always emits), and the never-raise sink guard.
 
5
  """
6
  import pytest
7
 
8
- from tribescore.progress import ENCODE_HI, ENCODE_LO, encode_frac, make_clip_sink
9
 
10
 
11
- def _raise(*_a):
12
- raise RuntimeError("boom")
13
-
14
-
15
- def _recorder():
16
- calls = []
17
-
18
- def progress(frac, desc=None):
19
- calls.append((frac, desc))
20
-
21
- return calls, progress
22
-
23
-
24
- # --- encode_frac ------------------------------------------------------------
25
  def test_encode_frac_endpoints_and_bounds():
26
- assert encode_frac(0, 0, 100, 2) == pytest.approx(ENCODE_LO) # first clip
27
- assert encode_frac(1, 100, 100, 2) == pytest.approx(ENCODE_HI) # last clip, last window
28
- for p in range(4):
29
- for d in (0, 1, 50, 100, 250):
30
- assert ENCODE_LO <= encode_frac(p, d, 100, 2) <= ENCODE_HI
31
-
32
-
33
- def test_encode_frac_monotonic_across_windows():
34
- P = 3
35
- seq = [encode_frac(p, d, 100, P) for p in range(P) for d in range(0, 101, 10)]
36
- assert seq == sorted(seq) # non-decreasing across every window seam
37
-
38
-
39
- def test_encode_frac_degrades_to_single_sweep():
40
- assert encode_frac(0, 0, 50, 1) == pytest.approx(ENCODE_LO)
41
- assert encode_frac(0, 50, 50, 1) == pytest.approx(ENCODE_HI)
42
-
43
-
44
- # --- make_clip_sink ---------------------------------------------------------
45
- def test_sink_single_window_labels_and_monotonic():
46
- calls, progress = _recorder()
47
- ticks = iter(range(1000))
48
- sink = make_clip_sink(progress, n_pass=1, clock=lambda: next(ticks))
49
- for d in range(1, 6):
50
- sink(d, 5)
51
- fracs = [c[0] for c in calls]
52
- assert fracs == sorted(fracs)
53
- assert fracs[-1] == pytest.approx(ENCODE_HI)
54
- assert "window 1/1" in calls[-1][1] and "clip 5/5" in calls[-1][1]
55
-
56
-
57
- def test_sink_detects_second_window_and_stays_monotonic():
58
- calls, progress = _recorder()
59
- ticks = iter(range(1000))
60
- sink = make_clip_sink(progress, n_pass=2, clock=lambda: next(ticks))
61
- for d in range(1, 4): # window 1
62
- sink(d, 3)
63
- for d in range(1, 4): # window 2 — counter restarts
64
- sink(d, 3)
65
- labels = [c[1] for c in calls]
66
- assert any("window 1/2" in s for s in labels)
67
- assert any("window 2/2" in s for s in labels)
68
- fracs = [c[0] for c in calls]
69
- assert fracs == sorted(fracs) # no reset at the seam
70
- assert fracs[-1] == pytest.approx(ENCODE_HI)
71
-
72
-
73
- def test_sink_throttles_but_always_emits_final():
74
- calls, progress = _recorder()
75
- sink = make_clip_sink(progress, n_pass=1, clock=lambda: 0.0, min_interval=0.4)
76
- sink(1, 5) # first emit always (t seeded far in the past)
77
- sink(2, 5) # throttled (Δ=0 < 0.4, not final)
78
- sink(3, 5) # throttled
79
- sink(5, 5) # final clip of the window -> always emits
80
- assert len(calls) == 2
81
- assert "clip 1/5" in calls[0][1] and "clip 5/5" in calls[1][1]
82
-
83
-
84
- # --- fast_encode sink plumbing ---------------------------------------------
85
- def test_fast_encode_sink_set_emit_clear_and_never_raises():
86
  from tribescore import fast_encode
87
 
88
  seen = []
89
  fast_encode.set_progress_sink(lambda d, t: seen.append((d, t)))
90
- fast_encode._emit_progress(3, 10)
91
- assert seen == [(3, 10)]
92
 
93
- fast_encode.set_progress_sink(_raise) # a raising sink must be swallowed
94
- fast_encode._emit_progress(1, 1) # must NOT raise
95
 
 
 
96
  fast_encode.clear_progress_sink()
97
- fast_encode._emit_progress(9, 9) # cleared -> no-op
98
- assert seen == [(3, 10)]
 
1
+ """Unit tests for the in-card determinate progress bar state (Option T).
2
 
3
  Covers tribe-brain's must-fixes: monotonic full-band mapping across P windows
4
+ (no reset, via the per-window counter-restart detection), the active flag, and
5
+ the never-raise fast_encode sink plumbing.
6
  """
7
  import pytest
8
 
9
+ from tribescore import progress as P
10
 
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  def test_encode_frac_endpoints_and_bounds():
13
+ P.begin(2)
14
+ assert P.encode_frac() == pytest.approx(0.0) # nothing encoded yet
15
+ P.sink(1, 10) # window 1, clip 1/10
16
+ assert 0.0 < P.encode_frac() < 0.5
17
+ P.sink(10, 10) # window 1 complete
18
+ assert P.encode_frac() == pytest.approx(0.5) # 1 of 2 windows
19
+ P.sink(1, 10) # window 2 starts (restart)
20
+ P.sink(10, 10) # window 2 complete
21
+ assert P.encode_frac() == pytest.approx(1.0)
22
+
23
+
24
+ def test_sink_detects_window_pass_and_stays_monotonic():
25
+ P.begin(2)
26
+ fracs = []
27
+ for d in range(1, 6): # window 1 of 2
28
+ P.sink(d, 5)
29
+ fracs.append(P.encode_frac())
30
+ assert P.snapshot()["pass_idx"] == 0
31
+ for d in range(1, 6): # window 2 — counter restarts
32
+ P.sink(d, 5)
33
+ fracs.append(P.encode_frac())
34
+ assert P.snapshot()["pass_idx"] == 1
35
+ assert fracs == sorted(fracs) # no reset at the seam
36
+ assert fracs[-1] == pytest.approx(1.0)
37
+
38
+
39
+ def test_single_window_degrades_cleanly():
40
+ P.begin(1)
41
+ P.sink(1, 4)
42
+ assert P.encode_frac() == pytest.approx(0.25)
43
+ P.sink(4, 4)
44
+ assert P.encode_frac() == pytest.approx(1.0)
45
+
46
+
47
+ def test_begin_end_active_flag_and_snapshot_is_copy():
48
+ P.begin(3)
49
+ snap = P.snapshot()
50
+ assert snap["active"] is True and snap["n_pass"] == 3
51
+ snap["done"] = 999 # snapshot is a copy
52
+ assert P.snapshot()["done"] == 0
53
+ P.end()
54
+ assert P.snapshot()["active"] is False
55
+
56
+
57
+ def test_fast_encode_sink_plumbing_never_raises():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  from tribescore import fast_encode
59
 
60
  seen = []
61
  fast_encode.set_progress_sink(lambda d, t: seen.append((d, t)))
62
+ fast_encode._emit_progress(2, 9)
63
+ assert seen == [(2, 9)]
64
 
65
+ def _boom(*_a):
66
+ raise RuntimeError("x")
67
 
68
+ fast_encode.set_progress_sink(_boom)
69
+ fast_encode._emit_progress(1, 1) # must NOT raise
70
  fast_encode.clear_progress_sink()
71
+ fast_encode._emit_progress(5, 5) # cleared -> no-op
72
+ assert seen == [(2, 9)]
theme.py CHANGED
@@ -385,28 +385,30 @@ CSS: str = """
385
  }
386
  .co-state.co-state-error .co-state-title { color: var(--co-error); }
387
 
388
- /* Loading: theme Gradio's native gr.Progress widget to the amber accent so the
389
- real per-clip determinate bar (app.py per-clip sink) matches the card. The
390
- old indeterminate .co-loading-bar scan-line is gone — the native bar is the
391
- single source of truth. Class names span Gradio's progress markup defensively
392
- (the determinate fill + the "clip k/N · window p/P" label). */
393
- .progress-bar,
394
- .progress-level .progress-level-inner,
395
- .progress-level-inner,
396
- .meta-text + .progress-bar {
397
- background: var(--co-amber) !important;
398
- color: var(--co-accent-text, #0E1116) !important;
399
- }
400
- .progress-text,
401
- .meta-text,
402
- .meta-text-center {
403
- color: var(--co-amber) !important;
404
- font-family: var(--co-mono, ui-monospace, monospace) !important;
405
- letter-spacing: 0.01em;
406
- }
407
- .progress-level {
408
- background: var(--co-border) !important;
409
- border-radius: 3px;
 
 
410
  }
411
 
412
  /* ---- Proxy / honesty caveat (the value-region disclaimer, §5) ----------- */
 
385
  }
386
  .co-state.co-state-error .co-state-title { color: var(--co-error); }
387
 
388
+ /* Loading: a DETERMINATE in-card progress bar (Option T). Gradio's native
389
+ gr.Progress bar does not render in this loading-card layout (smoke-test
390
+ confirmed), so a gr.Timer re-renders the card HTML with this amber fill bar
391
+ driven by the live per-clip encode state. */
392
+ .co-prog-track {
393
+ height: 6px;
394
+ border-radius: 4px;
395
+ background: var(--co-border);
396
+ margin: 16px auto 8px;
397
+ max-width: 460px;
398
+ overflow: hidden;
399
+ }
400
+ .co-prog-fill {
401
+ height: 100%;
402
+ border-radius: 4px;
403
+ background: linear-gradient(90deg, var(--co-amber), #FFD9A0);
404
+ box-shadow: 0 0 10px rgba(255,180,84,0.5);
405
+ transition: width 0.3s linear;
406
+ }
407
+ .co-prog-pct {
408
+ font-family: var(--co-mono, ui-monospace, monospace);
409
+ font-size: 12.5px;
410
+ color: var(--co-amber);
411
+ letter-spacing: 0.02em;
412
  }
413
 
414
  /* ---- Proxy / honesty caveat (the value-region disclaimer, §5) ----------- */
ui.py CHANGED
@@ -235,19 +235,11 @@ def build_results() -> dict[str, gr.components.Component]:
235
  empty = gr.HTML(_EMPTY_HTML, visible=True)
236
 
237
  # --- Loading ------------------------------------------------------
 
 
 
238
  with gr.Column(visible=False) as loading:
239
- gr.HTML(
240
- """
241
- <div class="co-state">
242
- <div class="co-state-icon">&#128300;</div>
243
- <div class="co-state-title">Scoring&hellip;</div>
244
- <div class="co-state-body" id="co-progress">
245
- Extracting features and predicting cortical activity. This
246
- runs window-by-window over the clip — progress below.
247
- </div>
248
- </div>
249
- """.strip()
250
- )
251
 
252
  # --- Error --------------------------------------------------------
253
  # app.py sets the inner text via this component's value on failure.
@@ -270,6 +262,7 @@ def build_results() -> dict[str, gr.components.Component]:
270
  return dict(
271
  empty=empty,
272
  loading=loading,
 
273
  error=error,
274
  result_grp=result_grp,
275
  media_html=media_html,
@@ -283,6 +276,36 @@ def build_results() -> dict[str, gr.components.Component]:
283
  # Small HTML helpers app.py can call to render the dynamic states. Pure
284
  # string builders (no Gradio state) so they stay unit-checkable.
285
  # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  def error_html(message: str) -> str:
287
  """Wrap an actionable, plain-voice message in the error-panel markup."""
288
  return (
 
235
  empty = gr.HTML(_EMPTY_HTML, visible=True)
236
 
237
  # --- Loading ------------------------------------------------------
238
+ # A gr.Timer (app.py) re-renders this card ~3x/s with the live per-clip
239
+ # bar during a video encode (Option T — the native gr.Progress bar does
240
+ # not render in this layout). ``loading_body`` is the addressable target.
241
  with gr.Column(visible=False) as loading:
242
+ loading_body = gr.HTML(loading_card_html())
 
 
 
 
 
 
 
 
 
 
 
243
 
244
  # --- Error --------------------------------------------------------
245
  # app.py sets the inner text via this component's value on failure.
 
262
  return dict(
263
  empty=empty,
264
  loading=loading,
265
+ loading_body=loading_body,
266
  error=error,
267
  result_grp=result_grp,
268
  media_html=media_html,
 
276
  # Small HTML helpers app.py can call to render the dynamic states. Pure
277
  # string builders (no Gradio state) so they stay unit-checkable.
278
  # ---------------------------------------------------------------------------
279
+ def loading_card_html(snap: dict | None = None) -> str:
280
+ """Render the 'Scoring…' card with a determinate in-card progress bar from a
281
+ live progress snapshot (``tribescore.progress.snapshot()``). Pure string
282
+ builder. The native ``gr.Progress`` bar does NOT render in this layout, so a
283
+ ``gr.Timer`` (app.py) drives this HTML directly every ~0.3 s (Option T)."""
284
+ snap = snap or {}
285
+ n = max(1, int(snap.get("n_pass", 1) or 1))
286
+ total = int(snap.get("total", 0) or 0)
287
+ done = int(snap.get("done", 0) or 0)
288
+ pass_idx = int(snap.get("pass_idx", 0) or 0)
289
+ within = (done / total) if total else 0.0
290
+ frac = min(1.0, max(0.0, (pass_idx + within) / n))
291
+ pct = int(round(frac * 100))
292
+ window = min(pass_idx + 1, n)
293
+ if total:
294
+ label = f"Encoding video &middot; clip {done}/{total} &middot; window {window}/{n}"
295
+ else:
296
+ label = "Extracting features and predicting cortical activity&hellip;"
297
+ return (
298
+ '<div class="co-state">'
299
+ '<div class="co-state-icon">&#128300;</div>'
300
+ '<div class="co-state-title">Scoring&hellip;</div>'
301
+ f'<div class="co-state-body" id="co-progress">{label}</div>'
302
+ '<div class="co-prog-track">'
303
+ f'<div class="co-prog-fill" style="width:{pct}%"></div></div>'
304
+ f'<div class="co-prog-pct">{pct}%</div>'
305
+ '</div>'
306
+ )
307
+
308
+
309
  def error_html(message: str) -> str:
310
  """Wrap an actionable, plain-voice message in the error-panel markup."""
311
  return (