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
+92
View File
@@ -1994,6 +1994,98 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video(
assert not window.toolbar.btn_run.isChecked(), "the transport stayed checked"
def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_recording):
"""The operator must not have to work out *what* is stuck before stopping it.
The transport is a play/pause toggle, which is ambiguous exactly when something is
wrong. So the rail's second button becomes an unconditional stop in this view, and
it covers both things that can run there: plain playback and a recompute pass.
"""
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert window._load_recording(replay_recording)
QApplication.processEvents()
assert window.toolbar.reset_role() == "stop", (
"the replay view kept the tracker reset button instead of a stop"
)
assert window.toolbar.btn_reset.text() == t("toolbar.stop_playback")
assert not window.toolbar.btn_reset.isEnabled(), (
"a stop is offered while nothing is running"
)
# Plain playback: the stop button appears while it plays and pressing it stops.
window.toolbar.btn_run.setChecked(True)
QApplication.processEvents()
assert window._play_timer.isActive()
assert window.toolbar.btn_reset.isEnabled(), "no stop offered while playing"
window.toolbar.btn_reset.click()
QApplication.processEvents()
assert not window._play_timer.isActive(), "the stop did not stop the playback"
assert not window.toolbar.btn_run.isChecked(), "the transport still shows playing"
assert t("toolbar.stopped_playback") in window.statusBar().currentMessage(), (
window.statusBar().currentMessage()
)
# A recompute pass: the same button stops it, without stopping the recording.
stopped: list = []
window._stop_run = lambda **kwargs: stopped.append(kwargs.get("reason"))
window._recompute_running = True
window._update_run_enabled()
assert window.toolbar.btn_reset.isEnabled(), "no stop offered while a pass runs"
window.toolbar.btn_reset.click()
QApplication.processEvents()
assert stopped == ["user_stop"], stopped
assert t("toolbar.stopped_pass") in window.statusBar().currentMessage(), (
window.statusBar().currentMessage()
)
# Nothing running: it says so instead of doing nothing at all.
# (The stubbed ``_stop_run`` above records the reason but does not clear the flag
# the real one clears, so the state is completed by hand here.)
window._recompute_running = False
window.toolbar.btn_reset.click()
QApplication.processEvents()
assert t("toolbar.nothing_to_stop") in window.statusBar().currentMessage(), (
window.statusBar().currentMessage()
)
window.close()
def test_escape_stops_the_replay_too(make_window, replay_recording):
"""Esc is the documented way out; in the replay view it stops the replay."""
from PyQt5.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert window._load_recording(replay_recording)
QApplication.processEvents()
window.toolbar.btn_run.setChecked(True)
QApplication.processEvents()
assert window._play_timer.isActive()
window.act_stop.trigger()
QApplication.processEvents()
assert not window._play_timer.isActive(), "Esc did not stop the playback"
assert not window.toolbar.btn_run.isChecked()
window.close()
def test_the_live_view_keeps_the_tracker_reset(make_window):
from PyQt5.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_live.click()
QApplication.processEvents()
assert window.toolbar.reset_role() == "reset"
window.close()
def test_a_video_that_is_not_a_recording_is_refused_with_a_reason(
make_window, record_root
):