Spaces:
Running on Zero
feat(ui): in-card determinate progress bar via gr.Timer (Option T)
Browse filesThe 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 +32 -3
- docs/superpowers/specs/2026-06-24-progress-bar-design.md +56 -1
- src/tribescore/progress.py +47 -58
- tests/test_progress.py +57 -83
- theme.py +24 -22
- ui.py +35 -12
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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)
|
|
@@ -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:**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -1,73 +1,62 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
The
|
| 4 |
-
:
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
``docs/superpowers/specs/2026-06-24-progress-bar-design.md``.
|
| 11 |
|
| 12 |
-
LOCAL-ONLY:
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
"""
|
| 17 |
from __future__ import annotations
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 31 |
-
|
| 32 |
-
|
| 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 |
-
|
| 50 |
-
a
|
| 51 |
-
|
| 52 |
-
|
| 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 |
-
|
| 59 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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))
|
|
@@ -1,98 +1,72 @@
|
|
| 1 |
-
"""Unit tests for the
|
| 2 |
|
| 3 |
Covers tribe-brain's must-fixes: monotonic full-band mapping across P windows
|
| 4 |
-
(no reset
|
|
|
|
| 5 |
"""
|
| 6 |
import pytest
|
| 7 |
|
| 8 |
-
from tribescore
|
| 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 |
-
|
| 27 |
-
assert encode_frac(
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
P =
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
#
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
assert
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 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(
|
| 91 |
-
assert seen == [(
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
|
|
|
|
|
|
|
| 96 |
fast_encode.clear_progress_sink()
|
| 97 |
-
fast_encode._emit_progress(
|
| 98 |
-
assert seen == [(
|
|
|
|
| 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)]
|
|
@@ -385,28 +385,30 @@ CSS: str = """
|
|
| 385 |
}
|
| 386 |
.co-state.co-state-error .co-state-title { color: var(--co-error); }
|
| 387 |
|
| 388 |
-
/* Loading:
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
}
|
| 400 |
-
.
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
}
|
| 407 |
-
.
|
| 408 |
-
|
| 409 |
-
|
|
|
|
|
|
|
| 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) ----------- */
|
|
@@ -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">🔬</div>
|
| 243 |
-
<div class="co-state-title">Scoring…</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 · clip {done}/{total} · window {window}/{n}"
|
| 295 |
+
else:
|
| 296 |
+
label = "Extracting features and predicting cortical activity…"
|
| 297 |
+
return (
|
| 298 |
+
'<div class="co-state">'
|
| 299 |
+
'<div class="co-state-icon">🔬</div>'
|
| 300 |
+
'<div class="co-state-title">Scoring…</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 (
|