207 lines
6.7 KiB
Python
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()
|