HongzeFu commited on
Commit
347ca04
·
1 Parent(s): 285b9a7

video play button

Browse files
gradio-web/gradio_callbacks.py CHANGED
@@ -24,6 +24,8 @@ from state_manager import (
24
  update_session_activity,
25
  get_session_activity,
26
  cleanup_session,
 
 
27
  reset_play_button_clicked,
28
  GLOBAL_SESSIONS,
29
  SESSION_LAST_ACTIVITY,
@@ -225,6 +227,21 @@ def on_video_end(uid):
225
  return format_log_markdown(_ui_text("log", "action_selection_prompt"))
226
 
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  def switch_to_execute_phase(uid):
229
  """Disable controls and keypoint clicking during execute playback."""
230
  if uid:
@@ -406,7 +423,8 @@ def on_video_end_transition(uid):
406
  gr.update(visible=False), # video_phase_group
407
  gr.update(visible=True), # action_phase_group
408
  gr.update(visible=True), # control_panel_group
409
- format_log_markdown(_ui_text("log", "action_selection_prompt"))
 
410
  )
411
 
412
 
@@ -421,6 +439,7 @@ def _task_load_failed_response(uid, message):
421
  "", # goal_box
422
  _ui_text("coords", "not_needed"), # coords_box
423
  gr.update(value=None, visible=False), # video_display
 
424
  "", # task_info_box
425
  "", # progress_info_box
426
  gr.update(interactive=False), # restart_episode_btn
@@ -501,6 +520,7 @@ def _load_status_task(uid, status):
501
  "", # goal_box
502
  _ui_text("coords", "not_needed"), # coords_box
503
  gr.update(value=None, visible=False), # video_display
 
504
  f"{actual_env_id} (Episode {ep_num})", # task_info_box
505
  progress_text, # progress_info_box
506
  gr.update(interactive=True), # restart_episode_btn
@@ -540,13 +560,10 @@ def _load_status_task(uid, status):
540
  )
541
 
542
  demo_video_path = None
543
- has_demo_video = False
544
  should_show = should_show_demo_video(actual_env_id) if actual_env_id else False
545
  initial_log_msg = format_log_markdown(_ui_text("log", "action_selection_prompt"))
546
 
547
  if should_show:
548
- has_demo_video = True
549
- initial_log_msg = format_log_markdown(_ui_text("log", "demo_video_prompt"))
550
  if session.demonstration_frames:
551
  try:
552
  demo_video_path = save_video(session.demonstration_frames, "demo")
@@ -565,6 +582,10 @@ def _load_status_task(uid, status):
565
  bool(demo_video_path),
566
  )
567
 
 
 
 
 
568
  img = session.get_pil_image(use_segmented=USE_SEGMENTED_VIEW)
569
 
570
  if has_demo_video:
@@ -579,6 +600,7 @@ def _load_status_task(uid, status):
579
  goal_text, # goal_box
580
  _ui_text("coords", "not_needed"), # coords_box
581
  gr.update(value=demo_video_path, visible=True), # video_display
 
582
  f"{actual_env_id} (Episode {ep_num})", # task_info_box
583
  progress_text, # progress_info_box
584
  gr.update(interactive=True), # restart_episode_btn
@@ -603,6 +625,7 @@ def _load_status_task(uid, status):
603
  goal_text, # goal_box
604
  _ui_text("coords", "not_needed"), # coords_box
605
  gr.update(value=None, visible=False), # video_display (no video)
 
606
  f"{actual_env_id} (Episode {ep_num})", # task_info_box
607
  progress_text, # progress_info_box
608
  gr.update(interactive=True), # restart_episode_btn
 
24
  update_session_activity,
25
  get_session_activity,
26
  cleanup_session,
27
+ get_play_button_clicked,
28
+ set_play_button_clicked,
29
  reset_play_button_clicked,
30
  GLOBAL_SESSIONS,
31
  SESSION_LAST_ACTIVITY,
 
227
  return format_log_markdown(_ui_text("log", "action_selection_prompt"))
228
 
229
 
230
+ def on_demo_video_play(uid):
231
+ """Mark the demo video as consumed and disable the play button."""
232
+ if uid:
233
+ update_session_activity(uid)
234
+ already_clicked = get_play_button_clicked(uid)
235
+ if not already_clicked:
236
+ set_play_button_clicked(uid, True)
237
+ LOGGER.debug(
238
+ "demo video play clicked uid=%s already_clicked=%s",
239
+ _uid_for_log(uid),
240
+ already_clicked,
241
+ )
242
+ return gr.update(visible=True, interactive=False)
243
+
244
+
245
  def switch_to_execute_phase(uid):
246
  """Disable controls and keypoint clicking during execute playback."""
247
  if uid:
 
423
  gr.update(visible=False), # video_phase_group
424
  gr.update(visible=True), # action_phase_group
425
  gr.update(visible=True), # control_panel_group
426
+ format_log_markdown(_ui_text("log", "action_selection_prompt")),
427
+ gr.update(visible=False, interactive=False), # watch_demo_video_btn
428
  )
429
 
430
 
 
439
  "", # goal_box
440
  _ui_text("coords", "not_needed"), # coords_box
441
  gr.update(value=None, visible=False), # video_display
442
+ gr.update(visible=False, interactive=False), # watch_demo_video_btn
443
  "", # task_info_box
444
  "", # progress_info_box
445
  gr.update(interactive=False), # restart_episode_btn
 
520
  "", # goal_box
521
  _ui_text("coords", "not_needed"), # coords_box
522
  gr.update(value=None, visible=False), # video_display
523
+ gr.update(visible=False, interactive=False), # watch_demo_video_btn
524
  f"{actual_env_id} (Episode {ep_num})", # task_info_box
525
  progress_text, # progress_info_box
526
  gr.update(interactive=True), # restart_episode_btn
 
560
  )
561
 
562
  demo_video_path = None
 
563
  should_show = should_show_demo_video(actual_env_id) if actual_env_id else False
564
  initial_log_msg = format_log_markdown(_ui_text("log", "action_selection_prompt"))
565
 
566
  if should_show:
 
 
567
  if session.demonstration_frames:
568
  try:
569
  demo_video_path = save_video(session.demonstration_frames, "demo")
 
582
  bool(demo_video_path),
583
  )
584
 
585
+ has_demo_video = bool(demo_video_path)
586
+ if has_demo_video:
587
+ initial_log_msg = format_log_markdown(_ui_text("log", "demo_video_prompt"))
588
+
589
  img = session.get_pil_image(use_segmented=USE_SEGMENTED_VIEW)
590
 
591
  if has_demo_video:
 
600
  goal_text, # goal_box
601
  _ui_text("coords", "not_needed"), # coords_box
602
  gr.update(value=demo_video_path, visible=True), # video_display
603
+ gr.update(visible=True, interactive=True), # watch_demo_video_btn
604
  f"{actual_env_id} (Episode {ep_num})", # task_info_box
605
  progress_text, # progress_info_box
606
  gr.update(interactive=True), # restart_episode_btn
 
625
  goal_text, # goal_box
626
  _ui_text("coords", "not_needed"), # coords_box
627
  gr.update(value=None, visible=False), # video_display (no video)
628
+ gr.update(visible=False, interactive=False), # watch_demo_video_btn
629
  f"{actual_env_id} (Episode {ep_num})", # task_info_box
630
  progress_text, # progress_info_box
631
  gr.update(interactive=True), # restart_episode_btn
gradio-web/test/test_ui_native_layout_contract.py CHANGED
@@ -77,6 +77,7 @@ def test_native_ui_config_contains_phase_machine_and_precheck_chain(reload_modul
77
  "video_phase_group",
78
  "action_phase_group",
79
  "demo_video",
 
80
  "live_obs",
81
  "action_radio",
82
  "coords_box",
@@ -107,7 +108,15 @@ def test_native_ui_config_contains_phase_machine_and_precheck_chain(reload_modul
107
  )
108
  assert log_output_comp.get("props", {}).get("max_lines") is None
109
 
 
 
 
 
 
 
 
110
  api_names = [dep.get("api_name") for dep in cfg.get("dependencies", [])]
 
111
  assert "precheck_execute_inputs" in api_names
112
  assert "switch_to_execute_phase" in api_names
113
  assert "execute_step" in api_names
 
77
  "video_phase_group",
78
  "action_phase_group",
79
  "demo_video",
80
+ "watch_demo_video_btn",
81
  "live_obs",
82
  "action_radio",
83
  "coords_box",
 
108
  )
109
  assert log_output_comp.get("props", {}).get("max_lines") is None
110
 
111
+ demo_video_comp = next(
112
+ comp
113
+ for comp in cfg.get("components", [])
114
+ if comp.get("props", {}).get("elem_id") == "demo_video"
115
+ )
116
+ assert demo_video_comp.get("props", {}).get("autoplay") is False
117
+
118
  api_names = [dep.get("api_name") for dep in cfg.get("dependencies", [])]
119
+ assert "on_demo_video_play" in api_names
120
  assert "precheck_execute_inputs" in api_names
121
  assert "switch_to_execute_phase" in api_names
122
  assert "execute_step" in api_names
gradio-web/test/test_ui_phase_machine_runtime_e2e.py CHANGED
@@ -113,6 +113,7 @@ def _read_phase_visibility(page) -> dict[str, bool | str | None]:
113
  return {
114
  videoPhase: visible('video_phase_group'),
115
  video: visible('demo_video'),
 
116
  actionPhase: visible('action_phase_group'),
117
  action: visible('live_obs'),
118
  controlPhase: visible('control_panel_group'),
@@ -123,6 +124,51 @@ def _read_phase_visibility(page) -> dict[str, bool | str | None]:
123
  )
124
 
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  def _read_live_obs_geometry(page) -> dict[str, dict[str, float] | None]:
127
  return page.evaluate(
128
  """() => {
@@ -197,7 +243,7 @@ def font_size_probe_ui_url(monkeypatch):
197
 
198
  @pytest.fixture
199
  def phase_machine_ui_url():
200
- state = {"precheck_calls": 0}
201
  demo_video_url = "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
202
  ui_layout = importlib.reload(importlib.import_module("ui_layout"))
203
 
@@ -210,7 +256,13 @@ def phase_machine_ui_url():
210
 
211
  with gr.Column(visible=False, elem_id="main_interface") as main_interface:
212
  with gr.Column(visible=False, elem_id="video_phase_group") as video_phase_group:
213
- video_display = gr.Video(value=None, elem_id="demo_video", autoplay=True)
 
 
 
 
 
 
214
 
215
  with gr.Column(visible=False, elem_id="action_phase_group") as action_phase_group:
216
  img_display = gr.Image(value=np.zeros((24, 24, 3), dtype=np.uint8), elem_id="live_obs")
@@ -228,6 +280,13 @@ def phase_machine_ui_url():
228
  next_task_btn = gr.Button("Next Task", elem_id="next_task_btn")
229
 
230
  log_output = gr.Markdown("", elem_id="log_output")
 
 
 
 
 
 
 
231
 
232
  def login_fn():
233
  return (
@@ -235,6 +294,7 @@ def phase_machine_ui_url():
235
  gr.update(visible=True),
236
  gr.update(visible=True),
237
  gr.update(value=demo_video_url, visible=True),
 
238
  gr.update(visible=False),
239
  gr.update(visible=False),
240
  gr.update(visible=False),
@@ -243,6 +303,13 @@ def phase_machine_ui_url():
243
  "demo_video",
244
  )
245
 
 
 
 
 
 
 
 
246
  def on_video_end_fn():
247
  return (
248
  gr.update(visible=False),
@@ -250,6 +317,7 @@ def phase_machine_ui_url():
250
  gr.update(visible=True),
251
  gr.update(visible=True),
252
  gr.update(interactive=True),
 
253
  "action_keypoint",
254
  )
255
 
@@ -293,6 +361,7 @@ def phase_machine_ui_url():
293
  main_interface,
294
  video_phase_group,
295
  video_display,
 
296
  action_phase_group,
297
  control_panel_group,
298
  action_buttons_row,
@@ -303,6 +372,12 @@ def phase_machine_ui_url():
303
  queue=False,
304
  )
305
 
 
 
 
 
 
 
306
  video_display.end(
307
  fn=on_video_end_fn,
308
  outputs=[
@@ -311,10 +386,51 @@ def phase_machine_ui_url():
311
  control_panel_group,
312
  action_buttons_row,
313
  reference_action_btn,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  phase_state,
315
  ],
316
  queue=False,
317
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
 
319
  exec_btn.click(
320
  fn=precheck_fn,
@@ -427,6 +543,7 @@ def test_phase_machine_runtime_flow_and_execute_precheck(phase_machine_ui_url):
427
  };
428
  return {
429
  video: visible('demo_video'),
 
430
  action: visible('live_obs'),
431
  control: visible('action_radio'),
432
  };
@@ -434,27 +551,59 @@ def test_phase_machine_runtime_flow_and_execute_precheck(phase_machine_ui_url):
434
  )
435
  assert phase_after_login == {
436
  "video": True,
 
437
  "action": False,
438
  "control": False,
439
  }
440
 
441
  page.wait_for_selector("#demo_video video", timeout=5000)
442
- did_dispatch_end = page.evaluate(
443
  """() => {
444
  const videoEl = document.querySelector('#demo_video video');
445
- if (!videoEl) return false;
446
- videoEl.dispatchEvent(new Event('ended', { bubbles: true }));
447
- return true;
448
- }"""
 
 
449
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  assert did_dispatch_end
451
 
452
  page.wait_for_function(
453
  """() => {
454
  const action = document.getElementById('live_obs');
455
  const control = document.getElementById('action_radio');
456
- if (!action || !control) return false;
457
- return getComputedStyle(action).display !== 'none' && getComputedStyle(control).display !== 'none';
 
 
 
 
 
458
  }"""
459
  )
460
 
@@ -549,6 +698,7 @@ def test_phase_machine_runtime_flow_and_execute_precheck(phase_machine_ui_url):
549
  browser.close()
550
 
551
  assert state["precheck_calls"] >= 2
 
552
 
553
 
554
  def test_reference_action_button_is_green_only_when_interactive(phase_machine_ui_url):
@@ -569,14 +719,17 @@ def test_reference_action_button_is_green_only_when_interactive(phase_machine_ui
569
  assert disabled_snapshot["backgroundColor"] != "rgb(31, 139, 76)"
570
 
571
  page.wait_for_selector("#demo_video video", timeout=5000)
572
- did_dispatch_end = page.evaluate(
 
573
  """() => {
574
- const videoEl = document.querySelector('#demo_video video');
575
- if (!videoEl) return false;
576
- videoEl.dispatchEvent(new Event('ended', { bubbles: true }));
577
- return true;
578
- }"""
 
579
  )
 
580
  assert did_dispatch_end
581
 
582
  page.wait_for_function(
@@ -597,6 +750,59 @@ def test_reference_action_button_is_green_only_when_interactive(phase_machine_ui
597
  browser.close()
598
 
599
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
  def test_unified_loading_overlay_init_flow(monkeypatch):
601
  ui_layout = importlib.reload(importlib.import_module("ui_layout"))
602
 
@@ -621,6 +827,7 @@ def test_unified_loading_overlay_init_flow(monkeypatch):
621
  "goal", # goal_box
622
  "No need for coordinates", # coords_box
623
  gr.update(value=None, visible=False), # video_display
 
624
  "PickXtimes (Episode 1)", # task_info_box
625
  "Completed: 0", # progress_info_box
626
  gr.update(interactive=True), # restart_episode_btn
@@ -690,6 +897,95 @@ def test_unified_loading_overlay_init_flow(monkeypatch):
690
  assert calls["init"] >= 1
691
 
692
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
693
  def test_live_obs_client_resize_fills_width_and_keeps_click_mapping(monkeypatch):
694
  callbacks = importlib.reload(importlib.import_module("gradio_callbacks"))
695
  ui_layout = importlib.reload(importlib.import_module("ui_layout"))
@@ -718,6 +1014,7 @@ def test_live_obs_client_resize_fills_width_and_keeps_click_mapping(monkeypatch)
718
  interactive=False,
719
  ), # coords_box
720
  gr.update(value=None, visible=False), # video_display
 
721
  "ResizeEnv (Episode 1)", # task_info_box
722
  "Completed: 0", # progress_info_box
723
  gr.update(interactive=True), # restart_episode_btn
@@ -946,6 +1243,7 @@ def test_header_task_shows_env_after_init(monkeypatch):
946
  "goal", # goal_box
947
  "No need for coordinates", # coords_box
948
  gr.update(value=None, visible=False), # video_display
 
949
  "PickXtimes (Episode 1)", # task_info_box
950
  "Completed: 0", # progress_info_box
951
  gr.update(interactive=True), # restart_episode_btn
@@ -1021,6 +1319,7 @@ def test_header_task_env_normalization_and_fallback(monkeypatch, task_info_text,
1021
  "goal", # goal_box
1022
  "No need for coordinates", # coords_box
1023
  gr.update(value=None, visible=False), # video_display
 
1024
  task_info_text, # task_info_box
1025
  "Completed: 0", # progress_info_box
1026
  gr.update(interactive=True), # restart_episode_btn
@@ -1092,6 +1391,7 @@ def test_header_task_switch_to_video_task_shows_demo_phase(monkeypatch):
1092
  "video goal" if show_video else "goal", # goal_box
1093
  "No need for coordinates", # coords_box
1094
  gr.update(value=demo_video_path if show_video else None, visible=show_video), # video_display
 
1095
  f"{task_name} (Episode 1)", # task_info_box
1096
  "Completed: 0", # progress_info_box
1097
  gr.update(interactive=True), # restart_episode_btn
@@ -1164,12 +1464,20 @@ def test_header_task_switch_to_video_task_shows_demo_phase(monkeypatch):
1164
  return st.display !== 'none' && st.visibility !== 'hidden' && el.getClientRects().length > 0;
1165
  };
1166
  const videoEl = document.querySelector('#demo_video video');
 
 
 
1167
  return (
1168
  visible('video_phase_group') &&
1169
  visible('demo_video') &&
 
1170
  !visible('action_phase_group') &&
1171
  !visible('control_panel_group') &&
1172
- !!(videoEl && videoEl.currentSrc)
 
 
 
 
1173
  );
1174
  }""",
1175
  timeout=10000,
@@ -1178,6 +1486,7 @@ def test_header_task_switch_to_video_task_shows_demo_phase(monkeypatch):
1178
  phase_after_switch = _read_phase_visibility(page)
1179
  assert phase_after_switch["videoPhase"] is True
1180
  assert phase_after_switch["video"] is True
 
1181
  assert phase_after_switch["actionPhase"] is False
1182
  assert phase_after_switch["controlPhase"] is False
1183
  assert phase_after_switch["currentSrc"]
@@ -1187,14 +1496,18 @@ def test_header_task_switch_to_video_task_shows_demo_phase(monkeypatch):
1187
  assert switch_calls == [("uid-header-video", "VideoPlaceButton")]
1188
  assert _read_header_task_value(page) == "VideoPlaceButton"
1189
 
1190
- did_dispatch_end = page.evaluate(
 
1191
  """() => {
1192
- const videoEl = document.querySelector('#demo_video video');
1193
- if (!videoEl) return false;
1194
- videoEl.dispatchEvent(new Event('ended', { bubbles: true }));
1195
- return true;
1196
- }"""
 
1197
  )
 
 
1198
  assert did_dispatch_end
1199
 
1200
  page.wait_for_function(
@@ -1208,6 +1521,7 @@ def test_header_task_switch_to_video_task_shows_demo_phase(monkeypatch):
1208
  return (
1209
  !visible('video_phase_group') &&
1210
  !visible('demo_video') &&
 
1211
  visible('action_phase_group') &&
1212
  visible('control_panel_group') &&
1213
  visible('live_obs') &&
@@ -1220,6 +1534,7 @@ def test_header_task_switch_to_video_task_shows_demo_phase(monkeypatch):
1220
  phase_after_end = _read_phase_visibility(page)
1221
  assert phase_after_end["videoPhase"] is False
1222
  assert phase_after_end["video"] is False
 
1223
  assert phase_after_end["actionPhase"] is True
1224
  assert phase_after_end["action"] is True
1225
  assert phase_after_end["controlPhase"] is True
@@ -1234,6 +1549,7 @@ def test_header_task_switch_to_video_task_shows_demo_phase(monkeypatch):
1234
 
1235
  def test_phase_machine_runtime_local_video_path_end_transition():
1236
  import gradio_callbacks as cb
 
1237
 
1238
  demo_video_path = gr.get_video("world.mp4")
1239
  fake_obs = np.zeros((24, 24, 3), dtype=np.uint8)
@@ -1275,9 +1591,20 @@ def test_phase_machine_runtime_local_video_path_end_transition():
1275
  try:
1276
  with gr.Blocks(title="Native phase machine local video test") as demo:
1277
  uid_state = gr.State(value="uid-local-video")
 
 
 
 
 
1278
  with gr.Column(visible=False, elem_id="main_interface") as main_interface:
1279
  with gr.Column(visible=False, elem_id="video_phase_group") as video_phase_group:
1280
  video_display = gr.Video(value=None, elem_id="demo_video", autoplay=False)
 
 
 
 
 
 
1281
 
1282
  with gr.Column(visible=True, elem_id="action_phase_group") as action_phase_group:
1283
  img_display = gr.Image(value=fake_obs.copy(), elem_id="live_obs")
@@ -1317,6 +1644,7 @@ def test_phase_machine_runtime_local_video_path_end_transition():
1317
  goal_box,
1318
  coords_box,
1319
  video_display,
 
1320
  task_info_box,
1321
  progress_info_box,
1322
  restart_episode_btn,
@@ -1332,10 +1660,23 @@ def test_phase_machine_runtime_local_video_path_end_transition():
1332
  queue=False,
1333
  )
1334
 
 
 
 
 
 
 
 
1335
  video_display.end(
1336
  fn=cb.on_video_end_transition,
1337
  inputs=[uid_state],
1338
- outputs=[video_phase_group, action_phase_group, control_panel_group, log_output],
 
 
 
 
 
 
1339
  queue=False,
1340
  )
1341
 
@@ -1370,6 +1711,7 @@ def test_phase_machine_runtime_local_video_path_end_transition():
1370
  };
1371
  return {
1372
  video: visible('demo_video'),
 
1373
  action: visible('live_obs'),
1374
  control: visible('action_radio'),
1375
  };
@@ -1377,18 +1719,29 @@ def test_phase_machine_runtime_local_video_path_end_transition():
1377
  )
1378
  assert phase_after_login == {
1379
  "video": True,
 
1380
  "action": False,
1381
  "control": False,
1382
  }
1383
 
1384
- did_dispatch_end = page.evaluate(
 
 
 
 
 
 
 
1385
  """() => {
1386
- const videoEl = document.querySelector('#demo_video video');
1387
- if (!videoEl) return false;
1388
- videoEl.dispatchEvent(new Event('ended', { bubbles: true }));
1389
- return true;
1390
- }"""
 
1391
  )
 
 
1392
  assert did_dispatch_end
1393
 
1394
  page.wait_for_function(
@@ -1399,7 +1752,12 @@ def test_phase_machine_runtime_local_video_path_end_transition():
1399
  const st = getComputedStyle(el);
1400
  return st.display !== 'none' && st.visibility !== 'hidden' && el.getClientRects().length > 0;
1401
  };
1402
- return visible('live_obs') && visible('action_radio') && !visible('demo_video');
 
 
 
 
 
1403
  }""",
1404
  timeout=2000,
1405
  )
 
113
  return {
114
  videoPhase: visible('video_phase_group'),
115
  video: visible('demo_video'),
116
+ watchButton: visible('watch_demo_video_btn'),
117
  actionPhase: visible('action_phase_group'),
118
  action: visible('live_obs'),
119
  controlPhase: visible('control_panel_group'),
 
124
  )
125
 
126
 
127
+ def _read_demo_video_controls(page) -> dict[str, bool | None]:
128
+ return page.evaluate(
129
+ """() => {
130
+ const visible = (id) => {
131
+ const el = document.getElementById(id);
132
+ if (!el) return false;
133
+ const st = getComputedStyle(el);
134
+ return st.display !== 'none' && st.visibility !== 'hidden' && el.getClientRects().length > 0;
135
+ };
136
+ const videoEl = document.querySelector('#demo_video video');
137
+ const button =
138
+ document.querySelector('#watch_demo_video_btn button') ||
139
+ document.querySelector('button#watch_demo_video_btn');
140
+ return {
141
+ videoVisible: visible('demo_video'),
142
+ buttonVisible: visible('watch_demo_video_btn'),
143
+ buttonDisabled: button ? button.disabled : null,
144
+ autoplay: videoEl ? videoEl.autoplay : null,
145
+ paused: videoEl ? videoEl.paused : null,
146
+ };
147
+ }"""
148
+ )
149
+
150
+
151
+ def _click_demo_video_button(page) -> None:
152
+ page.locator("#watch_demo_video_btn button, button#watch_demo_video_btn").first.click()
153
+
154
+
155
+ def _dispatch_video_event(page, event_name: str) -> bool:
156
+ return page.evaluate(
157
+ """(eventName) => {
158
+ const targets = [
159
+ document.querySelector('#demo_video video'),
160
+ document.getElementById('demo_video'),
161
+ ].filter(Boolean);
162
+ if (!targets.length) return false;
163
+ for (const target of targets) {
164
+ target.dispatchEvent(new Event(eventName, { bubbles: true, composed: true }));
165
+ }
166
+ return true;
167
+ }""",
168
+ event_name,
169
+ )
170
+
171
+
172
  def _read_live_obs_geometry(page) -> dict[str, dict[str, float] | None]:
173
  return page.evaluate(
174
  """() => {
 
243
 
244
  @pytest.fixture
245
  def phase_machine_ui_url():
246
+ state = {"precheck_calls": 0, "play_clicks": 0}
247
  demo_video_url = "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
248
  ui_layout = importlib.reload(importlib.import_module("ui_layout"))
249
 
 
256
 
257
  with gr.Column(visible=False, elem_id="main_interface") as main_interface:
258
  with gr.Column(visible=False, elem_id="video_phase_group") as video_phase_group:
259
+ video_display = gr.Video(value=None, elem_id="demo_video", autoplay=False)
260
+ watch_demo_video_btn = gr.Button(
261
+ "Watch Video Input🎬",
262
+ elem_id="watch_demo_video_btn",
263
+ interactive=False,
264
+ visible=False,
265
+ )
266
 
267
  with gr.Column(visible=False, elem_id="action_phase_group") as action_phase_group:
268
  img_display = gr.Image(value=np.zeros((24, 24, 3), dtype=np.uint8), elem_id="live_obs")
 
280
  next_task_btn = gr.Button("Next Task", elem_id="next_task_btn")
281
 
282
  log_output = gr.Markdown("", elem_id="log_output")
283
+ simulate_stop_btn = gr.Button("Simulate Stop", elem_id="simulate_stop_btn")
284
+
285
+ demo.load(
286
+ fn=None,
287
+ js=ui_layout.DEMO_VIDEO_PLAY_BINDING_JS,
288
+ queue=False,
289
+ )
290
 
291
  def login_fn():
292
  return (
 
294
  gr.update(visible=True),
295
  gr.update(visible=True),
296
  gr.update(value=demo_video_url, visible=True),
297
+ gr.update(visible=True, interactive=True),
298
  gr.update(visible=False),
299
  gr.update(visible=False),
300
  gr.update(visible=False),
 
303
  "demo_video",
304
  )
305
 
306
+ def on_play_demo_fn():
307
+ state["play_clicks"] += 1
308
+ return gr.update(visible=True, interactive=False)
309
+
310
+ def on_simulate_stop_fn():
311
+ return "stopped"
312
+
313
  def on_video_end_fn():
314
  return (
315
  gr.update(visible=False),
 
317
  gr.update(visible=True),
318
  gr.update(visible=True),
319
  gr.update(interactive=True),
320
+ gr.update(visible=False, interactive=False),
321
  "action_keypoint",
322
  )
323
 
 
361
  main_interface,
362
  video_phase_group,
363
  video_display,
364
+ watch_demo_video_btn,
365
  action_phase_group,
366
  control_panel_group,
367
  action_buttons_row,
 
372
  queue=False,
373
  )
374
 
375
+ watch_demo_video_btn.click(
376
+ fn=on_play_demo_fn,
377
+ outputs=[watch_demo_video_btn],
378
+ queue=False,
379
+ )
380
+
381
  video_display.end(
382
  fn=on_video_end_fn,
383
  outputs=[
 
386
  control_panel_group,
387
  action_buttons_row,
388
  reference_action_btn,
389
+ watch_demo_video_btn,
390
+ phase_state,
391
+ ],
392
+ queue=False,
393
+ )
394
+ video_display.stop(
395
+ fn=on_video_end_fn,
396
+ outputs=[
397
+ video_phase_group,
398
+ action_phase_group,
399
+ control_panel_group,
400
+ action_buttons_row,
401
+ reference_action_btn,
402
+ watch_demo_video_btn,
403
  phase_state,
404
  ],
405
  queue=False,
406
  )
407
+ simulate_stop_btn.click(
408
+ fn=on_simulate_stop_fn,
409
+ outputs=[log_output],
410
+ js="""() => {
411
+ const show = (id, visible) => {
412
+ const el = document.getElementById(id);
413
+ if (!el) return;
414
+ el.style.display = visible ? '' : 'none';
415
+ };
416
+ show('video_phase_group', false);
417
+ show('demo_video', false);
418
+ show('action_phase_group', true);
419
+ show('live_obs', true);
420
+ show('control_panel_group', true);
421
+ show('action_radio', true);
422
+ show('action_buttons_row', true);
423
+ show('watch_demo_video_btn', false);
424
+ const refBtn =
425
+ document.querySelector('#reference_action_btn button') ||
426
+ document.querySelector('button#reference_action_btn');
427
+ if (refBtn) {
428
+ refBtn.disabled = false;
429
+ }
430
+ return [];
431
+ }""",
432
+ queue=False,
433
+ )
434
 
435
  exec_btn.click(
436
  fn=precheck_fn,
 
543
  };
544
  return {
545
  video: visible('demo_video'),
546
+ watchButton: visible('watch_demo_video_btn'),
547
  action: visible('live_obs'),
548
  control: visible('action_radio'),
549
  };
 
551
  )
552
  assert phase_after_login == {
553
  "video": True,
554
+ "watchButton": True,
555
  "action": False,
556
  "control": False,
557
  }
558
 
559
  page.wait_for_selector("#demo_video video", timeout=5000)
560
+ page.wait_for_function(
561
  """() => {
562
  const videoEl = document.querySelector('#demo_video video');
563
+ const button =
564
+ document.querySelector('#watch_demo_video_btn button') ||
565
+ document.querySelector('button#watch_demo_video_btn');
566
+ return !!videoEl && !!videoEl.currentSrc && !!button && button.disabled === false && videoEl.paused === true;
567
+ }""",
568
+ timeout=10000,
569
  )
570
+ controls_after_login = _read_demo_video_controls(page)
571
+ assert controls_after_login["videoVisible"] is True
572
+ assert controls_after_login["buttonVisible"] is True
573
+ assert controls_after_login["buttonDisabled"] is False
574
+ assert controls_after_login["autoplay"] is False
575
+ assert controls_after_login["paused"] is True
576
+
577
+ _click_demo_video_button(page)
578
+ page.wait_for_function(
579
+ """() => {
580
+ const videoEl = document.querySelector('#demo_video video');
581
+ const button =
582
+ document.querySelector('#watch_demo_video_btn button') ||
583
+ document.querySelector('button#watch_demo_video_btn');
584
+ if (!videoEl || !button) return false;
585
+ return button.disabled === true && (videoEl.paused === false || videoEl.currentTime > 0);
586
+ }""",
587
+ timeout=10000,
588
+ )
589
+ controls_after_click = _read_demo_video_controls(page)
590
+ assert controls_after_click["buttonDisabled"] is True
591
+ assert controls_after_click["paused"] is False
592
+
593
+ did_dispatch_end = _dispatch_video_event(page, "ended")
594
  assert did_dispatch_end
595
 
596
  page.wait_for_function(
597
  """() => {
598
  const action = document.getElementById('live_obs');
599
  const control = document.getElementById('action_radio');
600
+ const watchButton = document.getElementById('watch_demo_video_btn');
601
+ if (!action || !control || !watchButton) return false;
602
+ return (
603
+ getComputedStyle(action).display !== 'none' &&
604
+ getComputedStyle(control).display !== 'none' &&
605
+ getComputedStyle(watchButton).display === 'none'
606
+ );
607
  }"""
608
  )
609
 
 
698
  browser.close()
699
 
700
  assert state["precheck_calls"] >= 2
701
+ assert state["play_clicks"] == 1
702
 
703
 
704
  def test_reference_action_button_is_green_only_when_interactive(phase_machine_ui_url):
 
719
  assert disabled_snapshot["backgroundColor"] != "rgb(31, 139, 76)"
720
 
721
  page.wait_for_selector("#demo_video video", timeout=5000)
722
+ _click_demo_video_button(page)
723
+ page.wait_for_function(
724
  """() => {
725
+ const button =
726
+ document.querySelector('#watch_demo_video_btn button') ||
727
+ document.querySelector('button#watch_demo_video_btn');
728
+ return !!button && button.disabled === true;
729
+ }""",
730
+ timeout=5000,
731
  )
732
+ did_dispatch_end = _dispatch_video_event(page, "ended")
733
  assert did_dispatch_end
734
 
735
  page.wait_for_function(
 
750
  browser.close()
751
 
752
 
753
+ @pytest.mark.xfail(
754
+ reason="Gradio 6.9.0 output video stop path is not reliably triggerable in headless Chromium; transition contract is covered by unit tests.",
755
+ strict=False,
756
+ )
757
+ def test_demo_video_stop_event_transitions_and_hides_button(phase_machine_ui_url):
758
+ root_url, state = phase_machine_ui_url
759
+
760
+ with sync_playwright() as p:
761
+ browser = p.chromium.launch(headless=True)
762
+ page = browser.new_page(viewport={"width": 1280, "height": 900})
763
+ page.goto(root_url, wait_until="domcontentloaded")
764
+
765
+ page.wait_for_timeout(2500)
766
+ page.wait_for_selector("#login_btn", timeout=20000)
767
+ page.click("#login_btn")
768
+ page.wait_for_selector("#demo_video video", timeout=5000)
769
+
770
+ _click_demo_video_button(page)
771
+ page.wait_for_function(
772
+ """() => {
773
+ const button =
774
+ document.querySelector('#watch_demo_video_btn button') ||
775
+ document.querySelector('button#watch_demo_video_btn');
776
+ return !!button && button.disabled === true;
777
+ }""",
778
+ timeout=5000,
779
+ )
780
+
781
+ page.locator("#simulate_stop_btn button, button#simulate_stop_btn").first.click()
782
+
783
+ page.wait_for_function(
784
+ """() => {
785
+ const visible = (id) => {
786
+ const el = document.getElementById(id);
787
+ if (!el) return false;
788
+ const st = getComputedStyle(el);
789
+ return st.display !== 'none' && st.visibility !== 'hidden' && el.getClientRects().length > 0;
790
+ };
791
+ return (
792
+ !visible('watch_demo_video_btn') &&
793
+ !visible('demo_video') &&
794
+ visible('live_obs') &&
795
+ visible('action_radio')
796
+ );
797
+ }""",
798
+ timeout=5000,
799
+ )
800
+
801
+ browser.close()
802
+
803
+ assert state["play_clicks"] == 1
804
+
805
+
806
  def test_unified_loading_overlay_init_flow(monkeypatch):
807
  ui_layout = importlib.reload(importlib.import_module("ui_layout"))
808
 
 
827
  "goal", # goal_box
828
  "No need for coordinates", # coords_box
829
  gr.update(value=None, visible=False), # video_display
830
+ gr.update(visible=False, interactive=False), # watch_demo_video_btn
831
  "PickXtimes (Episode 1)", # task_info_box
832
  "Completed: 0", # progress_info_box
833
  gr.update(interactive=True), # restart_episode_btn
 
897
  assert calls["init"] >= 1
898
 
899
 
900
+ def test_no_video_task_hides_manual_demo_button(monkeypatch):
901
+ ui_layout = importlib.reload(importlib.import_module("ui_layout"))
902
+
903
+ fake_obs = np.zeros((24, 24, 3), dtype=np.uint8)
904
+ fake_obs_img = Image.fromarray(fake_obs)
905
+
906
+ def fake_init_app(_request=None):
907
+ return (
908
+ "uid-no-video",
909
+ gr.update(visible=True), # main_interface
910
+ gr.update(value=fake_obs_img.copy(), interactive=False), # img_display
911
+ "ready", # log_output
912
+ gr.update(choices=[("pick", 0)], value=None), # options_radio
913
+ "goal", # goal_box
914
+ "No need for coordinates", # coords_box
915
+ gr.update(value=None, visible=False), # video_display
916
+ gr.update(visible=False, interactive=False), # watch_demo_video_btn
917
+ "PickXtimes (Episode 1)", # task_info_box
918
+ "Completed: 0", # progress_info_box
919
+ gr.update(interactive=True), # restart_episode_btn
920
+ gr.update(interactive=True), # next_task_btn
921
+ gr.update(interactive=True), # exec_btn
922
+ gr.update(visible=False), # video_phase_group
923
+ gr.update(visible=True), # action_phase_group
924
+ gr.update(visible=True), # control_panel_group
925
+ gr.update(value="hint"), # task_hint_display
926
+ gr.update(visible=False), # loading_overlay
927
+ gr.update(interactive=True), # reference_action_btn
928
+ )
929
+
930
+ monkeypatch.setattr(ui_layout, "init_app", fake_init_app)
931
+
932
+ demo = ui_layout.create_ui_blocks()
933
+
934
+ port = _free_port()
935
+ host = "127.0.0.1"
936
+ root_url = f"http://{host}:{port}/"
937
+
938
+ app = FastAPI(title="native-no-video-test")
939
+ app = gr.mount_gradio_app(app, demo, path="/")
940
+
941
+ config = uvicorn.Config(app, host=host, port=port, log_level="error")
942
+ server = uvicorn.Server(config)
943
+ thread = threading.Thread(target=server.run, daemon=True)
944
+ thread.start()
945
+ _wait_http_ready(root_url)
946
+
947
+ try:
948
+ with sync_playwright() as p:
949
+ browser = p.chromium.launch(headless=True)
950
+ page = browser.new_page(viewport={"width": 1280, "height": 900})
951
+ page.goto(root_url, wait_until="domcontentloaded")
952
+ page.wait_for_selector("#main_interface_root", state="visible", timeout=15000)
953
+
954
+ page.wait_for_function(
955
+ """() => {
956
+ const visible = (id) => {
957
+ const el = document.getElementById(id);
958
+ if (!el) return false;
959
+ const st = getComputedStyle(el);
960
+ return st.display !== 'none' && st.visibility !== 'hidden' && el.getClientRects().length > 0;
961
+ };
962
+ return (
963
+ !visible('video_phase_group') &&
964
+ !visible('demo_video') &&
965
+ !visible('watch_demo_video_btn') &&
966
+ visible('action_phase_group') &&
967
+ visible('control_panel_group')
968
+ );
969
+ }""",
970
+ timeout=5000,
971
+ )
972
+
973
+ phase_snapshot = _read_phase_visibility(page)
974
+ controls_snapshot = _read_demo_video_controls(page)
975
+ assert phase_snapshot["videoPhase"] is False
976
+ assert phase_snapshot["video"] is False
977
+ assert phase_snapshot["watchButton"] is False
978
+ assert phase_snapshot["actionPhase"] is True
979
+ assert phase_snapshot["controlPhase"] is True
980
+ assert controls_snapshot["buttonVisible"] is False
981
+
982
+ browser.close()
983
+ finally:
984
+ server.should_exit = True
985
+ thread.join(timeout=10)
986
+ demo.close()
987
+
988
+
989
  def test_live_obs_client_resize_fills_width_and_keeps_click_mapping(monkeypatch):
990
  callbacks = importlib.reload(importlib.import_module("gradio_callbacks"))
991
  ui_layout = importlib.reload(importlib.import_module("ui_layout"))
 
1014
  interactive=False,
1015
  ), # coords_box
1016
  gr.update(value=None, visible=False), # video_display
1017
+ gr.update(visible=False, interactive=False), # watch_demo_video_btn
1018
  "ResizeEnv (Episode 1)", # task_info_box
1019
  "Completed: 0", # progress_info_box
1020
  gr.update(interactive=True), # restart_episode_btn
 
1243
  "goal", # goal_box
1244
  "No need for coordinates", # coords_box
1245
  gr.update(value=None, visible=False), # video_display
1246
+ gr.update(visible=False, interactive=False), # watch_demo_video_btn
1247
  "PickXtimes (Episode 1)", # task_info_box
1248
  "Completed: 0", # progress_info_box
1249
  gr.update(interactive=True), # restart_episode_btn
 
1319
  "goal", # goal_box
1320
  "No need for coordinates", # coords_box
1321
  gr.update(value=None, visible=False), # video_display
1322
+ gr.update(visible=False, interactive=False), # watch_demo_video_btn
1323
  task_info_text, # task_info_box
1324
  "Completed: 0", # progress_info_box
1325
  gr.update(interactive=True), # restart_episode_btn
 
1391
  "video goal" if show_video else "goal", # goal_box
1392
  "No need for coordinates", # coords_box
1393
  gr.update(value=demo_video_path if show_video else None, visible=show_video), # video_display
1394
+ gr.update(visible=show_video, interactive=show_video), # watch_demo_video_btn
1395
  f"{task_name} (Episode 1)", # task_info_box
1396
  "Completed: 0", # progress_info_box
1397
  gr.update(interactive=True), # restart_episode_btn
 
1464
  return st.display !== 'none' && st.visibility !== 'hidden' && el.getClientRects().length > 0;
1465
  };
1466
  const videoEl = document.querySelector('#demo_video video');
1467
+ const button =
1468
+ document.querySelector('#watch_demo_video_btn button') ||
1469
+ document.querySelector('button#watch_demo_video_btn');
1470
  return (
1471
  visible('video_phase_group') &&
1472
  visible('demo_video') &&
1473
+ visible('watch_demo_video_btn') &&
1474
  !visible('action_phase_group') &&
1475
  !visible('control_panel_group') &&
1476
+ !!(videoEl && videoEl.currentSrc) &&
1477
+ !!button &&
1478
+ button.disabled === false &&
1479
+ videoEl.paused === true &&
1480
+ videoEl.autoplay === false
1481
  );
1482
  }""",
1483
  timeout=10000,
 
1486
  phase_after_switch = _read_phase_visibility(page)
1487
  assert phase_after_switch["videoPhase"] is True
1488
  assert phase_after_switch["video"] is True
1489
+ assert phase_after_switch["watchButton"] is True
1490
  assert phase_after_switch["actionPhase"] is False
1491
  assert phase_after_switch["controlPhase"] is False
1492
  assert phase_after_switch["currentSrc"]
 
1496
  assert switch_calls == [("uid-header-video", "VideoPlaceButton")]
1497
  assert _read_header_task_value(page) == "VideoPlaceButton"
1498
 
1499
+ _click_demo_video_button(page)
1500
+ page.wait_for_function(
1501
  """() => {
1502
+ const button =
1503
+ document.querySelector('#watch_demo_video_btn button') ||
1504
+ document.querySelector('button#watch_demo_video_btn');
1505
+ return !!button && button.disabled === true;
1506
+ }""",
1507
+ timeout=5000,
1508
  )
1509
+
1510
+ did_dispatch_end = _dispatch_video_event(page, "ended")
1511
  assert did_dispatch_end
1512
 
1513
  page.wait_for_function(
 
1521
  return (
1522
  !visible('video_phase_group') &&
1523
  !visible('demo_video') &&
1524
+ !visible('watch_demo_video_btn') &&
1525
  visible('action_phase_group') &&
1526
  visible('control_panel_group') &&
1527
  visible('live_obs') &&
 
1534
  phase_after_end = _read_phase_visibility(page)
1535
  assert phase_after_end["videoPhase"] is False
1536
  assert phase_after_end["video"] is False
1537
+ assert phase_after_end["watchButton"] is False
1538
  assert phase_after_end["actionPhase"] is True
1539
  assert phase_after_end["action"] is True
1540
  assert phase_after_end["controlPhase"] is True
 
1549
 
1550
  def test_phase_machine_runtime_local_video_path_end_transition():
1551
  import gradio_callbacks as cb
1552
+ ui_layout = importlib.reload(importlib.import_module("ui_layout"))
1553
 
1554
  demo_video_path = gr.get_video("world.mp4")
1555
  fake_obs = np.zeros((24, 24, 3), dtype=np.uint8)
 
1591
  try:
1592
  with gr.Blocks(title="Native phase machine local video test") as demo:
1593
  uid_state = gr.State(value="uid-local-video")
1594
+ demo.load(
1595
+ fn=None,
1596
+ js=ui_layout.DEMO_VIDEO_PLAY_BINDING_JS,
1597
+ queue=False,
1598
+ )
1599
  with gr.Column(visible=False, elem_id="main_interface") as main_interface:
1600
  with gr.Column(visible=False, elem_id="video_phase_group") as video_phase_group:
1601
  video_display = gr.Video(value=None, elem_id="demo_video", autoplay=False)
1602
+ watch_demo_video_btn = gr.Button(
1603
+ "Watch Video Input🎬",
1604
+ elem_id="watch_demo_video_btn",
1605
+ interactive=False,
1606
+ visible=False,
1607
+ )
1608
 
1609
  with gr.Column(visible=True, elem_id="action_phase_group") as action_phase_group:
1610
  img_display = gr.Image(value=fake_obs.copy(), elem_id="live_obs")
 
1644
  goal_box,
1645
  coords_box,
1646
  video_display,
1647
+ watch_demo_video_btn,
1648
  task_info_box,
1649
  progress_info_box,
1650
  restart_episode_btn,
 
1660
  queue=False,
1661
  )
1662
 
1663
+ watch_demo_video_btn.click(
1664
+ fn=cb.on_demo_video_play,
1665
+ inputs=[uid_state],
1666
+ outputs=[watch_demo_video_btn],
1667
+ queue=False,
1668
+ )
1669
+
1670
  video_display.end(
1671
  fn=cb.on_video_end_transition,
1672
  inputs=[uid_state],
1673
+ outputs=[
1674
+ video_phase_group,
1675
+ action_phase_group,
1676
+ control_panel_group,
1677
+ log_output,
1678
+ watch_demo_video_btn,
1679
+ ],
1680
  queue=False,
1681
  )
1682
 
 
1711
  };
1712
  return {
1713
  video: visible('demo_video'),
1714
+ watchButton: visible('watch_demo_video_btn'),
1715
  action: visible('live_obs'),
1716
  control: visible('action_radio'),
1717
  };
 
1719
  )
1720
  assert phase_after_login == {
1721
  "video": True,
1722
+ "watchButton": True,
1723
  "action": False,
1724
  "control": False,
1725
  }
1726
 
1727
+ controls_after_login = _read_demo_video_controls(page)
1728
+ assert controls_after_login["buttonVisible"] is True
1729
+ assert controls_after_login["buttonDisabled"] is False
1730
+ assert controls_after_login["autoplay"] is False
1731
+ assert controls_after_login["paused"] is True
1732
+
1733
+ _click_demo_video_button(page)
1734
+ page.wait_for_function(
1735
  """() => {
1736
+ const button =
1737
+ document.querySelector('#watch_demo_video_btn button') ||
1738
+ document.querySelector('button#watch_demo_video_btn');
1739
+ return !!button && button.disabled === true;
1740
+ }""",
1741
+ timeout=5000,
1742
  )
1743
+
1744
+ did_dispatch_end = _dispatch_video_event(page, "ended")
1745
  assert did_dispatch_end
1746
 
1747
  page.wait_for_function(
 
1752
  const st = getComputedStyle(el);
1753
  return st.display !== 'none' && st.visibility !== 'hidden' && el.getClientRects().length > 0;
1754
  };
1755
+ return (
1756
+ visible('live_obs') &&
1757
+ visible('action_radio') &&
1758
+ !visible('demo_video') &&
1759
+ !visible('watch_demo_video_btn')
1760
+ );
1761
  }""",
1762
  timeout=2000,
1763
  )
gradio-web/test/test_ui_text_config.py CHANGED
@@ -10,12 +10,12 @@ class _FakeOptionSession:
10
 
11
 
12
  class _FakeLoadSession:
13
- def __init__(self, env_id, available_options, raw_solve_options):
14
  self.env_id = env_id
15
  self.available_options = available_options
16
  self.raw_solve_options = raw_solve_options
17
- self.language_goal = ""
18
- self.demonstration_frames = []
19
 
20
  def load_episode(self, env_id, episode_idx):
21
  self.env_id = env_id
@@ -67,6 +67,29 @@ def test_on_video_end_transition_uses_configured_action_prompt(monkeypatch, relo
67
  result = callbacks.on_video_end_transition("uid-1")
68
 
69
  assert result[3] == "choose an action from config"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
 
72
  def test_missing_session_paths_use_configured_session_error(monkeypatch, reload_module):
@@ -90,8 +113,8 @@ def test_get_ui_action_text_uses_configured_overrides_and_fallback(reload_module
90
  config = reload_module("config")
91
 
92
  patternlock_expected = {
93
- "move forward": "move forward",
94
- "move backward": "move backward",
95
  "move left": "move left→",
96
  "move right": "move right←",
97
  "move forward-left": "move forward-left↘︎",
@@ -100,10 +123,10 @@ def test_get_ui_action_text_uses_configured_overrides_and_fallback(reload_module
100
  "move backward-right": "move backward-right↖︎",
101
  }
102
  routestick_expected = {
103
- "move to the nearest left target by circling around the stick clockwise": "move left clockwise↘︎→↗︎",
104
- "move to the nearest right target by circling around the stick clockwise": "move right clockwise↖︎←↙︎",
105
- "move to the nearest left target by circling around the stick counterclockwise": "move left counterclockwise↗︎→↘︎",
106
- "move to the nearest right target by circling around the stick counterclockwise": "move right counterclockwise︎←︎",
107
  }
108
 
109
  for raw_action, expected in patternlock_expected.items():
@@ -121,7 +144,7 @@ def test_ui_option_label_uses_patternlock_configured_action_text(reload_module):
121
  raw_solve_options=[{"label": "a", "action": "move forward", "available": False}],
122
  )
123
 
124
- assert callbacks._ui_option_label(session, "fallback", 0) == "a. move forward"
125
 
126
 
127
  def test_ui_option_label_uses_routestick_configured_action_text(reload_module):
@@ -138,7 +161,7 @@ def test_ui_option_label_uses_routestick_configured_action_text(reload_module):
138
  ],
139
  )
140
 
141
- assert callbacks._ui_option_label(session, "fallback", 0) == "d. move right counterclockwise︎←︎"
142
 
143
 
144
  def test_load_status_task_appends_configured_keypoint_suffix_after_mapped_label(monkeypatch, reload_module):
@@ -165,12 +188,81 @@ def test_load_status_task_appends_configured_keypoint_suffix_after_mapped_label(
165
 
166
  assert result[4]["choices"] == [
167
  (
168
- f"a. move forward{config.UI_TEXT['actions']['keypoint_required_suffix']}",
169
  0,
170
  )
171
  ]
172
 
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  def test_draw_coordinate_axes_uses_configured_routestick_overlay_labels(monkeypatch, reload_module):
175
  config = reload_module("config")
176
  image_utils = reload_module("image_utils")
 
10
 
11
 
12
  class _FakeLoadSession:
13
+ def __init__(self, env_id, available_options, raw_solve_options, demonstration_frames=None, language_goal=""):
14
  self.env_id = env_id
15
  self.available_options = available_options
16
  self.raw_solve_options = raw_solve_options
17
+ self.language_goal = language_goal
18
+ self.demonstration_frames = demonstration_frames or []
19
 
20
  def load_episode(self, env_id, episode_idx):
21
  self.env_id = env_id
 
67
  result = callbacks.on_video_end_transition("uid-1")
68
 
69
  assert result[3] == "choose an action from config"
70
+ assert result[4]["visible"] is False
71
+ assert result[4]["interactive"] is False
72
+
73
+
74
+ def test_on_demo_video_play_disables_button_and_sets_single_use_state(monkeypatch, reload_module):
75
+ reload_module("config")
76
+ callbacks = reload_module("gradio_callbacks")
77
+ recorded = {"activity": [], "clicked": []}
78
+
79
+ monkeypatch.setattr(callbacks, "update_session_activity", lambda uid: recorded["activity"].append(uid))
80
+ monkeypatch.setattr(callbacks, "get_play_button_clicked", lambda uid: False)
81
+ monkeypatch.setattr(
82
+ callbacks,
83
+ "set_play_button_clicked",
84
+ lambda uid, clicked=True: recorded["clicked"].append((uid, clicked)),
85
+ )
86
+
87
+ result = callbacks.on_demo_video_play("uid-play")
88
+
89
+ assert recorded["activity"] == ["uid-play"]
90
+ assert recorded["clicked"] == [("uid-play", True)]
91
+ assert result["visible"] is True
92
+ assert result["interactive"] is False
93
 
94
 
95
  def test_missing_session_paths_use_configured_session_error(monkeypatch, reload_module):
 
113
  config = reload_module("config")
114
 
115
  patternlock_expected = {
116
+ "move forward": "move forward",
117
+ "move backward": "move backward",
118
  "move left": "move left→",
119
  "move right": "move right←",
120
  "move forward-left": "move forward-left↘︎",
 
123
  "move backward-right": "move backward-right↖︎",
124
  }
125
  routestick_expected = {
126
+ "move to the nearest left target by circling around the stick clockwise": "move left clockwise↘︎→↗︎ ◟→◞",
127
+ "move to the nearest right target by circling around the stick clockwise": "move right clockwise↖︎←↙︎ ◟←◞",
128
+ "move to the nearest left target by circling around the stick counterclockwise": "move left counterclockwise↗︎→↘︎ ◜→◝",
129
+ "move to the nearest right target by circling around the stick counterclockwise": "move right counterclockwise︎← ◜←◝",
130
  }
131
 
132
  for raw_action, expected in patternlock_expected.items():
 
144
  raw_solve_options=[{"label": "a", "action": "move forward", "available": False}],
145
  )
146
 
147
+ assert callbacks._ui_option_label(session, "fallback", 0) == "a. move forward"
148
 
149
 
150
  def test_ui_option_label_uses_routestick_configured_action_text(reload_module):
 
161
  ],
162
  )
163
 
164
+ assert callbacks._ui_option_label(session, "fallback", 0) == "d. move right counterclockwise︎← ◜←◝"
165
 
166
 
167
  def test_load_status_task_appends_configured_keypoint_suffix_after_mapped_label(monkeypatch, reload_module):
 
188
 
189
  assert result[4]["choices"] == [
190
  (
191
+ f"a. move forward{config.UI_TEXT['actions']['keypoint_required_suffix']}",
192
  0,
193
  )
194
  ]
195
 
196
 
197
+ def test_load_status_task_shows_demo_video_button_for_valid_video(monkeypatch, reload_module, tmp_path):
198
+ callbacks = reload_module("gradio_callbacks")
199
+ session = _FakeLoadSession(
200
+ env_id="VideoUnmask",
201
+ available_options=[("pick", 0)],
202
+ raw_solve_options=[{"label": "a", "action": "pick", "available": False}],
203
+ demonstration_frames=["frame-1"],
204
+ language_goal="remember the cube",
205
+ )
206
+ video_path = tmp_path / "demo.mp4"
207
+ video_path.write_bytes(b"demo")
208
+
209
+ monkeypatch.setattr(callbacks, "get_session", lambda uid: session)
210
+ monkeypatch.setattr(callbacks, "reset_play_button_clicked", lambda uid: None)
211
+ monkeypatch.setattr(callbacks, "reset_execute_count", lambda uid, env_id, episode_idx: None)
212
+ monkeypatch.setattr(callbacks, "set_task_start_time", lambda uid, env_id, episode_idx, start_time: None)
213
+ monkeypatch.setattr(callbacks, "set_ui_phase", lambda uid, phase: None)
214
+ monkeypatch.setattr(callbacks, "get_task_hint", lambda env_id: "")
215
+ monkeypatch.setattr(callbacks, "should_show_demo_video", lambda env_id: True)
216
+ monkeypatch.setattr(callbacks, "save_video", lambda frames, suffix="": str(video_path))
217
+
218
+ result = callbacks._load_status_task(
219
+ "uid-video",
220
+ {"current_task": {"env_id": "VideoUnmask", "episode_idx": 1}, "completed_count": 0},
221
+ )
222
+
223
+ assert result[7]["visible"] is True
224
+ assert result[7]["value"] == str(video_path)
225
+ assert result[8]["visible"] is True
226
+ assert result[8]["interactive"] is True
227
+ assert result[14]["visible"] is True
228
+ assert result[15]["visible"] is False
229
+ assert result[16]["visible"] is False
230
+ assert callbacks.UI_TEXT["log"]["demo_video_prompt"] in result[3]
231
+
232
+
233
+ def test_load_status_task_hides_demo_video_button_when_video_is_missing(monkeypatch, reload_module):
234
+ callbacks = reload_module("gradio_callbacks")
235
+ session = _FakeLoadSession(
236
+ env_id="VideoUnmask",
237
+ available_options=[("pick", 0)],
238
+ raw_solve_options=[{"label": "a", "action": "pick", "available": False}],
239
+ demonstration_frames=["frame-1"],
240
+ language_goal="remember the cube",
241
+ )
242
+
243
+ monkeypatch.setattr(callbacks, "get_session", lambda uid: session)
244
+ monkeypatch.setattr(callbacks, "reset_play_button_clicked", lambda uid: None)
245
+ monkeypatch.setattr(callbacks, "reset_execute_count", lambda uid, env_id, episode_idx: None)
246
+ monkeypatch.setattr(callbacks, "set_task_start_time", lambda uid, env_id, episode_idx, start_time: None)
247
+ monkeypatch.setattr(callbacks, "set_ui_phase", lambda uid, phase: None)
248
+ monkeypatch.setattr(callbacks, "get_task_hint", lambda env_id: "")
249
+ monkeypatch.setattr(callbacks, "should_show_demo_video", lambda env_id: True)
250
+ monkeypatch.setattr(callbacks, "save_video", lambda frames, suffix="": None)
251
+
252
+ result = callbacks._load_status_task(
253
+ "uid-no-video",
254
+ {"current_task": {"env_id": "VideoUnmask", "episode_idx": 1}, "completed_count": 0},
255
+ )
256
+
257
+ assert result[7]["visible"] is False
258
+ assert result[8]["visible"] is False
259
+ assert result[8]["interactive"] is False
260
+ assert result[14]["visible"] is False
261
+ assert result[15]["visible"] is True
262
+ assert result[16]["visible"] is True
263
+ assert callbacks.UI_TEXT["log"]["action_selection_prompt"] in result[3]
264
+
265
+
266
  def test_draw_coordinate_axes_uses_configured_routestick_overlay_labels(monkeypatch, reload_module):
267
  config = reload_module("config")
268
  image_utils = reload_module("image_utils")
gradio-web/ui_layout.py CHANGED
@@ -21,6 +21,7 @@ from gradio_callbacks import (
21
  init_app,
22
  load_next_task_wrapper,
23
  on_map_click,
 
24
  on_option_select,
25
  on_reference_action,
26
  on_video_end_transition,
@@ -45,6 +46,42 @@ PHASE_EXECUTION_PLAYBACK = "execution_playback"
45
  SYNC_JS = ""
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  LIVE_OBS_CLIENT_RESIZE_JS = r"""
49
  () => {
50
  if (window.__robommeLiveObsResizerInstalled) {
@@ -256,6 +293,12 @@ button#reference_action_btn:not(:disabled):hover {{
256
  #live_obs.live-obs-resizable .upload-container {{
257
  width: 100%;
258
  }}
 
 
 
 
 
 
259
  """
260
 
261
 
@@ -286,12 +329,12 @@ def _phase_from_updates(main_interface_update, video_phase_update):
286
 
287
 
288
  def _with_phase_from_load(load_result):
289
- phase = _phase_from_updates(load_result[1], load_result[13])
290
  return (*load_result, phase)
291
 
292
 
293
  def _skip_load_flow():
294
- return tuple(gr.skip() for _ in range(20))
295
 
296
 
297
  def _phase_visibility_updates(phase):
@@ -377,10 +420,18 @@ def create_ui_blocks():
377
  label="Demonstration Video 🎬",
378
  interactive=False,
379
  elem_id="demo_video",
380
- autoplay=True,
381
  show_label=True,
382
  visible=True,
383
  )
 
 
 
 
 
 
 
 
384
 
385
  with gr.Column(visible=False, elem_id="action_phase_group") as action_phase_group:
386
  img_display = gr.Image(
@@ -484,6 +535,7 @@ def create_ui_blocks():
484
  goal_box,
485
  coords_box,
486
  video_display,
 
487
  task_info_box,
488
  progress_info_box,
489
  restart_episode_btn,
@@ -650,7 +702,13 @@ def create_ui_blocks():
650
  video_display.end(
651
  fn=on_video_end_transition,
652
  inputs=[uid_state],
653
- outputs=[video_phase_group, action_phase_group, control_panel_group, log_output],
 
 
 
 
 
 
654
  queue=False,
655
  show_progress="hidden",
656
  ).then(
@@ -662,7 +720,13 @@ def create_ui_blocks():
662
  video_display.stop(
663
  fn=on_video_end_transition,
664
  inputs=[uid_state],
665
- outputs=[video_phase_group, action_phase_group, control_panel_group, log_output],
 
 
 
 
 
 
666
  queue=False,
667
  show_progress="hidden",
668
  ).then(
@@ -684,6 +748,14 @@ def create_ui_blocks():
684
  outputs=[coords_box, img_display],
685
  )
686
 
 
 
 
 
 
 
 
 
687
  reference_action_btn.click(
688
  fn=on_reference_action,
689
  inputs=[uid_state],
@@ -748,6 +820,12 @@ def create_ui_blocks():
748
  queue=False,
749
  )
750
 
 
 
 
 
 
 
751
  demo.load(
752
  fn=init_app_with_phase,
753
  inputs=[],
 
21
  init_app,
22
  load_next_task_wrapper,
23
  on_map_click,
24
+ on_demo_video_play,
25
  on_option_select,
26
  on_reference_action,
27
  on_video_end_transition,
 
46
  SYNC_JS = ""
47
 
48
 
49
+ DEMO_VIDEO_PLAY_BINDING_JS = r"""
50
+ () => {
51
+ const bindPlayButton = () => {
52
+ const button =
53
+ document.querySelector("#watch_demo_video_btn button") ||
54
+ document.querySelector("button#watch_demo_video_btn");
55
+ if (!button || button.dataset.robommeDemoPlayBound === "1") {
56
+ return;
57
+ }
58
+ button.dataset.robommeDemoPlayBound = "1";
59
+ button.addEventListener("click", () => {
60
+ const videoEl = document.querySelector("#demo_video video");
61
+ if (!videoEl) {
62
+ return;
63
+ }
64
+ const playPromise = videoEl.play();
65
+ if (playPromise && typeof playPromise.catch === "function") {
66
+ playPromise.catch(() => {});
67
+ }
68
+ });
69
+ };
70
+
71
+ if (!window.__robommeDemoPlayBindingInstalled) {
72
+ const observer = new MutationObserver(() => bindPlayButton());
73
+ observer.observe(document.body, {
74
+ childList: true,
75
+ subtree: true,
76
+ });
77
+ window.__robommeDemoPlayBindingInstalled = true;
78
+ }
79
+
80
+ bindPlayButton();
81
+ }
82
+ """
83
+
84
+
85
  LIVE_OBS_CLIENT_RESIZE_JS = r"""
86
  () => {
87
  if (window.__robommeLiveObsResizerInstalled) {
 
293
  #live_obs.live-obs-resizable .upload-container {{
294
  width: 100%;
295
  }}
296
+
297
+ #watch_demo_video_btn,
298
+ #watch_demo_video_btn button,
299
+ button#watch_demo_video_btn {{
300
+ width: 100%;
301
+ }}
302
  """
303
 
304
 
 
329
 
330
 
331
  def _with_phase_from_load(load_result):
332
+ phase = _phase_from_updates(load_result[1], load_result[14])
333
  return (*load_result, phase)
334
 
335
 
336
  def _skip_load_flow():
337
+ return tuple(gr.skip() for _ in range(21))
338
 
339
 
340
  def _phase_visibility_updates(phase):
 
420
  label="Demonstration Video 🎬",
421
  interactive=False,
422
  elem_id="demo_video",
423
+ autoplay=False,
424
  show_label=True,
425
  visible=True,
426
  )
427
+ watch_demo_video_btn = gr.Button(
428
+ "Watch Video Input🎬",
429
+ variant="primary",
430
+ size="lg",
431
+ interactive=False,
432
+ visible=False,
433
+ elem_id="watch_demo_video_btn",
434
+ )
435
 
436
  with gr.Column(visible=False, elem_id="action_phase_group") as action_phase_group:
437
  img_display = gr.Image(
 
535
  goal_box,
536
  coords_box,
537
  video_display,
538
+ watch_demo_video_btn,
539
  task_info_box,
540
  progress_info_box,
541
  restart_episode_btn,
 
702
  video_display.end(
703
  fn=on_video_end_transition,
704
  inputs=[uid_state],
705
+ outputs=[
706
+ video_phase_group,
707
+ action_phase_group,
708
+ control_panel_group,
709
+ log_output,
710
+ watch_demo_video_btn,
711
+ ],
712
  queue=False,
713
  show_progress="hidden",
714
  ).then(
 
720
  video_display.stop(
721
  fn=on_video_end_transition,
722
  inputs=[uid_state],
723
+ outputs=[
724
+ video_phase_group,
725
+ action_phase_group,
726
+ control_panel_group,
727
+ log_output,
728
+ watch_demo_video_btn,
729
+ ],
730
  queue=False,
731
  show_progress="hidden",
732
  ).then(
 
748
  outputs=[coords_box, img_display],
749
  )
750
 
751
+ watch_demo_video_btn.click(
752
+ fn=on_demo_video_play,
753
+ inputs=[uid_state],
754
+ outputs=[watch_demo_video_btn],
755
+ queue=False,
756
+ show_progress="hidden",
757
+ )
758
+
759
  reference_action_btn.click(
760
  fn=on_reference_action,
761
  inputs=[uid_state],
 
820
  queue=False,
821
  )
822
 
823
+ demo.load(
824
+ fn=None,
825
+ js=DEMO_VIDEO_PLAY_BINDING_JS,
826
+ queue=False,
827
+ )
828
+
829
  demo.load(
830
  fn=init_app_with_phase,
831
  inputs=[],