"""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()