Files
Artemis-Eapil/tests/unit/utils/test_video_validation.py
T

207 lines
6.7 KiB
Python

"""Reject corrupt recording timelines and recover only actually captured frames."""
import asyncio
import json
from pathlib import Path
import subprocess
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from artemis.utils import video
def metadata(**updates):
return {
"streams": [
{
"width": 640,
"height": 360,
"duration": "1.0",
"start_time": "0",
"nb_frames": "15",
"avg_frame_rate": "15/1",
}
],
"format": {"duration": "1.0"},
"packets": [{"pts_time": "0.0", "flags": "K__"}, {"pts_time": "0.9", "flags": "___"}],
**updates,
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"change",
["microsecond-duration", "pts-outside-duration", "nan-duration", "no-packets", "no-dimensions"],
)
async def test_probe_rejects_invalid_timeline_despite_valid_container_exit(monkeypatch, change):
payload = metadata()
stream = payload["streams"][0]
if change == "microsecond-duration":
stream.update(duration="0.000375", avg_frame_rate="56000/3", nb_frames="7")
elif change == "pts-outside-duration":
payload["packets"][-1]["pts_time"] = "3"
elif change == "nan-duration":
stream["duration"] = "NaN"
elif change == "no-packets":
payload["packets"] = []
else:
stream["width"] = 0
process = SimpleNamespace(
returncode=0, communicate=AsyncMock(return_value=(json.dumps(payload).encode(), b""))
)
monkeypatch.setattr(video.asyncio, "create_subprocess_exec", AsyncMock(return_value=process))
assert await video.probe_video_segment(Path("invalid.mp4")) == {}
@pytest.mark.asyncio
async def test_probe_accepts_vfr_timing_without_inventing_extra_duration(monkeypatch):
payload = metadata()
payload["streams"][0].update(duration="8", avg_frame_rate="1/4", nb_frames="2")
payload["packets"][-1]["pts_time"] = "7.9"
process = SimpleNamespace(
returncode=0, communicate=AsyncMock(return_value=(json.dumps(payload).encode(), b""))
)
monkeypatch.setattr(video.asyncio, "create_subprocess_exec", AsyncMock(return_value=process))
assert (await video.probe_video_segment(Path("valid.mp4")))["duration"] == 8
@pytest.mark.asyncio
async def test_remux_never_publishes_invalid_output_and_preserves_source(tmp_path, monkeypatch):
source, output = tmp_path / "source.mkv", tmp_path / "recording.mp4"
source.write_bytes(b"original recording")
commands = []
async def spawn(*command, **_kwargs):
commands.append(command)
Path(command[-1]).write_bytes(b"nonempty but invalid")
return SimpleNamespace(returncode=0, communicate=AsyncMock(return_value=(b"", b"")))
monkeypatch.setattr(video.asyncio, "create_subprocess_exec", spawn)
monkeypatch.setattr(video, "probe_video_segment", AsyncMock(return_value={}))
assert not await video.remux_recording_to_mp4(source, output)
assert source.read_bytes() == b"original recording"
assert not output.exists()
assert not output.with_name("recording.part.mp4").exists()
assert len(commands) == 2
fallback = commands[1]
assert fallback[fallback.index("-fps_mode") + 1] == "passthrough"
assert "-r" not in fallback
assert "setpts=PTS-STARTPTS" in fallback
@pytest.mark.asyncio
async def test_bounded_media_wait_reaps_process_on_timeout():
async def stalled():
await asyncio.Future()
process = SimpleNamespace(
returncode=None, communicate=stalled, kill=Mock(), wait=AsyncMock(return_value=-9)
)
with pytest.raises(TimeoutError):
await video._communicate_video_process(process, 0.01)
process.kill.assert_called_once()
process.wait.assert_awaited_once()
@pytest.mark.asyncio
async def test_strict_scrcpy_start_rejects_eof_without_recording_marker():
reader = asyncio.StreamReader()
reader.feed_data(b"INFO: connection initialized\n")
reader.feed_eof()
process = SimpleNamespace(stdout=reader, wait=AsyncMock(return_value=0))
with pytest.raises(OSError, match="未确认开始录制"):
await video.await_scrcpy_first_frame(process, 100, require_marker=True)
@pytest.mark.asyncio
async def test_strict_scrcpy_start_rejects_silent_process_timeout():
process = SimpleNamespace(stdout=asyncio.StreamReader())
with pytest.raises(TimeoutError, match="启动时限"):
await video.await_scrcpy_first_frame(process, 100, timeout=0.01, require_marker=True)
@pytest.mark.asyncio
async def test_strict_scrcpy_start_accepts_actual_marker():
reader = asyncio.StreamReader()
reader.feed_data(b"INFO: Recording started to file: recording.mkv\n")
process = SimpleNamespace(stdout=reader)
assert await video.await_scrcpy_first_frame(process, 100, require_marker=True) >= 100
def run_media(*command):
return subprocess.run(command, check=True, capture_output=True, timeout=30).stdout
def frame_times(path):
payload = json.loads(
run_media(
video.get_ffprobe_path(),
"-v",
"error",
"-select_streams",
"v:0",
"-show_frames",
"-show_entries",
"frame=pts_time",
"-of",
"json",
str(path),
)
)
return [float(frame["pts_time"]) for frame in payload["frames"]]
@pytest.mark.asyncio
async def test_real_remux_recovers_pts_without_filling_missing_capture_time(tmp_path):
ffmpeg = video.get_ffmpeg_path()
clean = tmp_path / "clean.mkv"
corrupt = tmp_path / "corrupt.mp4"
restored = tmp_path / "restored.mp4"
run_media(
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-f",
"lavfi",
"-i",
"testsrc2=size=64x96:rate=10:duration=0.7",
"-c:v",
"libx264",
"-bf",
"0",
"-pix_fmt",
"yuv420p",
str(clean),
)
# Preserve original frame PTS while compressing decode timestamps to 1 ms.
run_media(
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
str(clean),
"-map",
"0:v:0",
"-c",
"copy",
"-bsf:v",
"setts=dts=N:duration=1",
str(corrupt),
)
assert await video.probe_video_segment(corrupt) == {}
captured = frame_times(corrupt)
assert await video.remux_recording_to_mp4(corrupt, restored)
result = await video.probe_video_segment(restored)
assert 0.4 <= result["duration"] <= 0.8
recovered = frame_times(restored)
assert recovered
assert len(recovered) <= len(captured)
assert recovered == pytest.approx(captured[: len(recovered)], abs=0.001)
assert corrupt.exists()