diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 5e57d2f..300bbab 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -102,6 +102,7 @@ "playback_tip": "Play this recording at 1× (space bar does the same); dragging the cursor 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_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", "reset": "Reset", "units": "Units", "units_newton": "Newton", @@ -205,6 +206,9 @@ "video_filter": "Recorded video (*.mp4 *.avi *.mov *.mkv);;All files (*)", "no_video": "This recording has no usable video segment", "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”.", + "video_only_panel": "This is not a recording (no forces.csv beside it)\nTo watch this video, use\nView ▸ Recompute forces from the video", + "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", "frame_dropped": "This frame never reached the video (the record queue overflowed)", "frame_unreadable": "This frame could not be decoded from the video", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 4244b05..0ba546e 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -102,6 +102,7 @@ "playback_tip": "按 1× 播放这段录制(空格键同效);拖动游标即暂停", "playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)", "playback_disabled": "先打开一段录制视频(左上「选择录制视频…」)", + "playback_needs_recording": "这段视频旁边没有记录(缺 forces.csv):回放需要一条记录;只想看视频请用 视图 ▸ 从视频重算力值", "reset": "重置", "units": "单位", "units_newton": "牛顿", @@ -205,6 +206,9 @@ "video_filter": "录制的视频 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*)", "no_video": "这段录制没有可用的视频分段", "open_failed": "无法打开该录制: {error}", + "not_a_recording": "「{name}」不是一条记录:同目录没有 forces.csv(一条记录 = 录像 + forces.csv + session.json)。回放需要记录;只想看这段视频请用「从视频重算力值」。", + "video_only_panel": "这不是一条记录(同目录缺少 forces.csv)\n只想看这段视频请用\n视图 ▸ 从视频重算力值", + "empty_recording": "这条记录没有可播放的画面(没有 raw_*.mp4 视频分段),因此无法回放", "none_found": "outputs/recordings 下没有找到录制", "frame_dropped": "该帧未被写入视频(记录队列曾溢出)", "frame_unreadable": "该帧无法从视频中解码", diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index b783b62..e51b9b2 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -901,15 +901,46 @@ 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 the recording a recorded video belongs to. + + 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. + """ video = Path(video) folder = video.parent if video.is_file() else video + if not (folder / "forces.csv").is_file(): + 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(): + # 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")) + self._refresh_replay_state() + self._notify(t("playback.not_a_recording", name=video.name)) + return False if not self._load_recording(folder): return False self.toolbar.set_video_path(str(video)) self._refresh_status_rail() return True + def _can_recompute(self) -> bool: + """Whether 「从视频重算」 has something to read: a recording, or a video file.""" + if self._view_preset != "replay": + return False + if self.replay.is_loaded(): + return True + named = self.toolbar.video_path() + return bool(named) and Path(named).is_file() + def _load_recording(self, directory: Path) -> bool: if self._view_preset != "replay": self._apply_view_preset("replay") @@ -951,10 +982,10 @@ class StudioMainWindow(QMainWindow): if segments: self.toolbar.set_video_path(str(segments[0])) self.toolbar.set_run_checked(self._play_timer.isActive()) - # Loading a recording is what makes a recompute pass possible at all. - self.act_recompute.setEnabled( - self._view_preset == "replay" and self.replay.is_loaded() - ) + # Loading a recording is what makes a recompute pass possible at all — but a + # chosen video file is enough to recompute *from*, even when it is not a + # recording: that is the way to look at a video that has no forces.csv. + self.act_recompute.setEnabled(self._can_recompute()) self._update_run_enabled() self._refresh_status_rail() @@ -987,7 +1018,10 @@ class StudioMainWindow(QMainWindow): def _start_playback(self) -> None: duration = self.replay.duration_s() if duration <= 0: + # A zero-length timeline is not something to play, and silently un-checking + # the button is how "pressing 回放 does nothing" felt to the operator. self.toolbar.set_run_checked(False) + self._notify(t("playback.empty_recording")) return position = self.replay.position() if position >= duration - 0.02: @@ -1045,7 +1079,7 @@ class StudioMainWindow(QMainWindow): run *forward*, because the chain is stateful, so it is a pass rather than a scrub. """ - wanted = bool(checked) and self._view_preset == "replay" and self.replay.is_loaded() + wanted = bool(checked) and self._can_recompute() if self.act_recompute.isChecked() != wanted: self.act_recompute.blockSignals(True) self.act_recompute.setChecked(wanted) @@ -1338,9 +1372,7 @@ class StudioMainWindow(QMainWindow): self.act_finger_3d.blockSignals(True) self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False) self.act_finger_3d.blockSignals(False) - self.act_recompute.setEnabled( - self._view_preset == "replay" and self.replay.is_loaded() - ) + self.act_recompute.setEnabled(self._can_recompute()) self._apply_arrow_rotation_for_sensor() def _current_source_kind(self) -> str: @@ -1486,9 +1518,16 @@ class StudioMainWindow(QMainWindow): # not a runnable sensor. While it plays it stays enabled so it can be # paused. loaded = self.replay.is_loaded() - self.toolbar.set_run_tooltip_reason( - "" if loaded else t("toolbar.playback_disabled") - ) + if loaded: + reason = "" + 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 + # leaving the operator pressing a dead button. + reason = t("toolbar.playback_needs_recording") + else: + reason = t("toolbar.playback_disabled") + self.toolbar.set_run_tooltip_reason(reason) self.toolbar.set_run_enabled(loaded or self._play_timer.isActive()) self.toolbar.set_record_enabled( self._running or self.recorder.is_recording diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index 991a8f7..58186a1 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -1994,6 +1994,118 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video( assert not window.toolbar.btn_run.isChecked(), "the transport stayed checked" +def test_a_video_that_is_not_a_recording_is_refused_with_a_reason( + make_window, record_root +): + """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 + folder as a recording used to *succeed* — and then the transport played a + zero-length timeline, which is what "pressing 回放 does nothing" was. + """ + import cv2 + import numpy as np + from PyQt5.QtWidgets import QApplication + + from orisys_app_shared.i18n import t + + bare = Path(record_root) / "bare_video.mp4" + writer = cv2.VideoWriter( + str(bare), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48) + ) + for index in range(30): + writer.write(np.full((48, 64, 3), index, dtype=np.uint8)) + writer.release() + assert bare.is_file() + + window = _show(make_window(), (1440, 880)) + window.brand_bar.btn_playback.click() + QApplication.processEvents() + assert not window.replay.is_loaded() + + assert window._load_replay_video(bare) is False, ( + "a video with no recording beside it was accepted as a recording" + ) + QApplication.processEvents() + assert not window.replay.is_loaded(), "an empty folder was opened as a recording" + assert not window.toolbar.btn_run.isEnabled(), ( + "the transport is offered although there is nothing to play" + ) + reason = window.toolbar.btn_run.toolTip() + assert "forces.csv" in reason or "recording" in reason.lower(), reason + # And what was missing, in the panel that would have shown the frames. + 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(), ( + "a chosen video cannot be recomputed either, so the operator has no way to " + "look at it" + ) + window.close() + + +def test_a_video_inside_a_recording_opens_the_recording_and_plays( + make_window, replay_recording +): + from PyQt5.QtWidgets import QApplication + + from orisys_app_shared.i18n import t + + window = _show(make_window(), (1440, 880)) + window.brand_bar.btn_playback.click() + QApplication.processEvents() + segments = sorted(Path(replay_recording).glob("raw_*.mp4")) + assert segments, "the fixture wrote no video segment" + assert window._load_replay_video(segments[0]) is True + QApplication.processEvents() + assert window.replay.is_loaded() + assert window.toolbar.btn_run.isEnabled(), "the transport is not offered" + assert t("toolbar.playback_needs_recording") not in window.toolbar.btn_run.toolTip(), ( + "a loaded recording is still described as one that cannot be played" + ) + window.toolbar.btn_run.setChecked(True) + QApplication.processEvents() + assert window._play_timer.isActive(), "pressing 回放 did not start playback" + window._pause_playback() + window.close() + + +def test_a_recording_without_a_video_says_it_cannot_be_played(make_window, record_root): + """The defensive case: a recording whose video segments are gone.""" + import csv + import json + + from PyQt5.QtWidgets import QApplication + + from orisys_app_shared.i18n import t + + directory = Path(record_root) / "no_video_recording" + directory.mkdir(parents=True, exist_ok=True) + (directory / "session.json").write_text( + json.dumps({"format_version": 1, "device": {"sensor_type": "finger"}}), + encoding="utf-8", + ) + with (directory / "forces.csv").open("w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(["session_id", "frame_id", "t_rel_s", "FN_raw"]) + writer.writerow([1, 1, 0.0, 1.0]) + + window = _show(make_window(), (1440, 880)) + window.brand_bar.btn_playback.click() + QApplication.processEvents() + if not window._load_recording(directory): + pytest.skip("this recording layout was refused before it could be played") + QApplication.processEvents() + assert window.replay.duration_s() <= 0.0 + window._start_playback() + QApplication.processEvents() + assert not window._play_timer.isActive() + assert t("playback.empty_recording")[:8] in window.statusBar().currentMessage(), ( + window.statusBar().currentMessage() + ) + window.close() + + # ------------------------------------------- selection readout and notices diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py index 40fc91d..5c47896 100644 --- a/apps/studio/tools/ui_check.py +++ b/apps/studio/tools/ui_check.py @@ -2992,6 +2992,61 @@ 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". + import cv2 as _cv2 + import numpy as _np + + bare_video = replay_root / "bare_video.mp4" + 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)) + bare_writer.release() + other_window = StudioMainWindow( + prefs={"language": "zh_CN", "theme": "dark", "sensor_type": "box"} + ) + other_window.resize(1280, 720) + other_window.show() + app.processEvents() + 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", + ) + app.processEvents() + check( + not other_window.replay.is_loaded(), + "an empty folder was opened as a recording", + ) + check( + not other_window.toolbar.btn_run.isEnabled(), + "the transport is offered although there is nothing to play", + ) + 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: " + f"{other_window.toolbar.btn_run.toolTip()!r}", + ) + 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.close() + app.processEvents() + replay_window.activateWindow() + app.processEvents() + replay = replay_window.replay replay_plots = replay_window.force_plots check(