"""Video: SDP text, ffmpeg argv, the downscale helper, and the start ordering. Nothing here spawns ffmpeg or touches a drone. """ import io import subprocess from pathlib import Path import pytest from PIL import Image from mcbebop.media import video def test_sdp_describes_our_own_port_not_the_drone(): # c= must be the wildcard: naming the drone makes ffmpeg wait to be sent # to, which is backwards. The port is ours, the one we named in the # handshake, and ffmpeg binds it. text = video.sdp_text() assert "c=IN IP4 0.0.0.0" in text assert f"m=video {video.STREAM_PORT} RTP/AVP 96" in text assert "a=rtpmap:96 H264/90000" in text assert text.startswith("v=0\n") assert "rtsp" not in text.lower() def test_sdp_lines_are_in_order_and_complete(): keys = [line.split("=", 1)[0] for line in video.sdp_text().strip().splitlines()] assert keys == ["v", "o", "s", "c", "t", "m", "a"] def test_sdp_port_is_overridable(): assert "m=video 60000 RTP/AVP 96" in video.sdp_text(port=60000) def test_write_sdp_creates_parents(tmp_path): out = video.write_sdp(tmp_path / "nested" / "bebop.sdp") assert out.read_text() == video.sdp_text() @pytest.fixture def sdp(tmp_path): return tmp_path / "bebop.sdp" def test_every_sink_whitelists_rtp_and_udp(sdp, tmp_path): # ffmpeg refuses to open rtp/udp referenced from a file-based SDP unless # they are explicitly whitelisted, and the failure reads like a bad file. sinks = [ video.view(sdp), video.record(sdp, tmp_path / "out.mp4"), video.snapshot(sdp, tmp_path / "out.png"), ] for sink in sinks: argv = sink.argv assert "-protocol_whitelist" in argv assert argv[argv.index("-protocol_whitelist") + 1] == "file,rtp,udp" def test_record_copies_the_stream_rather_than_re_encoding(sdp, tmp_path): argv = video.record(sdp, tmp_path / "out.mp4").argv assert argv[argv.index("-c") + 1] == "copy" assert "-t" not in argv assert argv[-1] == str(tmp_path / "out.mp4") def test_record_with_a_duration_passes_t(sdp, tmp_path): argv = video.record(sdp, tmp_path / "out.mp4", seconds=12.5).argv assert argv[argv.index("-t") + 1] == "12.5" def test_snapshot_skips_the_settle_period_and_asks_for_one_image(sdp, tmp_path): # -ss discards the frames that reference an SPS/PPS we have not seen yet # ("non-existing PPS 0") and lets auto-exposure settle. -update is what # lets a single filename work at all; image2 otherwise demands %03d. argv = video.snapshot(sdp, tmp_path / "f.png", settle_seconds=3).argv assert argv[argv.index("-ss") + 1] == "3" assert argv[argv.index("-frames:v") + 1] == "1" assert argv[argv.index("-update") + 1] == "1" # -ss has to come after -i here: we are discarding received frames, not # seeking in a file. assert argv.index("-i") < argv.index("-ss") def test_snapshot_settle_default_is_not_zero(sdp, tmp_path): argv = video.snapshot(sdp, tmp_path / "f.png").argv assert float(argv[argv.index("-ss") + 1]) > 0 def test_view_uses_ffplay_and_low_latency_flags(sdp): sink = video.view(sdp, title="hello") assert sink.tool == "ffplay" assert sink.argv[0] == "ffplay" assert "-nobuffer" not in sink.argv # it is a value, not a flag assert sink.argv[sink.argv.index("-fflags") + 1] == "nobuffer" assert sink.argv[sink.argv.index("-window_title") + 1] == "hello" def test_no_sink_opens_a_stream_url(sdp, tmp_path): # Firmware 4.7.1 refuses port 554 entirely, so an rtsp:// URL anywhere # here would be a regression to what pyparrot does. Match on the scheme # rather than the bare word: pytest's own tmp_path is named after the test. sinks = (video.view(sdp), video.record(sdp, tmp_path / "a.mp4"), video.snapshot(sdp, tmp_path / "a.png")) for sink in sinks: assert not any(part.startswith(("rtsp://", "rtmp://", "http://")) for part in sink.argv) assert not any(":554" in part for part in sink.argv) # --- downscale --------------------------------------------------------------- def _png(width: int, height: int) -> bytes: buf = io.BytesIO() Image.new("RGB", (width, height), (40, 90, 140)).save(buf, format="PNG") return buf.getvalue() def _jpeg(width: int, height: int) -> bytes: buf = io.BytesIO() Image.new("RGB", (width, height), (40, 90, 140)).save(buf, format="JPEG") return buf.getvalue() def test_downscale_shrinks_and_keeps_aspect_ratio(): out = video.downscale(_png(856, 480), 640) with Image.open(io.BytesIO(out)) as im: assert im.width == 640 assert im.height == round(480 * 640 / 856) assert im.format == "PNG" def test_downscale_leaves_a_narrow_image_byte_for_byte(): data = _png(320, 180) assert video.downscale(data, 640) is data def test_downscale_at_exactly_max_width_is_a_passthrough(): data = _png(640, 360) assert video.downscale(data, 640) is data def test_downscale_preserves_jpeg_format(): out = video.downscale(_jpeg(856, 480), 200) with Image.open(io.BytesIO(out)) as im: assert im.format == "JPEG" assert im.width == 200 def test_downscale_handles_rgba_into_jpeg(): buf = io.BytesIO() Image.new("RGBA", (856, 480), (1, 2, 3, 255)).save(buf, format="PNG") out = video.downscale(buf.getvalue(), 100) with Image.open(io.BytesIO(out)) as im: assert im.width == 100 def test_downscale_actually_saves_bytes(): big = _png(856, 480) assert len(video.downscale(big, 160)) < len(big) def test_downscale_rejects_a_nonsense_width(): with pytest.raises(ValueError): video.downscale(_png(856, 480), 0) # --- StreamSession ----------------------------------------------------------- class FakeProc: def __init__(self, argv): self.argv = argv self.terminated = False self.killed = False self._returncode = None def poll(self): return self._returncode def terminate(self): self.terminated = True self._returncode = 0 def kill(self): self.killed = True self._returncode = -9 def wait(self, timeout=None): return 0 @pytest.fixture def harness(monkeypatch, tmp_path): """Record the order of (subprocess start, command send) events.""" events: list[tuple] = [] procs: list[FakeProc] = [] def fake_popen(argv, *a, **kw): events.append(("popen", argv)) proc = FakeProc(argv) procs.append(proc) return proc monkeypatch.setattr(video.subprocess, "Popen", fake_popen) monkeypatch.setattr(video.shutil, "which", lambda tool: f"/usr/bin/{tool}") async def send(name, args): events.append(("send", name, args)) return {"ok": True} return events, procs, send async def test_the_receiver_binds_before_the_stream_is_enabled(harness, tmp_path): # RTP is connectionless. Enable the stream first and the opening packets, # which carry the SPS/PPS, hit a closed port and are gone. events, _, send = harness sink = video.snapshot(tmp_path / "s.sdp", tmp_path / "f.png") async with video.StreamSession(sink, send): pass assert events[0][0] == "popen" assert events[1] == ("send", video.VIDEO_ENABLE, {"enable": 1}) async def test_the_stream_is_disabled_and_the_sink_stopped_on_exit(harness, tmp_path): events, procs, send = harness sink = video.snapshot(tmp_path / "s.sdp", tmp_path / "f.png") async with video.StreamSession(sink, send): pass assert ("send", video.VIDEO_ENABLE, {"enable": 0}) in events assert procs[0].terminated async def test_exposure_is_set_then_restored(harness, tmp_path): events, _, send = harness sink = video.snapshot(tmp_path / "s.sdp", tmp_path / "f.png") state = {video.EXPOSURE_STATE_KEY: 0.25} async with video.StreamSession( sink, send, exposure=1.0, read_state=lambda keys: {k: state[k] for k in keys} ): pass sends = [e for e in events if e[0] == "send"] assert sends[0] == ("send", video.EXPOSURE, {"value": 1.0}) assert sends[-1] == ("send", video.EXPOSURE, {"value": 0.25}) async def test_exposure_outside_the_aircraft_range_is_refused(harness, tmp_path): _, _, send = harness with pytest.raises(ValueError): await video.set_exposure(send, 3.0) async def test_a_refused_videoenable_stops_the_subprocess(harness, tmp_path): _, procs, _ = harness sink = video.snapshot(tmp_path / "s.sdp", tmp_path / "f.png") async def refusing(name, args): raise RuntimeError("the drone did not acknowledge VideoEnable") with pytest.raises(RuntimeError): async with video.StreamSession(sink, refusing): pass assert procs and procs[0].terminated async def test_a_missing_ffmpeg_is_reported_before_anything_starts(monkeypatch, tmp_path): monkeypatch.setattr(video.shutil, "which", lambda tool: None) started = [] monkeypatch.setattr(video.subprocess, "Popen", lambda *a, **kw: started.append(a)) async def send(name, args): raise AssertionError("must not reach the drone") sink = video.snapshot(tmp_path / "s.sdp", tmp_path / "f.png") with pytest.raises(video.VideoUnavailable): async with video.StreamSession(sink, send): pass assert not started async def test_a_sink_that_outlives_sigterm_is_killed(harness, monkeypatch, tmp_path): _, procs, send = harness sink = video.view(tmp_path / "s.sdp") class Stubborn(FakeProc): def terminate(self): self.terminated = True # but stays running def wait(self, timeout=None): if timeout is not None: raise subprocess.TimeoutExpired("ffplay", timeout) return 0 def popen(argv, *a, **kw): procs.append(Stubborn(argv)) return procs[-1] monkeypatch.setattr(video.subprocess, "Popen", popen) async with video.StreamSession(sink, send): pass assert procs[-1].killed async def test_wait_returns_none_while_the_sink_is_still_running(harness, tmp_path): _, _, send = harness sink = video.view(tmp_path / "s.sdp") session = video.StreamSession(sink, send) async with session: session.proc.wait = lambda timeout=None: (_ for _ in ()).throw( subprocess.TimeoutExpired("ffplay", timeout or 0) ) assert await session.wait(0.01) is None def test_the_module_does_not_import_the_parallel_streams(): # media/ must stay loadable while arsdk/ and protocol/ are still being # written; it talks to them through a callable the caller supplies. source = Path(video.__file__).read_text() assert "import mcbebop.arsdk" not in source assert "from mcbebop.arsdk" not in source assert "from mcbebop.protocol" not in source async def test_a_failed_start_puts_the_exposure_back(harness, tmp_path): # __aexit__ never runs for a failed __aenter__, so the restore has to # happen on the way out of __aenter__ or the next snapshot inherits it. events, procs, _ = harness sink = video.snapshot(tmp_path / "s.sdp", tmp_path / "f.png") async def send(name, args): events.append(("send", name, args)) if name == video.VIDEO_ENABLE: raise RuntimeError("the drone did not acknowledge VideoEnable") with pytest.raises(RuntimeError): async with video.StreamSession( sink, send, exposure=1.0, read_state=lambda keys: {video.EXPOSURE_STATE_KEY: -0.5} ): pass sends = [e for e in events if e[0] == "send"] assert sends[0] == ("send", video.EXPOSURE, {"value": 1.0}) assert sends[-1] == ("send", video.EXPOSURE, {"value": -0.5}) assert procs[0].terminated async def test_a_rejected_exposure_does_not_trip_the_restore_path(harness, tmp_path): # The exposure was never changed, so there is nothing to put back and no # NameError on the way out either. events, procs, send = harness sink = video.snapshot(tmp_path / "s.sdp", tmp_path / "f.png") with pytest.raises(ValueError): async with video.StreamSession(sink, send, exposure=99.0): pass assert not [e for e in events if e[0] == "send"] assert procs[0].terminated