feat(studio): 回放可以直接播放「一条视频」——不必先按「从视频重算」

- What: 新增 `record/playback.py::VideoOnlySource`(一个视频文件的时间轴:时长来自
  `CAP_PROP_FRAME_COUNT/FPS`,按位置解码、顺序前进时只 step 不 seek、越界钳到最后一帧;没有帧计数或帧率就
  拒绝而不是猜);`ReplayController` 增加 `open_video()`/`has_video()`/`is_video_only()`/`video_path()`,
  `duration_s`/`set_position`/`_refresh_video`/`close` 都覆盖这条路径,并在 `warnings_text()` 里说明
  "这段视频没有配套记录";`app_window._load_replay_video()` 改为按"这条视频是什么"处理:
  同目录有 `forces.csv` → 当记录打开(原行为),否则**当视频播放**(曲线为空、明确提示),只有连解码都不行
  才拒绝并给出 `从视频重算` 的出路;transport/`_render_replay_frame`/`_replay_owns_image`/状态栏 tooltip
  一律改用 `has_video()`(而查曲线的 `_apply_replay_source` 仍用 `is_loaded()`)。
- Why: 用户要求"只导入视频也能用回放按钮"。此前有录像必须走「从视频重算」才能动,因为一条裸视频被当成
  "不是记录"而拒绝(更早是当成空记录"成功"打开→零长度时间轴→按回放没反应)。这两种都不对:视频就是可以看的
  东西,只是没有力值;把它按视频播放、把"没有记录"如实写在标签与提示里,才既不骗人也不挡人。
- Verification: `shared/tests/test_playback.py` +3(VideoOnlySource:时长/按位置取帧/顺序前进/越界钳位/关闭后
  返回空/不可解码与无时间轴被拒;顺带修回我在插入新类时弄丢的 `list_recordings` 的 `base = Path(root)`);
  `test_ui_smoke.py` 改 1 增 1(裸视频可播放:`is_video_only`、transport 可用、tooltip 说明曲线为空、
  按下后位置推进且解出画面、停止有效;不可解码文件仍被拒并提示);ui_check 587 项 0 失败(回放段改为验证
  "裸视频能播");apps/studio 与 shared 全量通过;locale_check 0 缺失。
- Not in this change: 裸视频的曲线(没有 CSV 就没有力值,这是事实而不是待办);裸视频的三维形变(要形变得走
  「从视频重算」,面板标签在回放里仍然是"录像不含三维场");多段视频拼接播放。
This commit is contained in:
ykcheungab
2026-09-14 20:21:13 +08:00
parent c9a28e7c3a
commit 6de6b8b703
10 changed files with 404 additions and 82 deletions
+40 -19
View File
@@ -3001,9 +3001,9 @@ def main() -> int:
f"{replay_window.toolbar.source_summary.text()!r}",
)
# A video that is not a recording must be refused *with a reason*, because the
# reader tolerates an empty folder: accepting it left the transport playing a
# zero-length timeline, which looked like "pressing 回放 does nothing".
# A video with no recording beside it is still something to *watch*: the transport
# plays it, the charts stay empty, and the rail/tooltip say why. Refusing it (the
# older behaviour) left an operator pointing at a clip with a dead button.
import cv2 as _cv2
import numpy as _np
@@ -3011,8 +3011,8 @@ def main() -> int:
bare_writer = _cv2.VideoWriter(
str(bare_video), _cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48)
)
for _index in range(10):
bare_writer.write(_np.zeros((48, 64, 3), dtype=_np.uint8))
for _index in range(30):
bare_writer.write(_np.full((48, 64, 3), _index * 8, dtype=_np.uint8))
bare_writer.release()
other_window = StudioMainWindow(
prefs={"language": "zh_CN", "theme": "dark", "sensor_type": "box"}
@@ -3023,34 +3023,55 @@ def main() -> int:
other_window.brand_bar.btn_playback.click()
app.processEvents()
check(
other_window._load_replay_video(bare_video) is False,
"a video with no forces.csv beside it was accepted as a recording",
other_window._load_replay_video(bare_video) is True,
"a playable video with no recording beside it was refused",
)
app.processEvents()
check(
other_window.replay.has_video() and other_window.replay.is_video_only(),
"the video is not playable as a video",
)
check(
not other_window.replay.is_loaded(),
"an empty folder was opened as a recording",
"a video with no forces.csv claimed to be a recording",
)
check(
not other_window.toolbar.btn_run.isEnabled(),
"the transport is offered although there is nothing to play",
other_window.toolbar.btn_run.isEnabled(),
"the transport is not offered for a video that can be played",
)
check(
"forces.csv" in other_window.toolbar.btn_run.toolTip()
or "记录" in other_window.toolbar.btn_run.toolTip(),
f"the transport does not say why it cannot play: "
t("toolbar.playback_video_only") in other_window.toolbar.btn_run.toolTip(),
f"the transport does not say the charts are empty: "
f"{other_window.toolbar.btn_run.toolTip()!r}",
)
check(
t("playback.video_only_notice") in other_window.replay.warnings_text(),
f"the video is not described as one: {other_window.replay.warnings_text()!r}",
)
check(
not other_window.force_plots.is_recording_source(),
"the charts claim to read a recording that does not exist",
)
other_window.toolbar.btn_run.setChecked(True)
app.processEvents()
_deadline = time.time() + 3.0
while time.time() < _deadline and other_window.replay.position() < 0.4:
app.processEvents()
time.sleep(0.05)
check(
other_window.replay.position() >= 0.4,
f"the transport did not play the video: {other_window.replay.position()}",
)
check(
other_window.replay.video_image() is not None,
"no frame was decoded from the video",
)
check(
other_window.act_recompute.isEnabled(),
"a chosen video cannot be recomputed either: the operator has no way to view it",
)
check(
"forces.csv" in other_window.arrow_panel.image_label.text()
or "记录" in other_window.arrow_panel.image_label.text(),
f"the panel does not say what is missing: "
f"{other_window.arrow_panel.image_label.text()!r}",
)
other_window._stop_replay_activity()
app.processEvents()
other_window.close()
app.processEvents()
replay_window.activateWindow()