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
+43 -24
View File
@@ -813,7 +813,7 @@ class StudioMainWindow(QMainWindow):
# The recompute pass owns the timeline: its samples are arriving in
# order and the cursor follows them.
return
if self._view_preset == "replay" and self.replay.is_loaded():
if self._view_preset == "replay" and self.replay.has_video():
self.replay.set_position(float(t_rel_s))
def _on_open_recording(self) -> None:
@@ -835,25 +835,33 @@ class StudioMainWindow(QMainWindow):
self._load_replay_video(Path(chosen))
def _load_replay_video(self, video: Path) -> bool:
"""Open the recording a recorded video belongs to.
"""Open what the chosen video *is*: a recording, or just a video.
A **recording** is a directory that holds ``forces.csv`` beside the video —
the same rule ``list_recordings`` uses, so the two cannot disagree about what
a recording is. A video on its own is not one: it has no force rows and no
timeline. Opening its folder anyway "succeeded" (the reader tolerates a
directory with nothing in it) and then left the transport button playing a
zero-length timeline, which is why choosing a video and pressing 回放 did
nothing at all. So a bare video is refused with what is missing, and the rail
keeps its path so 「从视频重算」 can still run the chain over it.
A **recording** is a directory that holds ``forces.csv`` beside the video — the
same rule ``list_recordings`` uses, so the two cannot disagree about what a
recording is. Anything else is still worth watching: an operator who points at
a clip expects the transport to play it, not to be told to press a different
button first. So a bare video is opened for playback (no forces, no session
metadata, and the rail says so), and only a file that cannot be decoded at all
is refused — with what is missing, and with 「从视频重算」 still offered, because
that is the only way to get *numbers* out of a video that has no recording.
"""
video = Path(video)
folder = video.parent if video.is_file() else video
if not (folder / "forces.csv").is_file():
if video.is_file() and self.replay.open_video(video):
self.toolbar.set_video_path(str(video))
self.prefs["video_path"] = str(video)
self._set_source_mode("video")
self._render_replay_frame()
self._notify(t("playback.video_only_opened", name=video.name))
self._refresh_replay_state()
return True
if video.is_file():
self.toolbar.set_video_path(str(video))
self.prefs["video_path"] = str(video)
self._set_source_mode("video")
if not self.replay.is_loaded():
if not self.replay.has_video():
# Nothing else is on screen, so the reason belongs in the panel that
# would have shown the frames — not only in a status line that expires.
self.arrow_panel.clear_preview(t("playback.video_only_panel"))
@@ -870,7 +878,7 @@ class StudioMainWindow(QMainWindow):
"""Whether 「从视频重算」 has something to read: a recording, or a video file."""
if self._view_preset != "replay":
return False
if self.replay.is_loaded():
if self.replay.has_video():
return True
named = self.toolbar.video_path()
return bool(named) and Path(named).is_file()
@@ -907,7 +915,11 @@ class StudioMainWindow(QMainWindow):
def _refresh_replay_state(self) -> None:
"""Push the engine's state into the charts' mode and the rail."""
if self.replay.is_loaded() and not self.toolbar.video_path():
if self.replay.is_video_only() and not self.toolbar.video_path():
path = self.replay.video_path()
if path is not None:
self.toolbar.set_video_path(str(path))
elif self.replay.is_loaded() and not self.toolbar.video_path():
# Opened without naming a video (the file menu's "latest"): name the
# segment that is on screen so the rail states the same thing the
# picker would.
@@ -944,7 +956,7 @@ class StudioMainWindow(QMainWindow):
self._stop_run(reason="user_stop")
self.toolbar.set_run_checked(bool(playing) and self._running)
return
if playing and self.replay.is_loaded():
if playing and self.replay.has_video():
self._start_playback()
else:
self._pause_playback()
@@ -1056,13 +1068,15 @@ class StudioMainWindow(QMainWindow):
self.mesh3d_panel.show_reference_only()
if self._view_preset == "replay":
self.mesh3d_panel.set_source_kind(self._current_source_kind())
if self._view_preset == "replay" and self.replay.is_loaded():
self._apply_replay_source(True)
if self.replay.is_loaded():
self._apply_replay_source(True)
# A bare video has no charts to point at the recording, but it does have
# frames to show again now that the pass is over.
self._render_replay_frame()
def _render_replay_frame(self) -> None:
"""Draw what the 2D panel should show for the current replay cursor."""
if self._view_preset != "replay" or not self.replay.is_loaded():
if self._view_preset != "replay" or not self.replay.has_video():
return
if self._recompute_running:
# The pipeline is drawing its own frames (see ``_replay_owns_image``).
@@ -1454,12 +1468,16 @@ class StudioMainWindow(QMainWindow):
def _update_run_enabled(self) -> None:
if self.toolbar.run_role() == "playback":
# In the replay view the button is a transport: it needs a recording,
# not a runnable sensor. While it plays it stays enabled so it can be
# paused.
loaded = self.replay.is_loaded()
# In the replay view the button is a transport: it needs something to
# play — a recording, or a bare video — not a runnable sensor. While it
# plays it stays enabled so it can be paused.
loaded = self.replay.has_video()
if loaded:
reason = ""
reason = (
t("toolbar.playback_video_only")
if self.replay.is_video_only()
else ""
)
elif self._can_recompute():
# A video was chosen but it is not a recording: the transport cannot
# play it, and the tooltip says which button can show it instead of
@@ -1615,7 +1633,8 @@ class StudioMainWindow(QMainWindow):
# they live in the summary's tooltip (the bar that used to show them is
# gone: the two views share one layout).
tip_parts = [self.toolbar.video_path()]
if self._view_preset == "replay" and self.replay.is_loaded():
if self._view_preset == "replay" and self.replay.has_video():
# Includes the "this is a video, not a recording" note for a bare video.
tip_parts.append(self.replay.warnings_text())
self.toolbar.set_source_summary_tip(
"\n".join(part for part in tip_parts if part)
@@ -2810,7 +2829,7 @@ class StudioMainWindow(QMainWindow):
"""
return (
self._view_preset == "replay"
and self.replay.is_loaded()
and self.replay.has_video()
and not self._recompute_running
)
+70 -7
View File
@@ -14,13 +14,14 @@ is the acquisition path's job (see the studio's "recompute" mode).
from __future__ import annotations
import logging
from pathlib import Path
import numpy as np
from PyQt5.QtCore import QObject, pyqtSignal
from PyQt5.QtGui import QImage
from orisys_app_shared.i18n import t
from orisys_app_shared.record import RecordingReader
from orisys_app_shared.record import RecordingReader, VideoOnlySource
logger = logging.getLogger(__name__)
@@ -57,6 +58,8 @@ class ReplayController(QObject):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._reader: RecordingReader | None = None
#: A video with no recording beside it: playable, but it has no measurements.
self._video: VideoOnlySource | None = None
self._unit = "raw"
self._position = 0.0
#: The frame cache: one video image per cursor position.
@@ -67,8 +70,44 @@ class ReplayController(QObject):
# ------------------------------------------------------------- recording
def is_loaded(self) -> bool:
"""A *recording* is open: a video with its forces and session metadata."""
return self._reader is not None
def has_video(self) -> bool:
"""Whether there is anything to play at all — a recording, or a bare video.
The transport asks this, not ``is_loaded``: an operator who opens a clip from
a camera should be able to watch it. What the *charts* ask is ``is_loaded``,
because a bare video has no rows to draw.
"""
return self._reader is not None or self._video is not None
def is_video_only(self) -> bool:
return self._reader is None and self._video is not None
def video_path(self):
return self._video.path if self._video is not None else None
def open_video(self, path) -> bool:
"""Open a bare video for playback (no forces, no session metadata)."""
self._release_reader()
self._release_video()
try:
self._video = VideoOnlySource(Path(path))
except Exception: # noqa: BLE001 - reported through the status text
logger.exception("Could not open the video %s", path)
self._video = None
self._reset_frames()
self.recording_changed.emit(False)
self.status_changed.emit()
return False
self._reset_frames()
self._position = 0.0
self.recording_changed.emit(True)
self._refresh_warnings()
self.set_position(0.0)
return True
def directory(self):
return self._reader.directory if self._reader is not None else None
@@ -107,8 +146,9 @@ class ReplayController(QObject):
return True
def close(self) -> None:
"""Drop the recording (the decoder is released with it)."""
"""Drop whatever is open (the decoder is released with it)."""
self._release_reader()
self._release_video()
self._reset_frames()
self.recording_changed.emit(False)
self.status_changed.emit()
@@ -121,6 +161,14 @@ class ReplayController(QObject):
except Exception: # noqa: BLE001 - releasing is best effort
logger.debug("Could not close the recording reader", exc_info=True)
def _release_video(self) -> None:
video, self._video = self._video, None
if video is not None:
try:
video.close()
except Exception: # noqa: BLE001 - releasing is best effort
logger.debug("Could not close the video", exc_info=True)
def _reset_frames(self) -> None:
self._video_position = None
self._video_qimage = None
@@ -131,13 +179,15 @@ class ReplayController(QObject):
return self._position
def duration_s(self) -> float:
if self._reader is None:
return 0.0
return float(self._reader.info().duration_s)
if self._reader is not None:
return float(self._reader.info().duration_s)
if self._video is not None:
return float(self._video.duration_s)
return 0.0
def set_position(self, t_rel_s: float) -> None:
"""Move the single cursor: the video frame and the charts follow it."""
if self._reader is None:
if not self.has_video():
return
duration = self.duration_s()
self._position = max(0.0, min(float(t_rel_s), duration if duration > 0 else 0.0))
@@ -168,7 +218,7 @@ class ReplayController(QObject):
return self._video_reason
def _refresh_video(self) -> None:
if self._reader is None:
if self._reader is None and self._video is None:
self._video_position = None
self._video_qimage = None
self._video_reason = t("playback.none_found")
@@ -176,6 +226,15 @@ class ReplayController(QObject):
if self._video_position == self._position:
return
self._video_position = self._position
if self._video is not None:
image, _frame = self._video.video_frame_at(self._position)
if image is None:
self._video_qimage = None
self._video_reason = t("playback.frame_unreadable")
return
self._video_reason = ""
self._video_qimage = _bgr_to_qimage(image)
return
image, frame = self._reader.video_frame_at(self._position)
if image is None:
self._video_qimage = None
@@ -195,6 +254,10 @@ class ReplayController(QObject):
def _refresh_warnings(self) -> None:
self._warnings = ""
if self._reader is None:
if self._video is not None:
# Not a warning: a statement of what this file *is*, so nobody reads an
# empty chart as a measurement of zero.
self._warnings = t("playback.video_only_notice")
return
info = self._reader.info()
codes = self._reader.replay_warnings(sdk_version=_sdk_version())
@@ -691,6 +691,11 @@ class ToolbarWidget(QWidget):
key = "toolbar.playback_pause_tip" if self.btn_run.isChecked() else "toolbar.playback_tip"
if not self._run_available and self._run_tooltip_reason:
self.btn_run.setToolTip(self._run_tooltip_reason)
elif self._run_tooltip_reason:
# Usable, but with something the operator should know before pressing:
# a bare video plays with empty charts, and the tip says so instead of
# hiding that behind a second hover.
self.btn_run.setToolTip(f"{t(key)} — {self._run_tooltip_reason}")
else:
self.btn_run.setToolTip(t(key))
return