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:
@@ -19,6 +19,7 @@ from orisys_app_shared.record.playback import (
|
||||
RecordingFrame,
|
||||
RecordingInfo,
|
||||
RecordingReader,
|
||||
VideoOnlySource,
|
||||
list_recordings,
|
||||
)
|
||||
from orisys_app_shared.record.visualization3d import (
|
||||
@@ -43,6 +44,7 @@ __all__ = [
|
||||
"RecordingFrame",
|
||||
"RecordingInfo",
|
||||
"RecordingReader",
|
||||
"VideoOnlySource",
|
||||
"Visualization3DRecord",
|
||||
"build_visualization3d_block",
|
||||
"format_value",
|
||||
|
||||
@@ -602,6 +602,84 @@ def decimate_window(window: ForceWindow, max_points: int, *, unit: str = "raw")
|
||||
)
|
||||
|
||||
|
||||
class VideoOnlySource:
|
||||
"""One video file with nothing beside it: watch it, but it has no measurements.
|
||||
|
||||
A recording is a directory (video + forces + session metadata); a *video* is what
|
||||
an operator often has — a clip from a camera, a file somebody sent, footage whose
|
||||
``forces.csv`` was never written. This serves the same questions
|
||||
:class:`RecordingReader` serves for a recording — how long is it, what is at *t* —
|
||||
so the replay view can play it with the transport instead of demanding that the
|
||||
file be something it is not.
|
||||
|
||||
What it deliberately does **not** do: invent force rows, a session id or a config
|
||||
hash. ``curve_source()`` is ``None`` and the caller is expected to say that the
|
||||
charts have nothing to read. Recomputing forces from this video is the acquisition
|
||||
path's job, not this one's.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
if not self.path.is_file():
|
||||
raise FileNotFoundError(f"Not a video file: {self.path}")
|
||||
import cv2
|
||||
|
||||
self._capture = cv2.VideoCapture(str(self.path))
|
||||
if not self._capture.isOpened():
|
||||
self._capture.release()
|
||||
self._capture = None
|
||||
raise ValueError(f"Could not open the video: {self.path}")
|
||||
self._fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0)
|
||||
frames = float(self._capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0.0)
|
||||
if not (self._fps > 0.0) or not (frames > 0.0):
|
||||
# Without a frame count or a rate there is no timeline to scrub, and a
|
||||
# guessed one would put the cursor somewhere the picture is not.
|
||||
self._capture.release()
|
||||
self._capture = None
|
||||
raise ValueError(
|
||||
f"The video reports no frame count or frame rate: {self.path.name}"
|
||||
)
|
||||
self.duration_s = frames / self._fps
|
||||
self._index = -1
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def video_frame_at(self, t_rel_s: float) -> tuple[Optional[np.ndarray], None]:
|
||||
"""The frame at ``t_rel_s``: ``(bgr, None)`` or ``(None, None)``.
|
||||
|
||||
The second element is always ``None``: there is no frame *record* to hand back,
|
||||
which is the difference between this and a recording (and the reason the
|
||||
status line for a dropped frame does not apply here).
|
||||
"""
|
||||
import cv2
|
||||
|
||||
if self._capture is None:
|
||||
return None, None
|
||||
target = int(max(0.0, float(t_rel_s)) * self._fps)
|
||||
target = max(0, min(target, int(round(self.duration_s * self._fps)) - 1))
|
||||
with self._lock:
|
||||
try:
|
||||
if self._index >= 0 and target == self._index + 1:
|
||||
ok, image = self._capture.read()
|
||||
else:
|
||||
self._capture.set(cv2.CAP_PROP_POS_FRAMES, target)
|
||||
ok, image = self._capture.read()
|
||||
except Exception: # noqa: BLE001 - a broken decode is not a crash
|
||||
logger.exception("Could not decode %s at frame %s", self.path, target)
|
||||
self._index = -1
|
||||
return None, None
|
||||
self._index = target if ok else -1
|
||||
return (image if ok else None), None
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
if self._capture is not None:
|
||||
try:
|
||||
self._capture.release()
|
||||
except Exception: # noqa: BLE001 - releasing is best effort
|
||||
pass
|
||||
self._capture = None
|
||||
|
||||
|
||||
def list_recordings(root: str | Path) -> list[Path]:
|
||||
base = Path(root)
|
||||
if not base.is_dir():
|
||||
|
||||
@@ -22,6 +22,7 @@ from orisys_app_shared.record import (
|
||||
ExperimentRecorder,
|
||||
RecordConfig,
|
||||
RecordingReader,
|
||||
VideoOnlySource,
|
||||
list_recordings,
|
||||
)
|
||||
|
||||
@@ -235,6 +236,84 @@ def test_list_recordings_finds_directories_newest_first(recording):
|
||||
assert recording in found
|
||||
|
||||
|
||||
# ------------------------------------------------------------- a video on its own
|
||||
def _write_clip(path: Path, frames: int = 60, fps: float = 30.0) -> Path:
|
||||
"""A tiny real video, so the decode path is exercised rather than mocked."""
|
||||
import cv2
|
||||
|
||||
writer = cv2.VideoWriter(
|
||||
str(path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (64, 48)
|
||||
)
|
||||
assert writer.isOpened(), "this build cannot write mp4v"
|
||||
for index in range(frames):
|
||||
writer.write(np.full((48, 64, 3), index * 4, dtype=np.uint8))
|
||||
writer.release()
|
||||
return path
|
||||
|
||||
|
||||
def test_a_video_with_no_recording_is_playable(tmp_path):
|
||||
"""A clip with nothing beside it answers the same questions a recording does."""
|
||||
clip = _write_clip(tmp_path / "clip.mp4")
|
||||
source = VideoOnlySource(clip)
|
||||
try:
|
||||
assert source.duration_s == pytest.approx(2.0, abs=0.2)
|
||||
first, extra = source.video_frame_at(0.0)
|
||||
later, _ = source.video_frame_at(1.5)
|
||||
assert first is not None and later is not None
|
||||
assert first.shape == (48, 64, 3)
|
||||
assert float(later.mean()) > float(first.mean()), (
|
||||
"the clip gets brighter over time; the same frame came back twice"
|
||||
)
|
||||
# Stepping forward is the playback case and must not need a seek.
|
||||
stepped, _ = source.video_frame_at(1.5 + 1.0 / 30.0)
|
||||
assert stepped is not None
|
||||
# Past the end clamps to the last frame instead of failing.
|
||||
assert source.video_frame_at(99.0)[0] is not None
|
||||
# There is deliberately no frame *record*: a video has no measurements.
|
||||
assert extra is None
|
||||
finally:
|
||||
source.close()
|
||||
assert source.video_frame_at(0.0) == (None, None), "closed source still served frames"
|
||||
|
||||
|
||||
def test_a_file_that_is_not_a_video_is_refused(tmp_path):
|
||||
broken = tmp_path / "broken.mp4"
|
||||
broken.write_text("not a video at all", encoding="utf-8")
|
||||
with pytest.raises(ValueError):
|
||||
VideoOnlySource(broken)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
VideoOnlySource(tmp_path / "missing.mp4")
|
||||
|
||||
|
||||
def test_a_video_without_a_timeline_is_refused(tmp_path, monkeypatch):
|
||||
"""No frame count or rate means no timeline, and a guessed one would lie.
|
||||
|
||||
The input is a decoder that reports neither, rather than a hand-made file: this
|
||||
build will not even write a video with a zero frame rate.
|
||||
"""
|
||||
import cv2
|
||||
|
||||
clip = _write_clip(tmp_path / "clip.mp4")
|
||||
real_capture = cv2.VideoCapture
|
||||
|
||||
class NoTimeline:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self._real = real_capture(*args, **kwargs)
|
||||
|
||||
def isOpened(self) -> bool: # noqa: N802 - OpenCV spelling
|
||||
return True
|
||||
|
||||
def get(self, prop): # noqa: ANN001 - OpenCV spelling
|
||||
return 0.0
|
||||
|
||||
def release(self) -> None:
|
||||
self._real.release()
|
||||
|
||||
monkeypatch.setattr(cv2, "VideoCapture", NoTimeline)
|
||||
with pytest.raises(ValueError):
|
||||
VideoOnlySource(clip)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - manual runner
|
||||
import shutil as _shutil
|
||||
|
||||
|
||||
Reference in New Issue
Block a user