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:
@@ -2086,15 +2086,16 @@ def test_the_live_view_keeps_the_tracker_reset(make_window):
|
||||
window.close()
|
||||
|
||||
|
||||
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.
|
||||
def test_a_bare_video_plays_even_though_it_is_not_a_recording(make_window, record_root):
|
||||
"""A clip with no forces.csv beside it is still something to watch.
|
||||
|
||||
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.
|
||||
An operator who points at a video expects the transport to play it. Opening the
|
||||
video's own folder as a *recording* used to "succeed" against the tolerant reader
|
||||
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 numpy as np
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
@@ -2106,34 +2107,82 @@ def test_a_video_that_is_not_a_recording_is_refused_with_a_reason(
|
||||
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.write(np.full((48, 64, 3), index * 8, 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()
|
||||
try:
|
||||
window.brand_bar.btn_playback.click()
|
||||
QApplication.processEvents()
|
||||
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, (
|
||||
"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()
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
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()
|
||||
assert not window.replay.has_video()
|
||||
assert not window.toolbar.btn_run.isEnabled(), (
|
||||
"the transport is offered although there is nothing to play"
|
||||
)
|
||||
reason = window.statusBar().currentMessage()
|
||||
assert "forces.csv" in reason or "记录" in reason, reason
|
||||
# The way forward for a video with no 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"
|
||||
)
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_a_video_inside_a_recording_opens_the_recording_and_plays(
|
||||
|
||||
Reference in New Issue
Block a user