fix(studio): 回放视图加一个无条件「停止」(Esc 同效),并让「文件一帧都不出」直接报错而不是一直等

- What: `ToolbarWidget` 新增 `set_reset_role("reset"/"stop")`,回放视图把轨道第二个按钮变成「停止」;
  `StudioMainWindow._stop_replay_activity()` 一次停掉回放(play_timer)与「从视频重算」通道(worker),
  并清掉三维面板上的重算画面,状态栏说明"已停止了什么";`_on_reset`/`_on_stop_shortcut`(Esc) 按角色路由;
  `_start_playback`/`_pause_playback` 结束时调用 `_update_run_enabled()`,让停止按钮在有东西可停时才可用;
  两个采集 worker 新增 `FILE_FIRST_FRAME_TIMEOUT_S=10s`:文件源一帧都没出来就报错结束会话
  (原先 `saw_frame` 为假时 EOF 分支永不执行,循环空转到天荒地老、窗口一直停在"连接中")。
- Why: 用户报告"视频播放完之后无法再次按回放,卡住了",并建议加手动停止。复现得到两种状态:
  ① 普通回放播到末尾后再按回放**是可以**重新从头播的(探针实测 0.967→0→0.371,计时器在跑),
  不是这条路径的问题;② 「从视频重算」通道跑文件时,工作线程可能既不出帧也不结束
  (实测:1.5 s 的合成视频跑 25 s,packets=0 且 is_running=True),于是界面永远停在运行/连接状态,
  操作者看到"卡住"。所以既给一个无论什么状态都能按的停止,也让"读不出帧"变成一条明确的错误。
- Verification: 新增/更新用例:`test_worker_frame_rate.py` 的"文件一帧都不出→error+消息"(缩短时限跑,
  真实 worker);`test_ui_smoke.py` 三项:回放视图的停止按钮(播放中可用、按下停回放、重算中停下重算、
  无事可停时说明)、Esc 同样能停、实时视图仍是「重置」;apps/studio 全量通过;shared 全量通过;
  ui_check 583 项 0 失败(新增回放停止的 5 项检查,并给"场景已加载却仍在显示消息"那条加了诊断细节,
  它此前偶发失败过一次、这次跑通)。
- Not in this change: 普通回放"播到末尾→再按回放"的路径(实测正常,未改);让回放直接播放"非记录"的视频
  (仍走「从视频重算」);`_recompute_running` 与 worker 真实状态的进一步合并(现在停止按钮同时看两者)。
This commit is contained in:
ykcheungab
2026-09-14 17:40:49 +08:00
parent a26f77bb67
commit eb1dfbce8a
9 changed files with 302 additions and 4 deletions
+54 -2
View File
@@ -1033,6 +1033,9 @@ class StudioMainWindow(QMainWindow):
self._play_timer.start()
self.toolbar.set_run_checked(True)
self.toolbar.refresh_labels()
# The stop button appears with the playback: one place decides what the rail
# offers, so it is told here rather than kept in step in two places.
self._update_run_enabled()
def _pause_playback(self) -> None:
if self._play_timer.isActive():
@@ -1040,6 +1043,7 @@ class StudioMainWindow(QMainWindow):
if self.toolbar.btn_run.isChecked():
self.toolbar.set_run_checked(False)
self.toolbar.refresh_labels()
self._update_run_enabled()
def _on_play_tick(self) -> None:
"""Advance the playhead from the wall clock, not from the tick count."""
@@ -1486,8 +1490,10 @@ class StudioMainWindow(QMainWindow):
self._pause_playback()
self.toolbar.set_source_view("replay" if preset == "replay" else "live")
# The rail's primary button is this view's main action: acquire while
# measuring, play back while replaying.
# measuring, play back while replaying; and the second button is that view's
# own reset — or, in the replay view, an unconditional stop.
self.toolbar.set_run_role("playback" if preset == "replay" else "acquire")
self.toolbar.set_reset_role("stop" if preset == "replay" else "reset")
self._apply_panel_visibility()
if apply_sizes and preset in VIEW_SPLITTER_SIZES:
self.content_splitter.setSizes(self._splitter_sizes_for(preset))
@@ -1529,6 +1535,14 @@ class StudioMainWindow(QMainWindow):
reason = t("toolbar.playback_disabled")
self.toolbar.set_run_tooltip_reason(reason)
self.toolbar.set_run_enabled(loaded or self._play_timer.isActive())
# The stop button is offered whenever there is something to stop; the
# operator must never have to guess whether that is the case.
if self.toolbar.reset_role() == "stop":
self.toolbar.set_reset_enabled(
self._play_timer.isActive()
or self._recompute_running
or self._session_active
)
self.toolbar.set_record_enabled(
self._running or self.recorder.is_recording
)
@@ -2272,7 +2286,16 @@ class StudioMainWindow(QMainWindow):
self.toolbar.btn_run.setChecked(not self.toolbar.btn_run.isChecked())
def _on_stop_shortcut(self) -> None:
"""Esc must always be able to cancel a session, even a stuck connect."""
"""Esc must always be able to cancel a session, even a stuck connect.
In the replay view it is the same escape hatch as the rail's 停止 button: a
replay or a recompute pass that will not stop by itself has to be stoppable
from the keyboard.
"""
if self.toolbar.run_role() == "playback":
if self._play_timer.isActive() or self._session_active:
self._stop_replay_activity()
return
if not self._session_active:
return
self._stop_run(reason="user_stop")
@@ -2576,7 +2599,36 @@ class StudioMainWindow(QMainWindow):
else:
self.statusBar().showMessage(t("status.run_stopped"))
def _stop_replay_activity(self) -> None:
"""Stop whatever the replay view is doing. The operator's escape hatch.
Two different things can be running there — plain playback of the recorded
timeline, and a recompute pass over the video — and the person in front of the
screen should not have to work out which one is stuck before they can stop it.
So this stops both, clears a recomputed 3D picture, and says what it stopped.
Safe to press when nothing is running (it says so rather than doing nothing
silently, which is what made a stuck state feel stuck).
"""
stopped: list[str] = []
if self._recompute_running or self._session_active:
self._stop_run(reason="user_stop")
stopped.append(t("toolbar.stopped_pass"))
if self._play_timer.isActive():
self._pause_playback()
stopped.append(t("toolbar.stopped_playback"))
self.mesh3d_panel.show_reference_only()
self._update_run_enabled()
if stopped:
self._notify(t("toolbar.stopped", what=", ".join(stopped)))
else:
self._notify(t("toolbar.nothing_to_stop"))
def _on_reset(self) -> None:
if self.toolbar.reset_role() == "stop":
# In the replay view that button is the operator's way out of a replay or
# a recompute pass, whatever state the transport is in.
self._stop_replay_activity()
return
if self._session_phase != "running" or self._active_worker is None:
return
try:
+30 -1
View File
@@ -107,6 +107,7 @@ class ToolbarWidget(QWidget):
self._video_path = ""
#: ``acquire`` while measuring, ``playback`` while replaying a recording.
self._run_role = "acquire"
self._reset_role = "reset"
outer = QVBoxLayout(self)
outer.setContentsMargins(2, 2, 2, 2)
@@ -387,6 +388,34 @@ class ToolbarWidget(QWidget):
def run_role(self) -> str:
return self._run_role
def set_reset_role(self, role: str) -> None:
"""What the second rail button does: zero the tracker, or stop the replay.
The replay view needs a *stop* the operator can always reach. The transport
button is a play/pause toggle, which is ambiguous precisely when something is
wrong (is it playing? is a recompute pass running?), so the second button —
disabled in that view otherwise — becomes an unconditional stop instead of a
second thing to reason about.
"""
wanted = "stop" if str(role) == "stop" else "reset"
if wanted == self._reset_role:
return
self._reset_role = wanted
if wanted == "reset":
self.btn_reset.setEnabled(False)
self._refresh_reset_label()
def reset_role(self) -> str:
return self._reset_role
def _refresh_reset_label(self) -> None:
if self._reset_role == "stop":
self.btn_reset.setText(t("toolbar.stop_playback"))
self.btn_reset.setToolTip(t("toolbar.stop_playback_tip"))
return
self.btn_reset.setText(t("toolbar.reset"))
self.btn_reset.setToolTip("")
def source_view(self) -> str:
return self._source_view
@@ -686,7 +715,7 @@ class ToolbarWidget(QWidget):
self.btn_use_camera.setText(t("toolbar.use_camera"))
self.btn_replay_video.setText(t("toolbar.choose_video"))
self.refresh_labels()
self.btn_reset.setText(t("toolbar.reset"))
self._refresh_reset_label()
self.units_label.setText(t("toolbar.units"))
self.units_combo.setItemText(0, t("toolbar.units_newton"))
self.units_combo.setItemText(1, t("toolbar.units_raw"))