"""Replay images / video to the SUT's V4L2 file frame source. Two replay modes: 1. Image-set replay (FT-P-01, FT-P-05) — emit a sequence of JPEG / PNG still images at a configurable rate to the file frame source path the SUT polls. 2. Video replay (FT-P-02, FT-P-04, FT-N-01..04, NFT-PERF-*) — decode an MP4 with OpenCV and emit frames at the encoded FPS (or a user-supplied rate for fast-forward). The actual frame-source path inside the SUT container is configured via the ``ONBOARD_FRAME_SOURCE_PATH`` environment variable on the SUT — the runner writes to a shared tmpfs volume mounted at the same path inside both containers. This file currently provides the public surface used by per-scenario tests; concrete implementations land alongside their consuming test tasks (AZ-407 onward). The intent is that `FrameSourceReplayer` is a stable API the test specs can rely on while the underlying replay strategy is filled in incrementally. """ from __future__ import annotations from dataclasses import dataclass from pathlib import Path from typing import Protocol @dataclass(frozen=True) class ReplayCadence: """Frame-rate / pace configuration for a replay session.""" fps: float = 10.0 realtime: bool = True class FrameSink(Protocol): """Abstract destination for replayed frames (file path or memory queue).""" def write_frame(self, jpeg_bytes: bytes, timestamp_ms: int) -> None: ... class FrameSourceReplayer: """Public surface for replaying frames into the SUT's frame-source path. AZ-407 (Static fixture builders) supplies the concrete still-image replay implementation; AZ-408 (Runtime synthetic-injection) supplies the video + injector variants. AZ-406 only commits to the contract. """ def __init__(self, sink: FrameSink, cadence: ReplayCadence | None = None) -> None: self._sink = sink self._cadence = cadence or ReplayCadence() def replay_image_directory(self, directory: Path) -> int: """Replay every image in ``directory`` (sorted by name). Returns count emitted. Raises NotImplementedError until AZ-407 lands. Tests that need this path should mark themselves @pytest.mark.skip(reason="awaiting AZ-407") until then; AC-1 (smoke) does not depend on this surface. """ raise NotImplementedError( "FrameSourceReplayer.replay_image_directory is owned by AZ-407 — " "AZ-406 supplies only the public surface." ) def replay_video(self, video_path: Path) -> int: """Replay an MP4 / .h264 file frame-by-frame. Returns count emitted. Raises NotImplementedError until AZ-408 lands. """ raise NotImplementedError( "FrameSourceReplayer.replay_video is owned by AZ-408 — " "AZ-406 supplies only the public surface." )