feat(ui): stabilize shared M4.8 review viewer
This commit is contained in:
@@ -5,7 +5,11 @@ from .active import (
|
||||
ActiveSessionLeaseError,
|
||||
recover_stale_active_session_marker,
|
||||
)
|
||||
from .camera_frame import RecordedCameraFrame, RecordedCameraFrameService
|
||||
from .camera_frame import (
|
||||
RecordedCameraFrame,
|
||||
RecordedCameraFrameService,
|
||||
RecordedCameraPlaybackSource,
|
||||
)
|
||||
from .lab_cache import publish_lab_replay_cache
|
||||
from .media import (
|
||||
RECORDED_MEDIA_MANIFEST_SCHEMA,
|
||||
@@ -68,6 +72,7 @@ __all__ = [
|
||||
"RecordedMediaArtifact",
|
||||
"RecordedCameraFrame",
|
||||
"RecordedCameraFrameService",
|
||||
"RecordedCameraPlaybackSource",
|
||||
"RECORDED_MEDIA_MANIFEST_SCHEMA",
|
||||
"RecordedMediaFile",
|
||||
"RecordedMediaInspector",
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -15,6 +16,8 @@ from .store import SessionStore
|
||||
|
||||
_MAX_KEYFRAME_DISTANCE = 120
|
||||
_FFMPEG_TIMEOUT_SECONDS = 15.0
|
||||
_DEFAULT_MAX_DECODE_LANES = 32
|
||||
_DEFAULT_MAX_SOURCE_MANIFESTS = 32
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -24,6 +27,34 @@ class RecordedCameraFrame:
|
||||
width: int
|
||||
height: int
|
||||
sha256: str
|
||||
source_fragment_sha256: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordedCameraPlaybackSource:
|
||||
"""Immutable single-epoch camera source for bounded review playback."""
|
||||
|
||||
session_id: str
|
||||
public_source_id: str
|
||||
artifact_id: str
|
||||
synchronization: str
|
||||
generation_sha256: str
|
||||
timeline_start_seconds: float
|
||||
timeline_end_seconds: float
|
||||
byte_length: int
|
||||
media_type: str
|
||||
segment_sha256s: tuple[str, ...]
|
||||
segment_start_times_ns: tuple[int, ...]
|
||||
|
||||
@property
|
||||
def segment_count(self) -> int:
|
||||
return len(self.segment_sha256s)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _CameraDecodeLane:
|
||||
active: bool = False
|
||||
latest_ticket: int = 0
|
||||
|
||||
|
||||
class RecordedCameraFrameService:
|
||||
@@ -42,6 +73,8 @@ class RecordedCameraFrameService:
|
||||
*,
|
||||
ffmpeg_path: Path,
|
||||
cache_root: Path,
|
||||
max_decode_lanes: int = _DEFAULT_MAX_DECODE_LANES,
|
||||
max_source_manifests: int = _DEFAULT_MAX_SOURCE_MANIFESTS,
|
||||
) -> None:
|
||||
resolved_ffmpeg = ffmpeg_path.expanduser().resolve(strict=True)
|
||||
if not resolved_ffmpeg.is_file() or not os.access(resolved_ffmpeg, os.X_OK):
|
||||
@@ -53,7 +86,16 @@ class RecordedCameraFrameService:
|
||||
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()
|
||||
if max_decode_lanes < 1 or max_source_manifests < 1:
|
||||
raise SessionIntegrityError("camera frame memory cache bounds are invalid")
|
||||
self._max_decode_lanes = max_decode_lanes
|
||||
self._max_source_manifests = max_source_manifests
|
||||
self._coordination = threading.Condition(threading.Lock())
|
||||
self._lanes: OrderedDict[tuple[str, str], _CameraDecodeLane] = OrderedDict()
|
||||
self._source_manifests: OrderedDict[
|
||||
tuple[str, str],
|
||||
RecordedMediaManifest,
|
||||
] = OrderedDict()
|
||||
|
||||
def extract(
|
||||
self,
|
||||
@@ -64,6 +106,108 @@ class RecordedCameraFrameService:
|
||||
) -> RecordedCameraFrame:
|
||||
if frame_index < 0:
|
||||
raise SessionIntegrityError("camera frame index is invalid")
|
||||
source_key = (session_id, expected_source_name)
|
||||
lane, ticket = self._acquire_lane(source_key)
|
||||
try:
|
||||
manifest = self._source_manifest(
|
||||
source_key,
|
||||
session_id=session_id,
|
||||
expected_source_name=expected_source_name,
|
||||
)
|
||||
self._require_latest(lane, ticket)
|
||||
epoch, sequence = _frame_location(manifest, frame_index)
|
||||
cache_key = hashlib.sha256(
|
||||
(
|
||||
f"{manifest.generation_sha256}\0{manifest.artifact_id}\0"
|
||||
f"{expected_source_name}\0{frame_index}\0jpeg-q2-v1"
|
||||
).encode()
|
||||
).hexdigest()
|
||||
cache_path = self._cache_root / f"{cache_key}.jpg"
|
||||
|
||||
cached = _read_cached_jpeg(
|
||||
cache_path,
|
||||
source_fragment_sha256=epoch.segments[sequence - 1].sha256,
|
||||
)
|
||||
if cached is not None:
|
||||
self._require_latest(lane, ticket)
|
||||
return cached
|
||||
self._require_latest(lane, ticket)
|
||||
frame = self._decode(manifest, epoch, sequence)
|
||||
_publish_cached_jpeg(cache_path, frame.payload)
|
||||
self._require_latest(lane, ticket)
|
||||
return frame
|
||||
finally:
|
||||
self._release_lane(source_key, lane)
|
||||
|
||||
def playback_source(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
expected_source_name: str = "sensor.camera.right",
|
||||
) -> RecordedCameraPlaybackSource:
|
||||
"""Return the generation-bound fMP4 source used by the shared LAB viewer.
|
||||
|
||||
LAB playback is intentionally admitted from the same cached immutable
|
||||
manifest as exact JPEG extraction. The contract is limited to one
|
||||
codec epoch because LAB frame sequence is a direct one-based segment
|
||||
sequence; a future multi-epoch source must add an explicit mapping
|
||||
contract instead of guessing across epoch boundaries.
|
||||
"""
|
||||
|
||||
manifest = self._source_manifest(
|
||||
(session_id, expected_source_name),
|
||||
session_id=session_id,
|
||||
expected_source_name=expected_source_name,
|
||||
)
|
||||
if manifest.synchronization != "host-arrival-best-effort" or len(manifest.epochs) != 1:
|
||||
raise SessionIntegrityError("recorded camera playback source is incompatible")
|
||||
epoch = manifest.epochs[0]
|
||||
if (
|
||||
epoch.ordinal != 1
|
||||
or epoch.timeline_start_seconds != manifest.timeline_start_seconds
|
||||
or epoch.timeline_end_seconds != manifest.timeline_end_seconds
|
||||
or not epoch.media_type.startswith("video/mp4;")
|
||||
or not epoch.segments
|
||||
or tuple(segment.sequence for segment in epoch.segments)
|
||||
!= tuple(range(1, len(epoch.segments) + 1))
|
||||
):
|
||||
raise SessionIntegrityError("recorded camera playback epoch is incompatible")
|
||||
starts_ns: list[int] = []
|
||||
previous_end_seconds = 0.0
|
||||
for segment in epoch.segments:
|
||||
starts_ns.append(
|
||||
round((epoch.timeline_start_seconds + previous_end_seconds) * 1_000_000_000)
|
||||
)
|
||||
previous_end_seconds = segment.end_time_seconds
|
||||
return RecordedCameraPlaybackSource(
|
||||
session_id=manifest.session_id,
|
||||
public_source_id=manifest.public_source_id,
|
||||
artifact_id=manifest.artifact_id,
|
||||
synchronization=manifest.synchronization,
|
||||
generation_sha256=manifest.generation_sha256,
|
||||
timeline_start_seconds=manifest.timeline_start_seconds,
|
||||
timeline_end_seconds=manifest.timeline_end_seconds,
|
||||
byte_length=manifest.byte_length,
|
||||
media_type=epoch.media_type,
|
||||
segment_sha256s=tuple(segment.sha256 for segment in epoch.segments),
|
||||
segment_start_times_ns=tuple(starts_ns),
|
||||
)
|
||||
|
||||
def _source_manifest(
|
||||
self,
|
||||
source_key: tuple[str, str],
|
||||
*,
|
||||
session_id: str,
|
||||
expected_source_name: str,
|
||||
) -> RecordedMediaManifest:
|
||||
"""Bind one immutable recorded source without rescanning it per frame."""
|
||||
|
||||
with self._coordination:
|
||||
cached = self._source_manifests.get(source_key)
|
||||
if cached is not None:
|
||||
self._source_manifests.move_to_end(source_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
replay = self._store.prepare_replay(session_id, speed=1.0, loop=False)
|
||||
matches = tuple(
|
||||
artifact
|
||||
@@ -74,22 +218,66 @@ class RecordedCameraFrameService:
|
||||
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._coordination:
|
||||
bound = self._source_manifests.get(source_key)
|
||||
if bound is None:
|
||||
bound = manifest
|
||||
self._source_manifests[source_key] = manifest
|
||||
self._source_manifests.move_to_end(source_key)
|
||||
while len(self._source_manifests) > self._max_source_manifests:
|
||||
self._source_manifests.popitem(last=False)
|
||||
return bound
|
||||
|
||||
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 _acquire_lane(
|
||||
self,
|
||||
source_key: tuple[str, str],
|
||||
) -> tuple[_CameraDecodeLane, int]:
|
||||
"""Admit only the newest waiter behind one active source decode."""
|
||||
|
||||
with self._coordination:
|
||||
lane = self._lanes.get(source_key)
|
||||
if lane is None:
|
||||
lane = _CameraDecodeLane()
|
||||
self._lanes[source_key] = lane
|
||||
self._lanes.move_to_end(source_key)
|
||||
self._evict_inactive_lanes(exclude=source_key)
|
||||
lane.latest_ticket += 1
|
||||
ticket = lane.latest_ticket
|
||||
self._coordination.notify_all()
|
||||
while lane.active:
|
||||
if ticket != lane.latest_ticket:
|
||||
raise SessionIntegrityError("camera frame request was superseded")
|
||||
self._coordination.wait()
|
||||
if ticket != lane.latest_ticket:
|
||||
raise SessionIntegrityError("camera frame request was superseded")
|
||||
lane.active = True
|
||||
return lane, ticket
|
||||
|
||||
def _evict_inactive_lanes(self, *, exclude: tuple[str, str] | None = None) -> None:
|
||||
if len(self._lanes) <= self._max_decode_lanes:
|
||||
return
|
||||
for source_key, lane in tuple(self._lanes.items()):
|
||||
if len(self._lanes) <= self._max_decode_lanes:
|
||||
break
|
||||
if source_key != exclude and not lane.active:
|
||||
del self._lanes[source_key]
|
||||
|
||||
def _require_latest(self, lane: _CameraDecodeLane, ticket: int) -> None:
|
||||
with self._coordination:
|
||||
if ticket != lane.latest_ticket:
|
||||
raise SessionIntegrityError("camera frame request was superseded")
|
||||
|
||||
def _release_lane(
|
||||
self,
|
||||
source_key: tuple[str, str],
|
||||
lane: _CameraDecodeLane,
|
||||
) -> None:
|
||||
with self._coordination:
|
||||
lane.active = False
|
||||
if self._lanes.get(source_key) is lane:
|
||||
self._lanes.move_to_end(source_key)
|
||||
self._evict_inactive_lanes()
|
||||
self._coordination.notify_all()
|
||||
|
||||
def _decode(
|
||||
self,
|
||||
@@ -156,6 +344,7 @@ class RecordedCameraFrameService:
|
||||
width=width,
|
||||
height=height,
|
||||
sha256=digest,
|
||||
source_fragment_sha256=target.sha256,
|
||||
)
|
||||
|
||||
|
||||
@@ -196,7 +385,11 @@ def _jpeg_dimensions(payload: bytes) -> tuple[int, int]:
|
||||
raise SessionIntegrityError("camera frame JPEG dimensions are unavailable")
|
||||
|
||||
|
||||
def _read_cached_jpeg(path: Path) -> RecordedCameraFrame | None:
|
||||
def _read_cached_jpeg(
|
||||
path: Path,
|
||||
*,
|
||||
source_fragment_sha256: str,
|
||||
) -> RecordedCameraFrame | None:
|
||||
try:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
return None
|
||||
@@ -210,6 +403,7 @@ def _read_cached_jpeg(path: Path) -> RecordedCameraFrame | None:
|
||||
width=width,
|
||||
height=height,
|
||||
sha256=hashlib.sha256(payload).hexdigest(),
|
||||
source_fragment_sha256=source_fragment_sha256,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user