"""Систематическая защита видеофрагментов от потери целых PHY-пакетов. Вызывающий код передаёт сюда только payload после строгого PHY/FEC/CRC. Арифметика GF(256) повторно используется из packet_erasure_fec; упаковка Lab028 не используется. Каждый внешний символ занимает полный PHY payload. """ from __future__ import annotations from dataclasses import dataclass, field from collections import deque import struct from typing import Sequence, cast from protocol.packet_erasure_fec import ( InsufficientSymbolsError, _linear_combine, encode_parity_symbols, matrix_inverse, systematic_generator_matrix, ) from protocol.video003_compact_header import ( HEADER_SIZE as VIDEO_HEADER_SIZE, decode as decode_video, encode as encode_video, ) from protocol.video_stream_transport import VideoFragment MAGIC = 0xFEC4 VERSION = 1 HEADER_FORMAT = "!HBBIIBBBB" HEADER_SIZE = struct.calcsize(HEADER_FORMAT) # 16 SOURCE_PREFIX_FORMAT = "!HI" SOURCE_PREFIX_SIZE = struct.calcsize(SOURCE_PREFIX_FORMAT) # length + capture_ms = 6 MAX_PHY_PAYLOAD = 1012 SYMBOL_SIZE = MAX_PHY_PAYLOAD - HEADER_SIZE # 996 MAX_VIDEO_BYTES = SYMBOL_SIZE - SOURCE_PREFIX_SIZE - VIDEO_HEADER_SIZE # 977 MAX_AGE_MS = 300 class VideoErasureError(ValueError): """Неверный внешний символ или противоречивый блок.""" @dataclass(frozen=True) class Symbol: stream_id: int block_id: int index: int source_count: int parity_count: int is_parity: bool data: bytes @dataclass(frozen=True) class Delivery: index: int fragment: VideoFragment capture_ms: int recovered: bool raw_video_wire: bytes def _header(stream_id: int, block_id: int, index: int, source_count: int, parity_count: int) -> bytes: if not (0 <= stream_id <= 0xFFFFFFFF and 0 <= block_id <= 0xFFFFFFFF and 1 <= source_count <= 255 and 1 <= parity_count <= 255 - source_count and 0 <= index < source_count + parity_count): raise VideoErasureError("поля внешнего FEC-заголовка вне диапазона") return struct.pack(HEADER_FORMAT, MAGIC, VERSION, int(index >= source_count), stream_id, block_id, index, source_count, parity_count, 0) def parse_symbol(wire: bytes) -> Symbol: """Разобрать payload только после CRC-проверки внешнего радиопакета.""" if not isinstance(wire, bytes) or len(wire) != MAX_PHY_PAYLOAD: raise VideoErasureError("длина внешнего символа должна быть 1012 байт") magic, version, flags, stream_id, block_id, index, source_count, parity_count, reserved = ( struct.unpack(HEADER_FORMAT, wire[:HEADER_SIZE])) if (magic != MAGIC or version != VERSION or flags not in (0, 1) or reserved != 0 or not 1 <= source_count <= 255 or not 1 <= parity_count <= 255 - source_count or index >= source_count + parity_count or bool(flags) != (index >= source_count)): raise VideoErasureError("неверные метаданные внешнего символа") return Symbol(stream_id, block_id, index, source_count, parity_count, bool(flags), wire[HEADER_SIZE:]) def _source_data(fragment: VideoFragment, capture_ms: int) -> bytes: if not 0 <= capture_ms <= 0xFFFFFFFF or len(fragment.data) > MAX_VIDEO_BYTES: raise VideoErasureError("время или размер исходного видеофрагмента вне диапазона") video_wire = encode_video(fragment, SYMBOL_SIZE - SOURCE_PREFIX_SIZE) source = struct.pack(SOURCE_PREFIX_FORMAT, len(video_wire), capture_ms) + video_wire return source + bytes(SYMBOL_SIZE - len(source)) def encode_block(fragments: Sequence[tuple[VideoFragment, int]], block_id: int, parity_count: int) -> tuple[bytes, ...]: """Отправить K источников первыми, затем R проверочных символов.""" if not fragments: raise VideoErasureError("пустой блок") stream_id = fragments[0][0].stream_id if any(fragment.stream_id != stream_id for fragment, _ in fragments): raise VideoErasureError("в одном блоке разные потоки") source_count = len(fragments) sources = tuple(_source_data(fragment, capture_ms) for fragment, capture_ms in fragments) parity = encode_parity_symbols(sources, parity_count) all_data = sources + parity return tuple(_header(stream_id, block_id, index, source_count, parity_count) + data for index, data in enumerate(all_data)) def decode_source(data: bytes, stream_id: int, index: int, recovered: bool) -> Delivery: if len(data) != SYMBOL_SIZE: raise VideoErasureError("неверная длина исходного символа") length, capture_ms = struct.unpack(SOURCE_PREFIX_FORMAT, data[:SOURCE_PREFIX_SIZE]) if not VIDEO_HEADER_SIZE < length <= SYMBOL_SIZE - SOURCE_PREFIX_SIZE: raise VideoErasureError("длина восстановленного видеофрагмента вне диапазона") end = SOURCE_PREFIX_SIZE + length if any(data[end:]): raise VideoErasureError("ненулевое заполнение исходного символа") video_wire = data[SOURCE_PREFIX_SIZE:end] fragment = decode_video(video_wire, SYMBOL_SIZE - SOURCE_PREFIX_SIZE) if fragment.stream_id != stream_id: raise VideoErasureError("поток в FEC и видео заголовках не совпал") return Delivery(index, fragment, capture_ms, recovered, video_wire) def recover_block(wires: Sequence[bytes]) -> tuple[Delivery, ...]: """Восстановить все K исходных символов при наличии любых K из K+R.""" if not wires: raise InsufficientSymbolsError("нет символов блока") symbols: dict[int, Symbol] = {} first = parse_symbol(wires[0]) metadata = (first.stream_id, first.block_id, first.source_count, first.parity_count) for wire in wires: symbol = parse_symbol(wire) if (symbol.stream_id, symbol.block_id, symbol.source_count, symbol.parity_count) != metadata: raise VideoErasureError("в одном блоке разные метаданные") old = symbols.get(symbol.index) if old is not None and old != symbol: raise VideoErasureError("противоречивый повтор внешнего символа") symbols[symbol.index] = symbol k = first.source_count if len(symbols) < k: raise InsufficientSymbolsError(f"получено {len(symbols)} из необходимых {k}") missing = [index for index in range(k) if index not in symbols] if not missing: return tuple(decode_source(symbols[index].data, first.stream_id, index, False) for index in range(k)) selected = tuple(sorted(symbols)[:k]) generator = systematic_generator_matrix(k, first.parity_count) inverse = matrix_inverse(tuple(generator[index] for index in selected)) received = tuple(symbols[index].data for index in selected) return tuple(decode_source(symbols[index].data if index in symbols else _linear_combine(inverse[index], received, SYMBOL_SIZE), first.stream_id, index, index in missing) for index in range(k)) @dataclass class _PendingBlock: source_count: int parity_count: int wires: dict[int, bytes] = field(default_factory=dict) delivered: set[int] = field(default_factory=set) class VideoErasureCollector: """Сразу выдаёт принятые источники; поздно восстановленные отбрасывает.""" def __init__(self, stream_id: int, max_age_ms: int = MAX_AGE_MS, max_pending_blocks: int = 8) -> None: if not 0 <= stream_id <= 0xFFFFFFFF or max_age_ms <= 0 or max_pending_blocks < 1: raise ValueError("неверный поток или срок") self.stream_id = stream_id self.max_age_ms = max_age_ms self.max_pending_blocks = max_pending_blocks self.blocks: dict[int, _PendingBlock] = {} self.closed: deque[int] = deque(maxlen=1024) self.parity_recoveries = 0 self.stale_rejections = 0 self.unrecoverable_blocks = 0 def offer(self, wire: bytes, now_ms: int) -> tuple[Delivery, ...]: if not isinstance(now_ms, int) or now_ms < 0: raise VideoErasureError("неверное время приёма") symbol = parse_symbol(wire) if symbol.stream_id != self.stream_id: raise VideoErasureError("чужой поток") direct_item = None if not symbol.is_parity: direct_item = decode_source(symbol.data, symbol.stream_id, symbol.index, False) if now_ms < direct_item.capture_ms: raise VideoErasureError("фрагмент датирован будущим временем") if symbol.block_id in self.closed: return () if symbol.block_id not in self.blocks and len(self.blocks) >= self.max_pending_blocks: oldest_id = next(iter(self.blocks)) oldest = self.blocks.pop(oldest_id) self.unrecoverable_blocks += len(oldest.wires) < oldest.source_count self.closed.append(oldest_id) block = self.blocks.setdefault(symbol.block_id, _PendingBlock(symbol.source_count, symbol.parity_count)) if (block.source_count, block.parity_count) != (symbol.source_count, symbol.parity_count): raise VideoErasureError("противоречивые параметры блока") old = block.wires.get(symbol.index) if old is not None and old != wire: raise VideoErasureError("противоречивый повтор символа") block.wires[symbol.index] = wire output = [] if not symbol.is_parity and symbol.index not in block.delivered: item = cast(Delivery, direct_item) block.delivered.add(symbol.index) if now_ms - item.capture_ms < self.max_age_ms: output.append(item) else: self.stale_rejections += 1 if len(block.wires) >= block.source_count and len(block.delivered) < block.source_count: for item in recover_block(tuple(block.wires.values())): if item.index in block.delivered: continue if now_ms < item.capture_ms: raise VideoErasureError("восстановленный фрагмент датирован будущим временем") block.delivered.add(item.index) if now_ms - item.capture_ms >= self.max_age_ms: self.stale_rejections += 1 continue self.parity_recoveries += 1 output.append(item) if len(block.delivered) == block.source_count: del self.blocks[symbol.block_id] self.closed.append(symbol.block_id) return tuple(output) def finish(self) -> None: self.unrecoverable_blocks += sum( len(block.wires) < block.source_count for block in self.blocks.values()) self.blocks.clear()