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:
@@ -103,6 +103,7 @@
|
|||||||
"playback_pause_tip": "Pause playback (dragging the cursor, box zoom or the wheel also pauses)",
|
"playback_pause_tip": "Pause playback (dragging the cursor, box zoom or the wheel also pauses)",
|
||||||
"playback_disabled": "Open a recorded video first (“Choose a recorded video…” at the top left)",
|
"playback_disabled": "Open a recorded video first (“Choose a recorded video…” at the top left)",
|
||||||
"playback_needs_recording": "This video has no recording beside it (no forces.csv): replay needs a recording; to just watch the video use View ▸ Recompute forces from the video",
|
"playback_needs_recording": "This video has no recording beside it (no forces.csv): replay needs a recording; to just watch the video use View ▸ Recompute forces from the video",
|
||||||
|
"playback_video_only": "Play this video (no recording beside it: the picture plays, the force charts are empty)",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"stop_playback": "Stop",
|
"stop_playback": "Stop",
|
||||||
"stop_playback_tip": "Stop the replay: plain playback and a “recompute from the video” pass both stop here (Esc does the same).",
|
"stop_playback_tip": "Stop the replay: plain playback and a “recompute from the video” pass both stop here (Esc does the same).",
|
||||||
@@ -213,7 +214,9 @@
|
|||||||
"no_video": "This recording has no usable video segment",
|
"no_video": "This recording has no usable video segment",
|
||||||
"open_failed": "Could not open that recording: {error}",
|
"open_failed": "Could not open that recording: {error}",
|
||||||
"not_a_recording": "“{name}” is not a recording: its folder has no forces.csv (a recording is the video plus forces.csv and session.json). Replay needs a recording; to just watch this video, use “Recompute forces from the video”.",
|
"not_a_recording": "“{name}” is not a recording: its folder has no forces.csv (a recording is the video plus forces.csv and session.json). Replay needs a recording; to just watch this video, use “Recompute forces from the video”.",
|
||||||
"video_only_panel": "This is not a recording (no forces.csv beside it)\nTo watch this video, use\nView ▸ Recompute forces from the video",
|
"video_only_opened": "“{name}” has no recording beside it (no forces.csv), so it is playing as a video: the picture and the timeline work, the force charts are empty. For forces and deformation use View ▸ Recompute forces from the video.",
|
||||||
|
"video_only_notice": "This video has no recording beside it (no forces.csv): it plays, but the force charts and the recording details are empty.",
|
||||||
|
"video_only_panel": "This is not a recording, and this video cannot be decoded (no forces.csv beside it, and it does not read as playable video)\nYou can still try View ▸ Recompute forces from the video",
|
||||||
"empty_recording": "This recording has no playable picture (no raw_*.mp4 segment), so it cannot be replayed",
|
"empty_recording": "This recording has no playable picture (no raw_*.mp4 segment), so it cannot be replayed",
|
||||||
"none_found": "No recording found under outputs/recordings",
|
"none_found": "No recording found under outputs/recordings",
|
||||||
"frame_dropped": "This frame never reached the video (the record queue overflowed)",
|
"frame_dropped": "This frame never reached the video (the record queue overflowed)",
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
"playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
|
"playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
|
||||||
"playback_disabled": "先打开一段录制视频(左上「选择录制视频…」)",
|
"playback_disabled": "先打开一段录制视频(左上「选择录制视频…」)",
|
||||||
"playback_needs_recording": "这段视频旁边没有记录(缺 forces.csv):回放需要一条记录;只想看视频请用 视图 ▸ 从视频重算力值",
|
"playback_needs_recording": "这段视频旁边没有记录(缺 forces.csv):回放需要一条记录;只想看视频请用 视图 ▸ 从视频重算力值",
|
||||||
|
"playback_video_only": "播放这段视频(没有配套记录:可以看画面,力曲线为空)",
|
||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
"stop_playback": "停止",
|
"stop_playback": "停止",
|
||||||
"stop_playback_tip": "停止回放:无论是普通播放还是「从视频重算」通道,都在这里停下(Esc 同效)。",
|
"stop_playback_tip": "停止回放:无论是普通播放还是「从视频重算」通道,都在这里停下(Esc 同效)。",
|
||||||
@@ -213,7 +214,9 @@
|
|||||||
"no_video": "这段录制没有可用的视频分段",
|
"no_video": "这段录制没有可用的视频分段",
|
||||||
"open_failed": "无法打开该录制: {error}",
|
"open_failed": "无法打开该录制: {error}",
|
||||||
"not_a_recording": "「{name}」不是一条记录:同目录没有 forces.csv(一条记录 = 录像 + forces.csv + session.json)。回放需要记录;只想看这段视频请用「从视频重算力值」。",
|
"not_a_recording": "「{name}」不是一条记录:同目录没有 forces.csv(一条记录 = 录像 + forces.csv + session.json)。回放需要记录;只想看这段视频请用「从视频重算力值」。",
|
||||||
"video_only_panel": "这不是一条记录(同目录缺少 forces.csv)\n只想看这段视频请用\n视图 ▸ 从视频重算力值",
|
"video_only_opened": "「{name}」没有配套记录(无 forces.csv),已按视频播放:可以看画面与拖时间轴,力曲线为空。要得助力值/形变请用 视图 ▸ 从视频重算力值。",
|
||||||
|
"video_only_notice": "这段视频没有配套记录(同目录无 forces.csv):可以播放,但力曲线与记录信息为空。",
|
||||||
|
"video_only_panel": "这不是一条记录,而且无法解码这段视频(没有 forces.csv,也不像可播放的视频文件)\n可以试试 视图 ▸ 从视频重算力值",
|
||||||
"empty_recording": "这条记录没有可播放的画面(没有 raw_*.mp4 视频分段),因此无法回放",
|
"empty_recording": "这条记录没有可播放的画面(没有 raw_*.mp4 视频分段),因此无法回放",
|
||||||
"none_found": "outputs/recordings 下没有找到录制",
|
"none_found": "outputs/recordings 下没有找到录制",
|
||||||
"frame_dropped": "该帧未被写入视频(记录队列曾溢出)",
|
"frame_dropped": "该帧未被写入视频(记录队列曾溢出)",
|
||||||
|
|||||||
@@ -813,7 +813,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
# The recompute pass owns the timeline: its samples are arriving in
|
# The recompute pass owns the timeline: its samples are arriving in
|
||||||
# order and the cursor follows them.
|
# order and the cursor follows them.
|
||||||
return
|
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))
|
self.replay.set_position(float(t_rel_s))
|
||||||
|
|
||||||
def _on_open_recording(self) -> None:
|
def _on_open_recording(self) -> None:
|
||||||
@@ -835,25 +835,33 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._load_replay_video(Path(chosen))
|
self._load_replay_video(Path(chosen))
|
||||||
|
|
||||||
def _load_replay_video(self, video: Path) -> bool:
|
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 —
|
A **recording** is a directory that holds ``forces.csv`` beside the video — the
|
||||||
the same rule ``list_recordings`` uses, so the two cannot disagree about what
|
same rule ``list_recordings`` uses, so the two cannot disagree about what a
|
||||||
a recording is. A video on its own is not one: it has no force rows and no
|
recording is. Anything else is still worth watching: an operator who points at
|
||||||
timeline. Opening its folder anyway "succeeded" (the reader tolerates a
|
a clip expects the transport to play it, not to be told to press a different
|
||||||
directory with nothing in it) and then left the transport button playing a
|
button first. So a bare video is opened for playback (no forces, no session
|
||||||
zero-length timeline, which is why choosing a video and pressing 回放 did
|
metadata, and the rail says so), and only a file that cannot be decoded at all
|
||||||
nothing at all. So a bare video is refused with what is missing, and the rail
|
is refused — with what is missing, and with 「从视频重算」 still offered, because
|
||||||
keeps its path so 「从视频重算」 can still run the chain over it.
|
that is the only way to get *numbers* out of a video that has no recording.
|
||||||
"""
|
"""
|
||||||
video = Path(video)
|
video = Path(video)
|
||||||
folder = video.parent if video.is_file() else video
|
folder = video.parent if video.is_file() else video
|
||||||
if not (folder / "forces.csv").is_file():
|
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():
|
if video.is_file():
|
||||||
self.toolbar.set_video_path(str(video))
|
self.toolbar.set_video_path(str(video))
|
||||||
self.prefs["video_path"] = str(video)
|
self.prefs["video_path"] = str(video)
|
||||||
self._set_source_mode("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
|
# 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.
|
# would have shown the frames — not only in a status line that expires.
|
||||||
self.arrow_panel.clear_preview(t("playback.video_only_panel"))
|
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."""
|
"""Whether 「从视频重算」 has something to read: a recording, or a video file."""
|
||||||
if self._view_preset != "replay":
|
if self._view_preset != "replay":
|
||||||
return False
|
return False
|
||||||
if self.replay.is_loaded():
|
if self.replay.has_video():
|
||||||
return True
|
return True
|
||||||
named = self.toolbar.video_path()
|
named = self.toolbar.video_path()
|
||||||
return bool(named) and Path(named).is_file()
|
return bool(named) and Path(named).is_file()
|
||||||
@@ -907,7 +915,11 @@ class StudioMainWindow(QMainWindow):
|
|||||||
|
|
||||||
def _refresh_replay_state(self) -> None:
|
def _refresh_replay_state(self) -> None:
|
||||||
"""Push the engine's state into the charts' mode and the rail."""
|
"""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
|
# 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
|
# segment that is on screen so the rail states the same thing the
|
||||||
# picker would.
|
# picker would.
|
||||||
@@ -944,7 +956,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._stop_run(reason="user_stop")
|
self._stop_run(reason="user_stop")
|
||||||
self.toolbar.set_run_checked(bool(playing) and self._running)
|
self.toolbar.set_run_checked(bool(playing) and self._running)
|
||||||
return
|
return
|
||||||
if playing and self.replay.is_loaded():
|
if playing and self.replay.has_video():
|
||||||
self._start_playback()
|
self._start_playback()
|
||||||
else:
|
else:
|
||||||
self._pause_playback()
|
self._pause_playback()
|
||||||
@@ -1056,13 +1068,15 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.mesh3d_panel.show_reference_only()
|
self.mesh3d_panel.show_reference_only()
|
||||||
if self._view_preset == "replay":
|
if self._view_preset == "replay":
|
||||||
self.mesh3d_panel.set_source_kind(self._current_source_kind())
|
self.mesh3d_panel.set_source_kind(self._current_source_kind())
|
||||||
if self._view_preset == "replay" and self.replay.is_loaded():
|
if self.replay.is_loaded():
|
||||||
self._apply_replay_source(True)
|
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()
|
self._render_replay_frame()
|
||||||
|
|
||||||
def _render_replay_frame(self) -> None:
|
def _render_replay_frame(self) -> None:
|
||||||
"""Draw what the 2D panel should show for the current replay cursor."""
|
"""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
|
return
|
||||||
if self._recompute_running:
|
if self._recompute_running:
|
||||||
# The pipeline is drawing its own frames (see ``_replay_owns_image``).
|
# The pipeline is drawing its own frames (see ``_replay_owns_image``).
|
||||||
@@ -1454,12 +1468,16 @@ class StudioMainWindow(QMainWindow):
|
|||||||
|
|
||||||
def _update_run_enabled(self) -> None:
|
def _update_run_enabled(self) -> None:
|
||||||
if self.toolbar.run_role() == "playback":
|
if self.toolbar.run_role() == "playback":
|
||||||
# In the replay view the button is a transport: it needs a recording,
|
# In the replay view the button is a transport: it needs something to
|
||||||
# not a runnable sensor. While it plays it stays enabled so it can be
|
# play — a recording, or a bare video — not a runnable sensor. While it
|
||||||
# paused.
|
# plays it stays enabled so it can be paused.
|
||||||
loaded = self.replay.is_loaded()
|
loaded = self.replay.has_video()
|
||||||
if loaded:
|
if loaded:
|
||||||
reason = ""
|
reason = (
|
||||||
|
t("toolbar.playback_video_only")
|
||||||
|
if self.replay.is_video_only()
|
||||||
|
else ""
|
||||||
|
)
|
||||||
elif self._can_recompute():
|
elif self._can_recompute():
|
||||||
# A video was chosen but it is not a recording: the transport cannot
|
# 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
|
# 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
|
# they live in the summary's tooltip (the bar that used to show them is
|
||||||
# gone: the two views share one layout).
|
# gone: the two views share one layout).
|
||||||
tip_parts = [self.toolbar.video_path()]
|
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())
|
tip_parts.append(self.replay.warnings_text())
|
||||||
self.toolbar.set_source_summary_tip(
|
self.toolbar.set_source_summary_tip(
|
||||||
"\n".join(part for part in tip_parts if part)
|
"\n".join(part for part in tip_parts if part)
|
||||||
@@ -2810,7 +2829,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
self._view_preset == "replay"
|
self._view_preset == "replay"
|
||||||
and self.replay.is_loaded()
|
and self.replay.has_video()
|
||||||
and not self._recompute_running
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtCore import QObject, pyqtSignal
|
from PyQt5.QtCore import QObject, pyqtSignal
|
||||||
from PyQt5.QtGui import QImage
|
from PyQt5.QtGui import QImage
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -57,6 +58,8 @@ class ReplayController(QObject):
|
|||||||
def __init__(self, parent=None) -> None:
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._reader: RecordingReader | None = None
|
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._unit = "raw"
|
||||||
self._position = 0.0
|
self._position = 0.0
|
||||||
#: The frame cache: one video image per cursor position.
|
#: The frame cache: one video image per cursor position.
|
||||||
@@ -67,8 +70,44 @@ class ReplayController(QObject):
|
|||||||
|
|
||||||
# ------------------------------------------------------------- recording
|
# ------------------------------------------------------------- recording
|
||||||
def is_loaded(self) -> bool:
|
def is_loaded(self) -> bool:
|
||||||
|
"""A *recording* is open: a video with its forces and session metadata."""
|
||||||
return self._reader is not None
|
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):
|
def directory(self):
|
||||||
return self._reader.directory if self._reader is not None else None
|
return self._reader.directory if self._reader is not None else None
|
||||||
|
|
||||||
@@ -107,8 +146,9 @@ class ReplayController(QObject):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def close(self) -> None:
|
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_reader()
|
||||||
|
self._release_video()
|
||||||
self._reset_frames()
|
self._reset_frames()
|
||||||
self.recording_changed.emit(False)
|
self.recording_changed.emit(False)
|
||||||
self.status_changed.emit()
|
self.status_changed.emit()
|
||||||
@@ -121,6 +161,14 @@ class ReplayController(QObject):
|
|||||||
except Exception: # noqa: BLE001 - releasing is best effort
|
except Exception: # noqa: BLE001 - releasing is best effort
|
||||||
logger.debug("Could not close the recording reader", exc_info=True)
|
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:
|
def _reset_frames(self) -> None:
|
||||||
self._video_position = None
|
self._video_position = None
|
||||||
self._video_qimage = None
|
self._video_qimage = None
|
||||||
@@ -131,13 +179,15 @@ class ReplayController(QObject):
|
|||||||
return self._position
|
return self._position
|
||||||
|
|
||||||
def duration_s(self) -> float:
|
def duration_s(self) -> float:
|
||||||
if self._reader is None:
|
if self._reader is not None:
|
||||||
return 0.0
|
|
||||||
return float(self._reader.info().duration_s)
|
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:
|
def set_position(self, t_rel_s: float) -> None:
|
||||||
"""Move the single cursor: the video frame and the charts follow it."""
|
"""Move the single cursor: the video frame and the charts follow it."""
|
||||||
if self._reader is None:
|
if not self.has_video():
|
||||||
return
|
return
|
||||||
duration = self.duration_s()
|
duration = self.duration_s()
|
||||||
self._position = max(0.0, min(float(t_rel_s), duration if duration > 0 else 0.0))
|
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
|
return self._video_reason
|
||||||
|
|
||||||
def _refresh_video(self) -> None:
|
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_position = None
|
||||||
self._video_qimage = None
|
self._video_qimage = None
|
||||||
self._video_reason = t("playback.none_found")
|
self._video_reason = t("playback.none_found")
|
||||||
@@ -176,6 +226,15 @@ class ReplayController(QObject):
|
|||||||
if self._video_position == self._position:
|
if self._video_position == self._position:
|
||||||
return
|
return
|
||||||
self._video_position = self._position
|
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)
|
image, frame = self._reader.video_frame_at(self._position)
|
||||||
if image is None:
|
if image is None:
|
||||||
self._video_qimage = None
|
self._video_qimage = None
|
||||||
@@ -195,6 +254,10 @@ class ReplayController(QObject):
|
|||||||
def _refresh_warnings(self) -> None:
|
def _refresh_warnings(self) -> None:
|
||||||
self._warnings = ""
|
self._warnings = ""
|
||||||
if self._reader is None:
|
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
|
return
|
||||||
info = self._reader.info()
|
info = self._reader.info()
|
||||||
codes = self._reader.replay_warnings(sdk_version=_sdk_version())
|
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"
|
key = "toolbar.playback_pause_tip" if self.btn_run.isChecked() else "toolbar.playback_tip"
|
||||||
if not self._run_available and self._run_tooltip_reason:
|
if not self._run_available and self._run_tooltip_reason:
|
||||||
self.btn_run.setToolTip(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:
|
else:
|
||||||
self.btn_run.setToolTip(t(key))
|
self.btn_run.setToolTip(t(key))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2086,15 +2086,16 @@ def test_the_live_view_keeps_the_tracker_reset(make_window):
|
|||||||
window.close()
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
def test_a_video_that_is_not_a_recording_is_refused_with_a_reason(
|
def test_a_bare_video_plays_even_though_it_is_not_a_recording(make_window, record_root):
|
||||||
make_window, record_root
|
"""A clip with no forces.csv beside it is still something to watch.
|
||||||
):
|
|
||||||
"""A bare video has no forces.csv, so it is not something this view can replay.
|
|
||||||
|
|
||||||
The reader tolerates a directory with nothing in it, so opening the video's own
|
An operator who points at a video expects the transport to play it. Opening the
|
||||||
folder as a recording used to *succeed* — and then the transport played a
|
video's own folder as a *recording* used to "succeed" against the tolerant reader
|
||||||
zero-length timeline, which is what "pressing 回放 does nothing" was.
|
and then play a zero-length timeline ("pressing 回放 does nothing"), so the fix is
|
||||||
|
not to refuse the file but to play it as what it is: a video, with empty charts.
|
||||||
"""
|
"""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PyQt5.QtWidgets import QApplication
|
||||||
@@ -2106,33 +2107,81 @@ def test_a_video_that_is_not_a_recording_is_refused_with_a_reason(
|
|||||||
str(bare), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48)
|
str(bare), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48)
|
||||||
)
|
)
|
||||||
for index in range(30):
|
for index in range(30):
|
||||||
writer.write(np.full((48, 64, 3), index, dtype=np.uint8))
|
writer.write(np.full((48, 64, 3), index * 8, dtype=np.uint8))
|
||||||
writer.release()
|
writer.release()
|
||||||
assert bare.is_file()
|
assert bare.is_file()
|
||||||
|
|
||||||
window = _show(make_window(), (1440, 880))
|
window = _show(make_window(), (1440, 880))
|
||||||
|
try:
|
||||||
window.brand_bar.btn_playback.click()
|
window.brand_bar.btn_playback.click()
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
assert not window.replay.is_loaded()
|
assert not window.replay.has_video()
|
||||||
|
assert window._load_replay_video(bare) is True, "a playable video was refused"
|
||||||
|
QApplication.processEvents()
|
||||||
|
assert window.replay.has_video(), "nothing is playable"
|
||||||
|
assert window.replay.is_video_only(), "a bare video was taken for a recording"
|
||||||
|
assert not window.replay.is_loaded(), (
|
||||||
|
"a bare video must not claim to carry a recording's measurements"
|
||||||
|
)
|
||||||
|
assert window.replay.duration_s() > 0.5, window.replay.duration_s()
|
||||||
|
assert window.toolbar.btn_run.isEnabled(), "the transport is not offered"
|
||||||
|
assert t("toolbar.playback_video_only") in window.toolbar.btn_run.toolTip(), (
|
||||||
|
window.toolbar.btn_run.toolTip()
|
||||||
|
)
|
||||||
|
assert t("playback.video_only_notice") in window.replay.warnings_text()
|
||||||
|
# The charts have nothing to read, and say so rather than drawing zeros.
|
||||||
|
assert not window.force_plots.is_recording_source()
|
||||||
|
|
||||||
assert window._load_replay_video(bare) is False, (
|
window.toolbar.btn_run.setChecked(True)
|
||||||
"a video with no recording beside it was accepted as a recording"
|
QApplication.processEvents()
|
||||||
|
deadline = _time.time() + 3.0
|
||||||
|
while _time.time() < deadline and window.replay.position() < 0.4:
|
||||||
|
QApplication.processEvents()
|
||||||
|
_time.sleep(0.05)
|
||||||
|
assert window.replay.position() >= 0.4, (
|
||||||
|
f"the transport did not play the video: {window.replay.position()}"
|
||||||
|
)
|
||||||
|
assert window._play_timer.isActive()
|
||||||
|
image = window.replay.video_image()
|
||||||
|
assert image is not None and not image.isNull(), "no frame was decoded"
|
||||||
|
window._stop_replay_activity()
|
||||||
|
QApplication.processEvents()
|
||||||
|
assert not window._play_timer.isActive()
|
||||||
|
finally:
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason(
|
||||||
|
make_window, record_root
|
||||||
|
):
|
||||||
|
"""Not a recording *and* not decodable: say what is missing and offer recompute."""
|
||||||
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
|
broken = Path(record_root) / "not_really_a_video.mp4"
|
||||||
|
broken.write_text("this is not a video at all\n", encoding="utf-8")
|
||||||
|
|
||||||
|
window = _show(make_window(), (1440, 880))
|
||||||
|
try:
|
||||||
|
window.brand_bar.btn_playback.click()
|
||||||
|
QApplication.processEvents()
|
||||||
|
assert window._load_replay_video(broken) is False, (
|
||||||
|
"a file that cannot be decoded was accepted for playback"
|
||||||
)
|
)
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
assert not window.replay.is_loaded(), "an empty folder was opened as a recording"
|
assert not window.replay.has_video()
|
||||||
assert not window.toolbar.btn_run.isEnabled(), (
|
assert not window.toolbar.btn_run.isEnabled(), (
|
||||||
"the transport is offered although there is nothing to play"
|
"the transport is offered although there is nothing to play"
|
||||||
)
|
)
|
||||||
reason = window.toolbar.btn_run.toolTip()
|
reason = window.statusBar().currentMessage()
|
||||||
assert "forces.csv" in reason or "recording" in reason.lower(), reason
|
assert "forces.csv" in reason or "记录" in reason, reason
|
||||||
# And what was missing, in the panel that would have shown the frames.
|
# The way forward for a video with no recording: recompute from it.
|
||||||
body = window.arrow_panel.image_label.text()
|
|
||||||
assert "forces.csv" in body or "recording" in body.lower(), body
|
|
||||||
# The way forward for a video that is not a recording: recompute from it.
|
|
||||||
assert window.act_recompute.isEnabled(), (
|
assert window.act_recompute.isEnabled(), (
|
||||||
"a chosen video cannot be recomputed either, so the operator has no way to "
|
"a chosen video cannot be recomputed either, so the operator has no way to "
|
||||||
"look at it"
|
"look at it"
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
window.close()
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3001,9 +3001,9 @@ def main() -> int:
|
|||||||
f"{replay_window.toolbar.source_summary.text()!r}",
|
f"{replay_window.toolbar.source_summary.text()!r}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# A video that is not a recording must be refused *with a reason*, because the
|
# A video with no recording beside it is still something to *watch*: the transport
|
||||||
# reader tolerates an empty folder: accepting it left the transport playing a
|
# plays it, the charts stay empty, and the rail/tooltip say why. Refusing it (the
|
||||||
# zero-length timeline, which looked like "pressing 回放 does nothing".
|
# older behaviour) left an operator pointing at a clip with a dead button.
|
||||||
import cv2 as _cv2
|
import cv2 as _cv2
|
||||||
import numpy as _np
|
import numpy as _np
|
||||||
|
|
||||||
@@ -3011,8 +3011,8 @@ def main() -> int:
|
|||||||
bare_writer = _cv2.VideoWriter(
|
bare_writer = _cv2.VideoWriter(
|
||||||
str(bare_video), _cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48)
|
str(bare_video), _cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48)
|
||||||
)
|
)
|
||||||
for _index in range(10):
|
for _index in range(30):
|
||||||
bare_writer.write(_np.zeros((48, 64, 3), dtype=_np.uint8))
|
bare_writer.write(_np.full((48, 64, 3), _index * 8, dtype=_np.uint8))
|
||||||
bare_writer.release()
|
bare_writer.release()
|
||||||
other_window = StudioMainWindow(
|
other_window = StudioMainWindow(
|
||||||
prefs={"language": "zh_CN", "theme": "dark", "sensor_type": "box"}
|
prefs={"language": "zh_CN", "theme": "dark", "sensor_type": "box"}
|
||||||
@@ -3023,34 +3023,55 @@ def main() -> int:
|
|||||||
other_window.brand_bar.btn_playback.click()
|
other_window.brand_bar.btn_playback.click()
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
check(
|
check(
|
||||||
other_window._load_replay_video(bare_video) is False,
|
other_window._load_replay_video(bare_video) is True,
|
||||||
"a video with no forces.csv beside it was accepted as a recording",
|
"a playable video with no recording beside it was refused",
|
||||||
)
|
)
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
|
check(
|
||||||
|
other_window.replay.has_video() and other_window.replay.is_video_only(),
|
||||||
|
"the video is not playable as a video",
|
||||||
|
)
|
||||||
check(
|
check(
|
||||||
not other_window.replay.is_loaded(),
|
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(
|
check(
|
||||||
not other_window.toolbar.btn_run.isEnabled(),
|
other_window.toolbar.btn_run.isEnabled(),
|
||||||
"the transport is offered although there is nothing to play",
|
"the transport is not offered for a video that can be played",
|
||||||
)
|
)
|
||||||
check(
|
check(
|
||||||
"forces.csv" in other_window.toolbar.btn_run.toolTip()
|
t("toolbar.playback_video_only") in other_window.toolbar.btn_run.toolTip(),
|
||||||
or "记录" in other_window.toolbar.btn_run.toolTip(),
|
f"the transport does not say the charts are empty: "
|
||||||
f"the transport does not say why it cannot play: "
|
|
||||||
f"{other_window.toolbar.btn_run.toolTip()!r}",
|
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(
|
check(
|
||||||
other_window.act_recompute.isEnabled(),
|
other_window.act_recompute.isEnabled(),
|
||||||
"a chosen video cannot be recomputed either: the operator has no way to view it",
|
"a chosen video cannot be recomputed either: the operator has no way to view it",
|
||||||
)
|
)
|
||||||
check(
|
other_window._stop_replay_activity()
|
||||||
"forces.csv" in other_window.arrow_panel.image_label.text()
|
app.processEvents()
|
||||||
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.close()
|
other_window.close()
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
replay_window.activateWindow()
|
replay_window.activateWindow()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from orisys_app_shared.record.playback import (
|
|||||||
RecordingFrame,
|
RecordingFrame,
|
||||||
RecordingInfo,
|
RecordingInfo,
|
||||||
RecordingReader,
|
RecordingReader,
|
||||||
|
VideoOnlySource,
|
||||||
list_recordings,
|
list_recordings,
|
||||||
)
|
)
|
||||||
from orisys_app_shared.record.visualization3d import (
|
from orisys_app_shared.record.visualization3d import (
|
||||||
@@ -43,6 +44,7 @@ __all__ = [
|
|||||||
"RecordingFrame",
|
"RecordingFrame",
|
||||||
"RecordingInfo",
|
"RecordingInfo",
|
||||||
"RecordingReader",
|
"RecordingReader",
|
||||||
|
"VideoOnlySource",
|
||||||
"Visualization3DRecord",
|
"Visualization3DRecord",
|
||||||
"build_visualization3d_block",
|
"build_visualization3d_block",
|
||||||
"format_value",
|
"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]:
|
def list_recordings(root: str | Path) -> list[Path]:
|
||||||
base = Path(root)
|
base = Path(root)
|
||||||
if not base.is_dir():
|
if not base.is_dir():
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from orisys_app_shared.record import (
|
|||||||
ExperimentRecorder,
|
ExperimentRecorder,
|
||||||
RecordConfig,
|
RecordConfig,
|
||||||
RecordingReader,
|
RecordingReader,
|
||||||
|
VideoOnlySource,
|
||||||
list_recordings,
|
list_recordings,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -235,6 +236,84 @@ def test_list_recordings_finds_directories_newest_first(recording):
|
|||||||
assert recording in found
|
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
|
if __name__ == "__main__": # pragma: no cover - manual runner
|
||||||
import shutil as _shutil
|
import shutil as _shutil
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user