|
| 1 | +import logging |
| 2 | + |
| 3 | +import pybase64 |
| 4 | +import valkey |
| 5 | +from prometheus_client import Counter, Histogram |
| 6 | +from visionapi.common_pb2 import MessageType |
| 7 | +from visionapi.sae_pb2 import SaeMessage |
| 8 | +from visionlib.pipeline import ValkeyPublisher |
| 9 | + |
| 10 | +from .config import SaeApiConfig |
| 11 | + |
| 12 | +logger = logging.getLogger(__name__) |
| 13 | + |
| 14 | +FRAMES_FORWARDED = Counter('sae_api_frames_forwarded', 'How many frames have been forwarded to the backend') |
| 15 | +FORWARD_CYCLE_DURATION = Histogram('sae_api_forward_cycle_duration', 'The time it takes to run one full frame forwarding cycle') |
| 16 | + |
| 17 | + |
| 18 | +class FrameForwarder: |
| 19 | + '''Periodically pulls the latest frame from every video source stream on the source Valkey |
| 20 | + and forwards a frame-only SaeMessage to the backend Valkey (one output stream per source).''' |
| 21 | + |
| 22 | + def __init__(self, config: SaeApiConfig) -> None: |
| 23 | + self._config = config |
| 24 | + self._scan_pattern = f'{config.frame_forwarding.video_source_stream_prefix}:*' |
| 25 | + self._output_prefix = config.frame_forwarding.output_stream_prefix |
| 26 | + |
| 27 | + self._source_client = None |
| 28 | + self._publish_ctx = ValkeyPublisher(config.backend_valkey.host, config.backend_valkey.port) |
| 29 | + self._publish = None |
| 30 | + |
| 31 | + def __enter__(self): |
| 32 | + self._source_client = valkey.Valkey(self._config.source_valkey.host, self._config.source_valkey.port) |
| 33 | + self._publish = self._publish_ctx.__enter__() |
| 34 | + return self |
| 35 | + |
| 36 | + def __exit__(self, exc_type, exc_value, traceback): |
| 37 | + try: |
| 38 | + self._source_client.close() |
| 39 | + except Exception as e: |
| 40 | + logger.warning('Error while closing source valkey client', exc_info=e) |
| 41 | + return self._publish_ctx.__exit__(exc_type, exc_value, traceback) |
| 42 | + |
| 43 | + @FORWARD_CYCLE_DURATION.time() |
| 44 | + def forward_once(self): |
| 45 | + stream_keys = list(self._source_client.scan_iter(match=self._scan_pattern, _type='STREAM')) |
| 46 | + logger.debug(f'Discovered {len(stream_keys)} source stream(s) matching {self._scan_pattern}') |
| 47 | + |
| 48 | + for stream_key in stream_keys: |
| 49 | + try: |
| 50 | + self._forward_stream(stream_key) |
| 51 | + except Exception as e: |
| 52 | + logger.warning(f'Error while forwarding latest frame from {stream_key!r}', exc_info=e) |
| 53 | + |
| 54 | + def _forward_stream(self, stream_key: bytes): |
| 55 | + entries = self._source_client.xrevrange(stream_key, count=1) |
| 56 | + if not entries: |
| 57 | + return |
| 58 | + |
| 59 | + _, fields = entries[0] |
| 60 | + proto_data = fields.get(b'proto_data_b64') |
| 61 | + if proto_data is not None: |
| 62 | + proto_data = pybase64.b64decode(proto_data, validate=True) |
| 63 | + else: |
| 64 | + proto_data = fields.get(b'proto_data') |
| 65 | + if proto_data is None: |
| 66 | + logger.warning(f'No proto data field found in latest entry of {stream_key!r}') |
| 67 | + return |
| 68 | + |
| 69 | + sae_msg = SaeMessage() |
| 70 | + sae_msg.ParseFromString(proto_data) |
| 71 | + |
| 72 | + if not sae_msg.HasField('frame'): |
| 73 | + logger.debug(f'Latest message in {stream_key!r} has no frame; skipping') |
| 74 | + return |
| 75 | + |
| 76 | + out_msg = SaeMessage() |
| 77 | + out_msg.frame.CopyFrom(sae_msg.frame) |
| 78 | + out_msg.type = MessageType.SAE |
| 79 | + |
| 80 | + source_id = stream_key.decode('utf-8').split(':', 1)[1] |
| 81 | + output_key = f'{self._output_prefix}:{source_id}' |
| 82 | + self._publish(output_key, out_msg.SerializeToString()) |
| 83 | + |
| 84 | + FRAMES_FORWARDED.inc() |
| 85 | + logger.debug(f'Forwarded latest frame from {stream_key!r} to {output_key}') |
0 commit comments