typoverflow Claude Opus 5 (1M context) commited on
Commit
a9ea39e
·
1 Parent(s): 498277b

Fall back to the composed side-by-side video

Browse files

Rerun could not be made to work: the gradio_rerun viewer intermittently
stops mid-update, reproducible in a 40-line app with synthetic data, and
pinning the reference Space's gradio 6.5.1 broke the page outright.

Keeps the simple single-view layout -- episode list, search, sort, PSNR/MSE --
and puts back the composed mp4, which frame-locks ground truth against the
generated rollout in one player. Camera selector, optional difference panel
and playback fps are retained; drops rerun-sdk and gradio_rerun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. app.py +206 -75
  2. requirements.txt +1 -1
app.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Rerun viewer for forward-dynamics rollouts vs. ground truth.
3
 
4
  Point it at any HF dataset repo laid out as:
5
 
@@ -8,15 +8,16 @@ Point it at any HF dataset repo laid out as:
8
  windows/<episode_id>/gt.mp4 # ground-truth window
9
  windows/<episode_id>/generated.mp4 # model rollout for the same slice
10
 
11
- Pick an episode and its two videos stream into an embedded Rerun viewer, side
12
- by side on a shared frame timeline.
 
 
13
 
 
14
  Nothing is downloaded at build time and no repo is ever pulled whole. Each
15
  browser session gets its own scratch directory, keeps at most
16
- MAX_WINDOWS_ON_DISK windows, and is deleted when the session ends. Recordings
17
- go straight to the viewer as a binary stream, so no .rrd files are written.
18
 
19
- The app starts empty and assumes no dataset: type a repo id and press Load.
20
  Private repos need an HF_TOKEN secret with read access.
21
  """
22
 
@@ -35,10 +36,8 @@ from pathlib import Path
35
 
36
  import cv2
37
  import gradio as gr
 
38
  import numpy as np
39
- import rerun as rr
40
- import rerun.blueprint as rrb
41
- from gradio_rerun import Rerun
42
  from huggingface_hub import HfApi, RepoFolder, hf_hub_download
43
 
44
  HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or None
@@ -48,9 +47,22 @@ BASE_DIR.mkdir(parents=True, exist_ok=True)
48
 
49
  MAX_WINDOWS_ON_DISK = int(os.environ.get("MAX_WINDOWS_ON_DISK", 12))
50
  MAX_PAIRS_IN_RAM = 2
 
51
  MAX_DISCOVER = int(os.environ.get("MAX_DISCOVER", 5000))
52
  SESSION_TTL = int(os.environ.get("SESSION_TTL", 3600))
53
- JPEG_QUALITY = 90
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  SORTS = [
56
  "Hardest first (critical PSNR up)",
@@ -90,6 +102,7 @@ class Session:
90
  self.rows: list[dict] = []
91
  self._on_disk: OrderedDict[str, bool] = OrderedDict()
92
  self._pairs: OrderedDict[str, tuple[np.ndarray, np.ndarray]] = OrderedDict()
 
93
  self.touched = time.time()
94
 
95
  def fetch(self, rel: str, optional: bool = False) -> str | None:
@@ -123,6 +136,8 @@ class Session:
123
  old, _ = self._on_disk.popitem(last=False)
124
  shutil.rmtree(self.data / "windows" / old, ignore_errors=True)
125
  self._pairs.pop(old, None)
 
 
126
  for f in self.render.glob(f"{old}_*"):
127
  f.unlink(missing_ok=True)
128
 
@@ -143,6 +158,18 @@ class Session:
143
  self._pairs.popitem(last=False)
144
  return value
145
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  def set_repo(self, repo: str, revision: str | None) -> None:
147
  if (repo, revision) != (self.repo, self.revision):
148
  self.reset()
@@ -151,6 +178,7 @@ class Session:
151
  def reset(self) -> None:
152
  self._on_disk.clear()
153
  self._pairs.clear()
 
154
  self.rows = []
155
  for d in (self.data, self.render):
156
  shutil.rmtree(d, ignore_errors=True)
@@ -158,6 +186,7 @@ class Session:
158
 
159
  def close(self) -> None:
160
  self._pairs.clear()
 
161
  shutil.rmtree(self.root, ignore_errors=True)
162
 
163
 
@@ -339,6 +368,143 @@ def read_video(path: str) -> np.ndarray:
339
  return np.stack(frames)
340
 
341
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  # --------------------------------------------------------------------------- ui glue
343
 
344
 
@@ -368,7 +534,7 @@ def metrics_md(row: dict) -> str:
368
  def critical_index(row: dict) -> int:
369
  start, crit = row.get("start_frame"), row.get("critical_frame")
370
  if start is None or crit is None:
371
- return -1
372
  return int(crit) - int(start)
373
 
374
 
@@ -404,50 +570,19 @@ def on_filter(sid, query, sort, current):
404
  )
405
 
406
 
407
- def on_metrics(sid, episode_id):
408
  session = get_session(sid)
409
  if not session.rows or not episode_id:
410
- return "*No episode selected.*"
411
- return metrics_md(row_by_id(session, episode_id))
 
412
 
 
 
 
413
 
414
- def render_episode(sid, episode_id):
415
- """Write the episode to an .rrd: both videos side by side on one frame timeline.
416
-
417
- Handing the viewer a finished file rather than a live binary stream: streaming
418
- races the viewer's wasm boot, and chunks that land before it is ready are dropped
419
- against a stopped instance.
420
- """
421
- session = get_session(sid)
422
- if not session.rows or not episode_id:
423
- # Never hand the viewer a null: that resets and stops the wasm instance, and the
424
- # next real value then tries to open a channel on a dead viewer.
425
- return gr.skip()
426
-
427
- path = session.render / f"{episode_id}.rrd"
428
- if path.exists():
429
- return str(path)
430
-
431
- gt, gen = session.pair(episode_id)
432
- crit = critical_index(row_by_id(session, episode_id))
433
- rec = rr.RecordingStream("wmviz", recording_id=episode_id)
434
- for i in range(len(gt)):
435
- rec.set_time("frame", sequence=i)
436
- rec.log("ground_truth", rr.Image(gt[i]).compress(jpeg_quality=JPEG_QUALITY))
437
- rec.log("generated", rr.Image(gen[i]).compress(jpeg_quality=JPEG_QUALITY))
438
- if i == crit:
439
- rec.log("critical_frame", rr.TextLog("critical frame"))
440
- rec.save(
441
- str(path),
442
- default_blueprint=rrb.Blueprint(
443
- rrb.Horizontal(
444
- rrb.Spatial2DView(origin="ground_truth", name="Ground truth"),
445
- rrb.Spatial2DView(origin="generated", name="Generated"),
446
- ),
447
- collapse_panels=True,
448
- ),
449
- )
450
- return str(path)
451
 
452
 
453
  def step(ids, current, delta):
@@ -462,6 +597,10 @@ def step(ids, current, delta):
462
 
463
  # --------------------------------------------------------------------------- app
464
 
 
 
 
 
465
  with gr.Blocks(title="Rollout vs. ground truth", fill_width=True, delete_cache=(600, 600)) as demo:
466
  sid_state = gr.State("", time_to_live=SESSION_TTL, delete_callback=release)
467
  ids_state = gr.State([])
@@ -483,44 +622,36 @@ with gr.Blocks(title="Rollout vs. ground truth", fill_width=True, delete_cache=(
483
  with gr.Row():
484
  prev_btn = gr.Button("Prev")
485
  next_btn = gr.Button("Next")
 
 
 
486
  info = gr.Markdown()
487
 
488
  with gr.Column(scale=4):
489
- # Config matched to rerun/gradio-rerun-viewer: a CSS-string height leaves the
490
- # container at zero height while the viewer re-initialises, and it never recovers.
491
- viewer = Rerun(
492
- streaming=True,
493
- height=820,
494
- panel_states={"time": "expanded", "blueprint": "hidden", "selection": "hidden"},
495
  )
496
 
497
  filter_io = ([sid_state, query, sort, picker], [picker, ids_state])
 
498
 
499
- # Loading and filtering only repopulate the episode list. Selecting an episode is the
500
- # single trigger that streams into the viewer -- if these chains also streamed, setting
501
- # the dropdown value here would fire picker.change too and run two generators
502
- # concurrently into one viewer, which crashes it.
503
  for event in (load_btn.click, repo_box.submit):
504
  event(on_load, [repo_box, sid_state], [sid_state, status]).then(on_filter, *filter_io)
505
 
506
  for control in (query, sort):
507
  control.change(on_filter, *filter_io)
508
 
509
- picker.change(on_metrics, [sid_state, picker], info)
510
- # trigger_mode="once" is load-bearing: .change() defaults to "always_last", which lets a
511
- # second run queue up behind the pending one. Two generators streaming into one viewer
512
- # stops the first viewer mid-write, which is what left it stuck on "Loading Rerun...".
513
- picker.change(
514
- render_episode,
515
- [sid_state, picker],
516
- viewer,
517
- concurrency_limit=1,
518
- trigger_mode="once",
519
- )
520
  prev_btn.click(lambda i, c: step(i, c, -1), [ids_state, picker], picker)
521
  next_btn.click(lambda i, c: step(i, c, +1), [ids_state, picker], picker)
522
 
523
  if __name__ == "__main__":
524
- # ssr_mode must stay off: server-side rendering re-mounts the viewer canvas on
525
- # hydration, which kills the wasm instance mid-stream.
526
- demo.queue(max_size=16).launch(ssr_mode=False)
 
1
  """
2
+ Side-by-side viewer for forward-dynamics rollouts vs. ground truth.
3
 
4
  Point it at any HF dataset repo laid out as:
5
 
 
8
  windows/<episode_id>/gt.mp4 # ground-truth window
9
  windows/<episode_id>/generated.mp4 # model rollout for the same slice
10
 
11
+ Ground truth and generated are composed into a single video so the two panels
12
+ stay frame-locked; two independent players drift apart and cannot be scrubbed
13
+ together. Videos may stack N cameras vertically; N is inferred from the frame
14
+ aspect ratio and the camera selector adapts.
15
 
16
+ The app starts empty and assumes no dataset: type a repo id and press Load.
17
  Nothing is downloaded at build time and no repo is ever pulled whole. Each
18
  browser session gets its own scratch directory, keeps at most
19
+ MAX_WINDOWS_ON_DISK windows, and is deleted when the session ends.
 
20
 
 
21
  Private repos need an HF_TOKEN secret with read access.
22
  """
23
 
 
36
 
37
  import cv2
38
  import gradio as gr
39
+ import imageio_ffmpeg
40
  import numpy as np
 
 
 
41
  from huggingface_hub import HfApi, RepoFolder, hf_hub_download
42
 
43
  HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or None
 
47
 
48
  MAX_WINDOWS_ON_DISK = int(os.environ.get("MAX_WINDOWS_ON_DISK", 12))
49
  MAX_PAIRS_IN_RAM = 2
50
+ MAX_COMPOSED_IN_RAM = 1
51
  MAX_DISCOVER = int(os.environ.get("MAX_DISCOVER", 5000))
52
  SESSION_TTL = int(os.environ.get("SESSION_TTL", 3600))
53
+
54
+ THREE_CAM_NAMES = ["Top", "Left wrist", "Right wrist"]
55
+ ALL_CAMS = -1
56
+ NO_CRIT = -1
57
+
58
+ FONT = cv2.FONT_HERSHEY_SIMPLEX
59
+ HDR, FTR, SEP = 26, 26, 4
60
+ SCALE = 2 # source frames are small; upscale so overlays stay legible
61
+
62
+ GT_COLOR = (130, 225, 140)
63
+ GEN_COLOR = (120, 180, 255)
64
+ DIFF_COLOR = (215, 215, 215)
65
+ CRIT_COLOR = (255, 95, 95)
66
 
67
  SORTS = [
68
  "Hardest first (critical PSNR up)",
 
102
  self.rows: list[dict] = []
103
  self._on_disk: OrderedDict[str, bool] = OrderedDict()
104
  self._pairs: OrderedDict[str, tuple[np.ndarray, np.ndarray]] = OrderedDict()
105
+ self._composed: OrderedDict[tuple, np.ndarray] = OrderedDict()
106
  self.touched = time.time()
107
 
108
  def fetch(self, rel: str, optional: bool = False) -> str | None:
 
136
  old, _ = self._on_disk.popitem(last=False)
137
  shutil.rmtree(self.data / "windows" / old, ignore_errors=True)
138
  self._pairs.pop(old, None)
139
+ for key in [k for k in self._composed if k[0] == old]:
140
+ self._composed.pop(key, None)
141
  for f in self.render.glob(f"{old}_*"):
142
  f.unlink(missing_ok=True)
143
 
 
158
  self._pairs.popitem(last=False)
159
  return value
160
 
161
+ def composed(self, episode_id: str, view: int, show_diff: bool, crit: int) -> np.ndarray:
162
+ key = (episode_id, view, show_diff, crit)
163
+ hit = self._composed.get(key)
164
+ if hit is not None:
165
+ self._composed.move_to_end(key)
166
+ return hit
167
+ value = compose(self.pair(episode_id), view, show_diff, crit)
168
+ self._composed[key] = value
169
+ while len(self._composed) > MAX_COMPOSED_IN_RAM:
170
+ self._composed.popitem(last=False)
171
+ return value
172
+
173
  def set_repo(self, repo: str, revision: str | None) -> None:
174
  if (repo, revision) != (self.repo, self.revision):
175
  self.reset()
 
178
  def reset(self) -> None:
179
  self._on_disk.clear()
180
  self._pairs.clear()
181
+ self._composed.clear()
182
  self.rows = []
183
  for d in (self.data, self.render):
184
  shutil.rmtree(d, ignore_errors=True)
 
186
 
187
  def close(self) -> None:
188
  self._pairs.clear()
189
+ self._composed.clear()
190
  shutil.rmtree(self.root, ignore_errors=True)
191
 
192
 
 
368
  return np.stack(frames)
369
 
370
 
371
+ def n_cameras(frames: np.ndarray) -> int:
372
+ """How many roughly-square cameras are stacked vertically in the frame."""
373
+ h, w = frames.shape[1], frames.shape[2]
374
+ return max(1, min(6, int(round(h / w))))
375
+
376
+
377
+ def camera_names(n: int) -> list[str]:
378
+ return THREE_CAM_NAMES if n == 3 else [f"Camera {i + 1}" for i in range(n)]
379
+
380
+
381
+ def camera_choices(n: int) -> list[tuple[str, int]]:
382
+ choices = [(name, i) for i, name in enumerate(camera_names(n))]
383
+ return ([("All cameras", ALL_CAMS)] + choices) if n > 1 else choices
384
+
385
+
386
+ def cam_slices(frames: np.ndarray, view: int) -> list[np.ndarray]:
387
+ n = n_cameras(frames)
388
+ h = frames.shape[1] // n
389
+ rows = range(n) if view == ALL_CAMS or not 0 <= view < n else [view]
390
+ return [frames[:, i * h : (i + 1) * h] for i in rows]
391
+
392
+
393
+ def letterbox_rows(*bands: np.ndarray, thresh: int = 16) -> tuple[int, int]:
394
+ """Rows to keep after dropping the black bars shared by every given band."""
395
+ h = bands[0].shape[1]
396
+ lit = np.zeros(h, bool)
397
+ for b in bands:
398
+ lit |= b.reshape(b.shape[0], h, -1).max(axis=(0, 2)) > thresh
399
+ idx = np.flatnonzero(lit)
400
+ if idx.size < h * 0.1: # nothing meaningful to trim
401
+ return 0, h
402
+ return int(idx[0]), int(idx[-1]) + 1
403
+
404
+
405
+ def crop_view(gt: np.ndarray, gen: np.ndarray, view: int) -> tuple[np.ndarray, np.ndarray]:
406
+ """Select cameras and trim the letterbox, identically for both videos."""
407
+ gt_out, gen_out = [], []
408
+ for g, p in zip(cam_slices(gt, view), cam_slices(gen, view)):
409
+ lo, hi = letterbox_rows(g, p)
410
+ gt_out.append(g[:, lo:hi])
411
+ gen_out.append(p[:, lo:hi])
412
+ return np.concatenate(gt_out, axis=1), np.concatenate(gen_out, axis=1)
413
+
414
+
415
+ def upscale(frames: np.ndarray, scale: int) -> np.ndarray:
416
+ if scale == 1:
417
+ return frames
418
+ return np.stack(
419
+ [cv2.resize(f, None, fx=scale, fy=scale, interpolation=cv2.INTER_NEAREST) for f in frames]
420
+ )
421
+
422
+
423
+ def panel(frames: np.ndarray, title: str, color: tuple[int, int, int]) -> np.ndarray:
424
+ t, h, w, _ = frames.shape
425
+ out = np.zeros((t, HDR + h, w, 3), np.uint8)
426
+ out[:, HDR:] = frames
427
+ bar = np.full((HDR, w, 3), 22, np.uint8)
428
+ cv2.putText(bar, title, (8, HDR - 8), FONT, 0.52, color, 1, cv2.LINE_AA)
429
+ out[:, :HDR] = bar
430
+ return out
431
+
432
+
433
+ def diff_frames(gt: np.ndarray, gen: np.ndarray, gain: float = 3.0) -> np.ndarray:
434
+ d = np.abs(gt.astype(np.int16) - gen.astype(np.int16)).mean(axis=-1)
435
+ d = np.clip(d * gain, 0, 255).astype(np.uint8)
436
+ return np.stack([cv2.applyColorMap(f, cv2.COLORMAP_INFERNO)[:, :, ::-1] for f in d])
437
+
438
+
439
+ def compose(pair: tuple[np.ndarray, np.ndarray], view: int, show_diff: bool, crit_idx: int) -> np.ndarray:
440
+ """Build the labelled side-by-side strip: [T, H, W, 3] RGB."""
441
+ gt_c, gen_c = crop_view(pair[0], pair[1], view)
442
+ gt_v, gen_v = upscale(gt_c, SCALE), upscale(gen_c, SCALE)
443
+
444
+ panels = [panel(gt_v, "GROUND TRUTH", GT_COLOR), panel(gen_v, "GENERATED", GEN_COLOR)]
445
+ if show_diff:
446
+ panels.append(panel(diff_frames(gt_v, gen_v), "|DIFF| x3", DIFF_COLOR))
447
+
448
+ t, ph, _, _ = panels[0].shape
449
+ gap = np.zeros((t, ph, SEP, 3), np.uint8)
450
+ stitched = [panels[0]]
451
+ for p in panels[1:]:
452
+ stitched += [gap, p]
453
+ comp = np.concatenate(stitched, axis=2)
454
+
455
+ t, h, w, _ = comp.shape
456
+ out = np.zeros((t, h + FTR, w, 3), np.uint8)
457
+ out[:, :h] = comp
458
+ has_crit = 0 <= crit_idx < t
459
+ tl0, tl1, y = int(w * 0.45), w - 14, FTR // 2
460
+ for i in range(t):
461
+ bar = np.full((FTR, w, 3), 22, np.uint8)
462
+ is_crit = has_crit and i == crit_idx
463
+ text = f"frame {i:02d}/{t - 1}" + (" CRITICAL FRAME" if is_crit else "")
464
+ cv2.putText(bar, text, (8, FTR - 8), FONT, 0.5, CRIT_COLOR if is_crit else (200, 200, 200), 1, cv2.LINE_AA)
465
+ cv2.line(bar, (tl0, y), (tl1, y), (70, 70, 70), 2, cv2.LINE_AA)
466
+ if has_crit:
467
+ cx = int(tl0 + (tl1 - tl0) * crit_idx / max(t - 1, 1))
468
+ cv2.line(bar, (cx, y - 7), (cx, y + 7), CRIT_COLOR, 2, cv2.LINE_AA)
469
+ px = int(tl0 + (tl1 - tl0) * i / max(t - 1, 1))
470
+ cv2.circle(bar, (px, y), 4, (255, 255, 255), -1, cv2.LINE_AA)
471
+ out[i, h:] = bar
472
+ if is_crit:
473
+ cv2.rectangle(out[i], (0, 0), (w - 1, h + FTR - 1), CRIT_COLOR, 3)
474
+
475
+ # yuv420p needs even dimensions
476
+ ph, pw = out.shape[1] % 2, out.shape[2] % 2
477
+ if ph or pw:
478
+ out = np.pad(out, ((0, 0), (0, ph), (0, pw), (0, 0)))
479
+ return out
480
+
481
+
482
+ def write_mp4(frames: np.ndarray, path: Path, fps: float) -> str:
483
+ _, h, w, _ = frames.shape
484
+ writer = imageio_ffmpeg.write_frames(
485
+ str(path),
486
+ size=(w, h),
487
+ fps=fps,
488
+ codec="libx264",
489
+ pix_fmt_in="rgb24",
490
+ pix_fmt_out="yuv420p",
491
+ macro_block_size=1,
492
+ output_params=["-crf", "16", "-preset", "veryfast", "-movflags", "+faststart"],
493
+ )
494
+ writer.send(None)
495
+ for f in frames:
496
+ writer.send(np.ascontiguousarray(f).tobytes())
497
+ writer.close()
498
+ return str(path)
499
+
500
+
501
+ def render_video(session: Session, episode_id: str, view: int, show_diff: bool, fps: float, crit: int) -> str:
502
+ path = session.render / f"{episode_id}_{view}_{int(show_diff)}_{fps:g}.mp4"
503
+ if not path.exists():
504
+ write_mp4(session.composed(episode_id, view, show_diff, crit), path, fps)
505
+ return str(path)
506
+
507
+
508
  # --------------------------------------------------------------------------- ui glue
509
 
510
 
 
534
  def critical_index(row: dict) -> int:
535
  start, crit = row.get("start_frame"), row.get("critical_frame")
536
  if start is None or crit is None:
537
+ return NO_CRIT
538
  return int(crit) - int(start)
539
 
540
 
 
570
  )
571
 
572
 
573
+ def on_select(sid, episode_id, view, show_diff, fps):
574
  session = get_session(sid)
575
  if not session.rows or not episode_id:
576
+ return None, "*No episode selected.*", gr.update()
577
+ row = row_by_id(session, episode_id)
578
+ crit = critical_index(row)
579
 
580
+ choices = camera_choices(n_cameras(session.pair(episode_id)[0]))
581
+ valid = [v for _, v in choices]
582
+ view = view if view in valid else choices[0][1]
583
 
584
+ video = render_video(session, episode_id, view, show_diff, fps, crit)
585
+ return video, metrics_md(row), gr.update(choices=choices, value=view)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
 
587
 
588
  def step(ids, current, delta):
 
597
 
598
  # --------------------------------------------------------------------------- app
599
 
600
+ CSS = """
601
+ .window-video video { max-height: 82vh; object-fit: contain; background: #111; }
602
+ """
603
+
604
  with gr.Blocks(title="Rollout vs. ground truth", fill_width=True, delete_cache=(600, 600)) as demo:
605
  sid_state = gr.State("", time_to_live=SESSION_TTL, delete_callback=release)
606
  ids_state = gr.State([])
 
622
  with gr.Row():
623
  prev_btn = gr.Button("Prev")
624
  next_btn = gr.Button("Next")
625
+ view = gr.Radio(camera_choices(3), value=0, label="Camera")
626
+ show_diff = gr.Checkbox(False, label="Add |GT - generated| panel")
627
+ fps = gr.Slider(2, 30, value=8, step=1, label="Playback fps")
628
  info = gr.Markdown()
629
 
630
  with gr.Column(scale=4):
631
+ video = gr.Video(
632
+ show_label=False, # the panels are captioned in-frame; the chip covers them
633
+ autoplay=True,
634
+ loop=True,
635
+ buttons=["download", "fullscreen"],
636
+ elem_classes="window-video",
637
  )
638
 
639
  filter_io = ([sid_state, query, sort, picker], [picker, ids_state])
640
+ select_io = ([sid_state, picker, view, show_diff, fps], [video, info, view])
641
 
642
+ # Load and filter only repopulate the episode list; selecting an episode is what renders.
 
 
 
643
  for event in (load_btn.click, repo_box.submit):
644
  event(on_load, [repo_box, sid_state], [sid_state, status]).then(on_filter, *filter_io)
645
 
646
  for control in (query, sort):
647
  control.change(on_filter, *filter_io)
648
 
649
+ picker.change(on_select, *select_io)
650
+ for control in (view, show_diff, fps):
651
+ control.change(on_select, *select_io)
652
+
 
 
 
 
 
 
 
653
  prev_btn.click(lambda i, c: step(i, c, -1), [ids_state, picker], picker)
654
  next_btn.click(lambda i, c: step(i, c, +1), [ids_state, picker], picker)
655
 
656
  if __name__ == "__main__":
657
+ demo.queue(max_size=16).launch(css=CSS)
 
 
requirements.txt CHANGED
@@ -1,5 +1,5 @@
1
  gradio==6.22.0
2
- gradio_rerun==0.35.0
3
  huggingface_hub>=0.28
4
  numpy
5
  opencv-python-headless
 
 
1
  gradio==6.22.0
 
2
  huggingface_hub>=0.28
3
  numpy
4
  opencv-python-headless
5
+ imageio-ffmpeg