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:
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user