fix(m4): retain compact obstacle evidence
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .media import RecordedMediaEpoch, RecordedMediaInspector, RecordedMediaManifest
|
||||
from .models import SessionIntegrityError
|
||||
from .store import SessionStore
|
||||
|
||||
_MAX_KEYFRAME_DISTANCE = 120
|
||||
_FFMPEG_TIMEOUT_SECONDS = 15.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordedCameraFrame:
|
||||
payload: bytes
|
||||
media_type: str
|
||||
width: int
|
||||
height: int
|
||||
sha256: str
|
||||
|
||||
|
||||
class RecordedCameraFrameService:
|
||||
"""Decode one exact archived camera frame without preparing the full video.
|
||||
|
||||
Canonical camera archives contain one video sample per fMP4 fragment. The
|
||||
service validates the sealed manifest, walks back only to the preceding IDR
|
||||
fragment and gives that bounded GOP to ffmpeg. This keeps CAMERA review
|
||||
independent from the 4489-row VIDEO overlay and full-player preparation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: SessionStore,
|
||||
inspector: RecordedMediaInspector,
|
||||
*,
|
||||
ffmpeg_path: Path,
|
||||
cache_root: Path,
|
||||
) -> None:
|
||||
resolved_ffmpeg = ffmpeg_path.expanduser().resolve(strict=True)
|
||||
if not resolved_ffmpeg.is_file() or not os.access(resolved_ffmpeg, os.X_OK):
|
||||
raise SessionIntegrityError("ffmpeg is unavailable for camera frame review")
|
||||
self._store = store
|
||||
self._inspector = inspector
|
||||
self._ffmpeg_path = resolved_ffmpeg
|
||||
self._cache_root = cache_root.expanduser().absolute()
|
||||
self._cache_root.mkdir(parents=True, exist_ok=True)
|
||||
if self._cache_root.is_symlink() or not self._cache_root.is_dir():
|
||||
raise SessionIntegrityError("camera frame cache root is invalid")
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def extract(
|
||||
self,
|
||||
session_id: str,
|
||||
frame_index: int,
|
||||
*,
|
||||
expected_source_name: str = "sensor.camera.right",
|
||||
) -> RecordedCameraFrame:
|
||||
if frame_index < 0:
|
||||
raise SessionIntegrityError("camera frame index is invalid")
|
||||
replay = self._store.prepare_replay(session_id, speed=1.0, loop=False)
|
||||
matches = tuple(
|
||||
artifact
|
||||
for artifact in self._store.list_recorded_media(session_id)
|
||||
if artifact.source_path.name == expected_source_name
|
||||
)
|
||||
if len(matches) != 1:
|
||||
raise SessionIntegrityError("recorded camera source is unavailable")
|
||||
artifact = matches[0]
|
||||
manifest = self._inspector.inspect(artifact, replay)
|
||||
epoch, sequence = _frame_location(manifest, frame_index)
|
||||
cache_key = hashlib.sha256(
|
||||
(
|
||||
f"{manifest.generation_sha256}\0{artifact.artifact_id}\0"
|
||||
f"{expected_source_name}\0{frame_index}\0jpeg-q2-v1"
|
||||
).encode()
|
||||
).hexdigest()
|
||||
cache_path = self._cache_root / f"{cache_key}.jpg"
|
||||
|
||||
with self._lock:
|
||||
cached = _read_cached_jpeg(cache_path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
frame = self._decode(manifest, epoch, sequence)
|
||||
_publish_cached_jpeg(cache_path, frame.payload)
|
||||
return frame
|
||||
|
||||
def _decode(
|
||||
self,
|
||||
manifest: RecordedMediaManifest,
|
||||
epoch: RecordedMediaEpoch,
|
||||
sequence: int,
|
||||
) -> RecordedCameraFrame:
|
||||
target = self._inspector.get_segment(manifest, epoch.ordinal, sequence)
|
||||
fragments: list[bytes] = [target.payload]
|
||||
key_sequence = sequence
|
||||
while not _fragment_is_sync(fragments[0]):
|
||||
key_sequence -= 1
|
||||
if key_sequence < 1 or sequence - key_sequence > _MAX_KEYFRAME_DISTANCE:
|
||||
raise SessionIntegrityError("camera frame has no bounded sync fragment")
|
||||
previous = self._inspector.get_segment(
|
||||
manifest,
|
||||
epoch.ordinal,
|
||||
key_sequence,
|
||||
)
|
||||
fragments.insert(0, previous.payload)
|
||||
|
||||
init = self._inspector.get_init(manifest, epoch.ordinal)
|
||||
select_index = sequence - key_sequence
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
str(self._ffmpeg_path),
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"mp4",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-vf",
|
||||
f"select=eq(n\\,{select_index})",
|
||||
"-fps_mode",
|
||||
"passthrough",
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-c:v",
|
||||
"mjpeg",
|
||||
"-q:v",
|
||||
"2",
|
||||
"pipe:1",
|
||||
],
|
||||
input=b"".join((init.payload, *fragments)),
|
||||
capture_output=True,
|
||||
timeout=_FFMPEG_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise SessionIntegrityError("camera frame decoder failed") from exc
|
||||
if completed.returncode != 0:
|
||||
detail = completed.stderr.decode("utf-8", errors="replace").strip()[-500:]
|
||||
raise SessionIntegrityError(f"camera frame decoder rejected sealed media: {detail}")
|
||||
width, height = _jpeg_dimensions(completed.stdout)
|
||||
digest = hashlib.sha256(completed.stdout).hexdigest()
|
||||
return RecordedCameraFrame(
|
||||
payload=completed.stdout,
|
||||
media_type="image/jpeg",
|
||||
width=width,
|
||||
height=height,
|
||||
sha256=digest,
|
||||
)
|
||||
|
||||
|
||||
def _frame_location(
|
||||
manifest: RecordedMediaManifest,
|
||||
frame_index: int,
|
||||
) -> tuple[RecordedMediaEpoch, int]:
|
||||
remaining = frame_index
|
||||
for epoch in manifest.epochs:
|
||||
if remaining < len(epoch.segments):
|
||||
return epoch, remaining + 1
|
||||
remaining -= len(epoch.segments)
|
||||
raise SessionIntegrityError("camera frame is outside the recorded media manifest")
|
||||
|
||||
|
||||
def _fragment_is_sync(payload: bytes) -> bool:
|
||||
tfhd_default_flags: int | None = None
|
||||
sample_flags: int | None = None
|
||||
sample_count: int | None = None
|
||||
for box_type, body in _walk_boxes(payload):
|
||||
if box_type == b"tfhd":
|
||||
if len(body) < 8:
|
||||
raise SessionIntegrityError("camera fragment tfhd is truncated")
|
||||
flags = int.from_bytes(body[1:4], "big")
|
||||
offset = 8
|
||||
for mask, size in ((0x000001, 8), (0x000002, 4), (0x000008, 4), (0x000010, 4)):
|
||||
if flags & mask:
|
||||
offset += size
|
||||
if flags & 0x000020:
|
||||
if offset + 4 > len(body):
|
||||
raise SessionIntegrityError("camera fragment default flags are truncated")
|
||||
tfhd_default_flags = struct.unpack_from(">I", body, offset)[0]
|
||||
elif box_type == b"trun":
|
||||
if len(body) < 8:
|
||||
raise SessionIntegrityError("camera fragment trun is truncated")
|
||||
flags = int.from_bytes(body[1:4], "big")
|
||||
sample_count = struct.unpack_from(">I", body, 4)[0]
|
||||
if sample_count != 1:
|
||||
raise SessionIntegrityError("camera fragment must contain exactly one sample")
|
||||
offset = 8
|
||||
if flags & 0x000001:
|
||||
offset += 4
|
||||
if flags & 0x000004:
|
||||
if offset + 4 > len(body):
|
||||
raise SessionIntegrityError("camera fragment first flags are truncated")
|
||||
sample_flags = struct.unpack_from(">I", body, offset)[0]
|
||||
offset += 4
|
||||
per_sample_sizes = (
|
||||
(0x000100, 4),
|
||||
(0x000200, 4),
|
||||
(0x000400, 4),
|
||||
(0x000800, 4),
|
||||
)
|
||||
for mask, size in per_sample_sizes:
|
||||
if flags & mask:
|
||||
if offset + size > len(body):
|
||||
raise SessionIntegrityError("camera fragment sample data is truncated")
|
||||
if mask == 0x000400:
|
||||
sample_flags = struct.unpack_from(">I", body, offset)[0]
|
||||
offset += size
|
||||
if sample_count != 1:
|
||||
raise SessionIntegrityError("camera fragment has no unique video sample")
|
||||
effective_flags = sample_flags if sample_flags is not None else tfhd_default_flags
|
||||
if effective_flags is None:
|
||||
raise SessionIntegrityError("camera fragment sample flags are unavailable")
|
||||
return (effective_flags & 0x00010000) == 0
|
||||
|
||||
|
||||
def _walk_boxes(payload: bytes):
|
||||
containers = {b"moof", b"traf"}
|
||||
pending = [(0, len(payload))]
|
||||
boxes = 0
|
||||
while pending:
|
||||
start, end = pending.pop()
|
||||
offset = start
|
||||
while offset + 8 <= end:
|
||||
boxes += 1
|
||||
if boxes > 64:
|
||||
raise SessionIntegrityError("camera fragment box budget was exceeded")
|
||||
size = struct.unpack_from(">I", payload, offset)[0]
|
||||
box_type = payload[offset + 4 : offset + 8]
|
||||
header = 8
|
||||
if size == 1:
|
||||
if offset + 16 > end:
|
||||
raise SessionIntegrityError("camera fragment extended box is truncated")
|
||||
size = struct.unpack_from(">Q", payload, offset + 8)[0]
|
||||
header = 16
|
||||
elif size == 0:
|
||||
size = end - offset
|
||||
if size < header or offset + size > end:
|
||||
raise SessionIntegrityError("camera fragment box size is invalid")
|
||||
body_start = offset + header
|
||||
body_end = offset + size
|
||||
yield box_type, payload[body_start:body_end]
|
||||
if box_type in containers:
|
||||
pending.append((body_start, body_end))
|
||||
offset = body_end
|
||||
if offset != end:
|
||||
raise SessionIntegrityError("camera fragment box boundary is invalid")
|
||||
|
||||
|
||||
def _jpeg_dimensions(payload: bytes) -> tuple[int, int]:
|
||||
if len(payload) < 4 or payload[:2] != b"\xff\xd8" or payload[-2:] != b"\xff\xd9":
|
||||
raise SessionIntegrityError("camera frame decoder returned an invalid JPEG")
|
||||
offset = 2
|
||||
while offset + 4 <= len(payload):
|
||||
if payload[offset] != 0xFF:
|
||||
offset += 1
|
||||
continue
|
||||
marker = payload[offset + 1]
|
||||
offset += 2
|
||||
if marker in {0xD8, 0xD9} or 0xD0 <= marker <= 0xD7:
|
||||
continue
|
||||
if offset + 2 > len(payload):
|
||||
break
|
||||
length = struct.unpack_from(">H", payload, offset)[0]
|
||||
if length < 2 or offset + length > len(payload):
|
||||
break
|
||||
if marker in {0xC0, 0xC1, 0xC2} and length >= 7:
|
||||
height, width = struct.unpack_from(">HH", payload, offset + 3)
|
||||
if width > 0 and height > 0:
|
||||
return width, height
|
||||
offset += length
|
||||
raise SessionIntegrityError("camera frame JPEG dimensions are unavailable")
|
||||
|
||||
|
||||
def _read_cached_jpeg(path: Path) -> RecordedCameraFrame | None:
|
||||
try:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
return None
|
||||
payload = path.read_bytes()
|
||||
width, height = _jpeg_dimensions(payload)
|
||||
except (OSError, SessionIntegrityError):
|
||||
return None
|
||||
return RecordedCameraFrame(
|
||||
payload=payload,
|
||||
media_type="image/jpeg",
|
||||
width=width,
|
||||
height=height,
|
||||
sha256=hashlib.sha256(payload).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
def _publish_cached_jpeg(path: Path, payload: bytes) -> None:
|
||||
temporary = path.parent / f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp"
|
||||
try:
|
||||
with temporary.open("xb") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("camera frame cache could not be published") from exc
|
||||
finally:
|
||||
with suppress(FileNotFoundError):
|
||||
temporary.unlink()
|
||||
Reference in New Issue
Block a user