from __future__ import annotations import asyncio import threading from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from typing import Any import pytest import cmvr_edge_ai.detection.video as video_module from cmvr_edge_ai.contracts import CameraIntrinsics, ImageFrame from cmvr_edge_ai.core import ComponentContext, Emission, Envelope from cmvr_edge_ai.detection.video import ( DecodedVideoFrame, VideoDecodeError, VideoDecoderOperator, ) @dataclass class _BackendLog: created_codecs: list[str] = field(default_factory=list) decoded_packets: list[bytes] = field(default_factory=list) worker_threads: list[int] = field(default_factory=list) close_count: int = 0 class _FakeBackend: def __init__(self, codec: str, log: _BackendLog) -> None: self._log = log self._closed = False log.created_codecs.append(codec) log.worker_threads.append(threading.get_ident()) def decode(self, data: bytes) -> tuple[DecodedVideoFrame, ...]: if self._closed: raise RuntimeError("fake decoder is closed") self._log.decoded_packets.append(data) self._log.worker_threads.append(threading.get_ident()) if data == b"decode-error": raise VideoDecodeError("synthetic packet discontinuity") if data == b"buffered": return () if data == b"two-frames": return ( DecodedVideoFrame(b"first", 2, 2), DecodedVideoFrame(b"second", 3, 2), ) return (DecodedVideoFrame(b"decoded:" + data, 2, 2),) def close(self) -> None: if not self._closed: self._closed = True self._log.close_count += 1 self._log.worker_threads.append(threading.get_ident()) def _factory(log: _BackendLog): # type: ignore[no-untyped-def] return lambda codec: _FakeBackend(codec, log) def _image( *, codec: str = "H264", key_frame: bool = False, width: int = 640, height: int = 480, data: bytes = b"packet", intrinsics: CameraIntrinsics | None = None, ) -> ImageFrame: return ImageFrame( data=data, width=width, height=height, pixel_format="encoded", codec=codec, is_key_frame=key_frame, intrinsics=intrinsics, ) def _envelope( image: Any, *, source_id: str = "camera-1", session_id: str | None = "session-1", sequence: int = 0, ) -> Envelope[Any]: return Envelope( payload=image, schema_name="ImageFrame", schema_version=1, source_id=source_id, session_id=session_id, sequence=sequence, captured_at_ns=123, received_at_ns=456, deadline_ns=999, trace_id="trace-1", attributes={"transport": "test"}, ) async def _setup(operator: VideoDecoderOperator, executor: Any = None) -> None: metadata = {} if executor is None else {"thread_executor": executor} await operator.setup( ComponentContext( pipeline_id="test-pipeline", node_id="decoder", shutdown_event=asyncio.Event(), metadata=metadata, ) ) async def _keep_restricted_event_loop_responsive() -> None: """Provide wakeups where sandboxed self-pipe notifications are unavailable.""" while True: await asyncio.sleep(0.01) def _run_with_ticker(coroutine: Any) -> Any: async def runner() -> Any: loop = asyncio.get_running_loop() host_executor = ThreadPoolExecutor(max_workers=2) loop.set_default_executor(host_executor) ticker = asyncio.create_task(_keep_restricted_event_loop_responsive()) try: return await coroutine finally: ticker.cancel() await asyncio.gather(ticker, return_exceptions=True) host_executor.shutdown(wait=True, cancel_futures=True) # Avoid a second shutdown by asyncio.run() after ownership above. loop._default_executor = None # type: ignore[attr-defined] return asyncio.run(runner()) def test_raw_frame_is_passed_through_without_loading_a_backend() -> None: factory_calls: list[str] = [] def forbidden_factory(codec: str): # type: ignore[no-untyped-def] factory_calls.append(codec) raise AssertionError("raw frames must not construct a decoder") operator = VideoDecoderOperator("decoder", {}, backend_factory=forbidden_factory) envelope = _envelope( ImageFrame( data=b"raw-bgr", width=2, height=2, pixel_format="BGR8", codec="none", ) ) async def exercise() -> Emission: await _setup(operator) result = await operator.process(envelope) await operator.stop() assert isinstance(result, Emission) return result emission = _run_with_ticker(exercise()) assert emission.port == "frames" assert emission.envelope is envelope assert factory_calls == [] def test_waits_for_keyframe_decodes_off_loop_and_preserves_envelope_metadata() -> None: log = _BackendLog() operator = VideoDecoderOperator("decoder", {}, backend_factory=_factory(log)) intrinsics = CameraIntrinsics(100.0, 101.0, 50.0, 51.0) event_loop_thread = threading.get_ident() async def exercise(): # type: ignore[no-untyped-def] with ThreadPoolExecutor(max_workers=1) as executor: await _setup(operator, executor) dropped = await operator.process( _envelope(_image(key_frame=False), sequence=10) ) key_envelope = _envelope( _image( codec=".H264", key_frame=True, data=b"key", intrinsics=intrinsics, ), sequence=11, ) decoded = await operator.process(key_envelope) health = await operator.health() await operator.stop() return dropped, key_envelope, decoded, health dropped, original, result, health = _run_with_ticker(exercise()) assert dropped is None assert isinstance(result, tuple) and len(result) == 1 emission = result[0] assert emission.port == "frames" assert isinstance(emission.envelope.payload, ImageFrame) assert emission.envelope.payload == ImageFrame( data=b"decoded:key", width=2, height=2, pixel_format="BGR8", codec="none", is_key_frame=False, intrinsics=intrinsics, ) assert emission.envelope.source_id == original.source_id assert emission.envelope.session_id == original.session_id assert emission.envelope.sequence == original.sequence assert emission.envelope.trace_id == original.trace_id assert emission.envelope.captured_at_ns == original.captured_at_ns assert emission.envelope.received_at_ns == original.received_at_ns assert emission.envelope.attributes == original.attributes assert log.created_codecs == ["h264"] assert log.decoded_packets == [b"key"] assert log.close_count == 1 assert log.worker_threads assert all(thread_id != event_loop_thread for thread_id in log.worker_threads) assert "decoded_frames=1" in health.detail assert "dropped_until_keyframe=1" in health.detail @pytest.mark.parametrize( ("change", "expected_codec"), [ ({"source_id": "camera-2"}, "h264"), ({"session_id": "session-2"}, "h264"), ({"codec": "H265"}, "hevc"), ({"width": 800}, "h264"), ({"height": 600}, "h264"), ({"sequence": 4}, "h264"), ], ) def test_stream_identity_change_or_sequence_gap_resets_and_waits_for_keyframe( change: dict[str, Any], expected_codec: str, ) -> None: log = _BackendLog() operator = VideoDecoderOperator("decoder", {}, backend_factory=_factory(log)) async def exercise(): # type: ignore[no-untyped-def] await _setup(operator) first = await operator.process( _envelope(_image(key_frame=True, data=b"initial"), sequence=1) ) source_id = str(change.get("source_id", "camera-1")) session_id = change.get("session_id", "session-1") sequence = int(change.get("sequence", 2)) codec = str(change.get("codec", "H264")) width = int(change.get("width", 640)) height = int(change.get("height", 480)) changed_image = _image( codec=codec, key_frame=False, width=width, height=height, data=b"must-drop", ) dropped = await operator.process( _envelope( changed_image, source_id=source_id, session_id=session_id, sequence=sequence, ) ) recovered = await operator.process( _envelope( _image( codec=codec, key_frame=True, width=width, height=height, data=b"recovered", ), source_id=source_id, session_id=session_id, sequence=sequence + 1, ) ) await operator.stop() return first, dropped, recovered first, dropped, recovered = _run_with_ticker(exercise()) assert isinstance(first, tuple) assert dropped is None assert isinstance(recovered, tuple) assert log.created_codecs == ["h264", expected_codec] assert log.decoded_packets == [b"initial", b"recovered"] assert log.close_count == 2 def test_recoverable_decode_error_resets_and_suppresses_until_next_keyframe() -> None: log = _BackendLog() operator = VideoDecoderOperator("decoder", {}, backend_factory=_factory(log)) async def exercise(): # type: ignore[no-untyped-def] await _setup(operator) failed = await operator.process( _envelope( _image(key_frame=True, data=b"decode-error"), sequence=1, ) ) suppressed = await operator.process( _envelope(_image(key_frame=False, data=b"p-frame"), sequence=2) ) recovered = await operator.process( _envelope(_image(key_frame=True, data=b"new-key"), sequence=3) ) health = await operator.health() await operator.stop() await operator.stop() return failed, suppressed, recovered, health failed, suppressed, recovered, health = _run_with_ticker(exercise()) assert failed is None assert suppressed is None assert isinstance(recovered, tuple) assert log.created_codecs == ["h264", "h264"] assert log.decoded_packets == [b"decode-error", b"new-key"] assert log.close_count == 2 assert "decode_errors=1" in health.detail assert "resets=1" in health.detail def test_backend_can_emit_zero_or_multiple_decoded_frames() -> None: log = _BackendLog() operator = VideoDecoderOperator("decoder", {}, backend_factory=_factory(log)) async def exercise(): # type: ignore[no-untyped-def] await _setup(operator) buffered = await operator.process( _envelope(_image(key_frame=True, data=b"buffered"), sequence=1) ) multiple = await operator.process( _envelope(_image(key_frame=False, data=b"two-frames"), sequence=2) ) await operator.stop() return buffered, multiple buffered, multiple = _run_with_ticker(exercise()) assert buffered is None assert isinstance(multiple, tuple) and len(multiple) == 2 assert [item.envelope.payload.data for item in multiple] == [b"first", b"second"] def test_raw_transition_closes_an_existing_encoded_decoder() -> None: log = _BackendLog() operator = VideoDecoderOperator("decoder", {}, backend_factory=_factory(log)) raw_envelope = _envelope( ImageFrame(b"raw", 1, 1, "BGR8", codec="raw"), sequence=2, ) async def exercise(): # type: ignore[no-untyped-def] await _setup(operator) await operator.process( _envelope(_image(key_frame=True, data=b"key"), sequence=1) ) raw = await operator.process(raw_envelope) await operator.stop() return raw result = _run_with_ticker(exercise()) assert isinstance(result, Emission) assert result.envelope is raw_envelope assert log.close_count == 1 def test_rejects_wrong_payload_unsupported_codec_and_invalid_dimensions() -> None: log = _BackendLog() operator = VideoDecoderOperator("decoder", {}, backend_factory=_factory(log)) async def exercise() -> None: with pytest.raises(TypeError, match="expected ImageFrame"): await operator.process(_envelope("not-an-image")) with pytest.raises(ValueError, match="unsupported encoded video codec"): await operator.process( _envelope(_image(codec="vp9", key_frame=True), sequence=1) ) with pytest.raises(ValueError, match="dimensions must be positive"): await operator.process( _envelope(_image(width=0, key_frame=True), sequence=1) ) _run_with_ticker(exercise()) assert log.created_codecs == [] def test_default_backend_imports_pyav_only_for_an_encoded_keyframe( monkeypatch: pytest.MonkeyPatch, ) -> None: imported: list[str] = [] def missing_import(name: str): # type: ignore[no-untyped-def] imported.append(name) raise ImportError("synthetic missing dependency") monkeypatch.setattr(video_module, "import_module", missing_import) operator = VideoDecoderOperator("decoder", {}) raw = _envelope(ImageFrame(b"raw", 1, 1, "BGR8", codec="none")) async def exercise() -> None: assert isinstance(await operator.process(raw), Emission) assert imported == [] assert ( await operator.process(_envelope(_image(key_frame=False), sequence=1)) is None ) assert imported == [] with pytest.raises(RuntimeError, match="optional video dependencies"): await operator.process(_envelope(_image(key_frame=True), sequence=2)) _run_with_ticker(exercise()) assert imported == ["av"] def test_setup_rejects_a_non_executor_thread_executor() -> None: operator = VideoDecoderOperator("decoder", {}) async def exercise() -> None: with pytest.raises(TypeError, match="must be an Executor"): await _setup(operator, object()) _run_with_ticker(exercise()) def test_constructor_rejects_unknown_parameters() -> None: with pytest.raises(ValueError, match="does not support parameters: typo"): VideoDecoderOperator("decoder", {"typo": True})