feat(perception): integrate calibrated operator pipeline
Add calibrated K1 projection, recorded and near-live perception qualification, unified Rerun operator layers, bounded replay admission, audited viewer controls, worker experiments, and lab evidence.
This commit is contained in:
+79
-6
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
@@ -14,7 +15,13 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import ValidationError
|
||||
|
||||
from k1link import __version__
|
||||
from k1link.compute import RecordedPerceptionOverlayStore
|
||||
from k1link.compute import (
|
||||
IntegratedPerceptionOverlayStore,
|
||||
RecordedCalibratedFusionStore,
|
||||
RecordedPerceptionEpochStore,
|
||||
RecordedPerceptionOverlayMux,
|
||||
RecordedPerceptionOverlayStore,
|
||||
)
|
||||
from k1link.sessions import (
|
||||
MaterializedRecording,
|
||||
RecordedMediaInspector,
|
||||
@@ -42,6 +49,22 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
INVALID_REQUEST_DETAIL = "Некорректные параметры запроса."
|
||||
|
||||
|
||||
def _resolve_media_tool(name: str) -> Path | None:
|
||||
"""Resolve media tools under interactive shells and minimal launchd PATHs."""
|
||||
|
||||
discovered = shutil.which(name)
|
||||
candidates = (
|
||||
Path(discovered) if discovered is not None else None,
|
||||
Path("/opt/homebrew/bin") / name,
|
||||
Path("/usr/local/bin") / name,
|
||||
Path("/usr/bin") / name,
|
||||
)
|
||||
for candidate in candidates:
|
||||
if candidate is not None and candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
|
||||
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
|
||||
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
|
||||
@@ -53,19 +76,68 @@ session_recording_materializer = SessionRecordingMaterializer(
|
||||
session_recorded_media_inspector = RecordedMediaInspector(
|
||||
session_store.data_dir / "recorded-media-preparations"
|
||||
)
|
||||
_ffmpeg = shutil.which("ffmpeg")
|
||||
_ffprobe = shutil.which("ffprobe")
|
||||
session_perception_overlay_store = (
|
||||
_ffmpeg = _resolve_media_tool("ffmpeg")
|
||||
_ffprobe = _resolve_media_tool("ffprobe")
|
||||
session_legacy_perception_overlay_store = (
|
||||
RecordedPerceptionOverlayStore(
|
||||
jobs_root=REPOSITORY_ROOT / ".runtime" / "compute-jobs",
|
||||
results_root=REPOSITORY_ROOT / ".runtime" / "compute-results",
|
||||
cache_root=session_store.data_dir / "perception-overlays",
|
||||
ffmpeg_path=Path(_ffmpeg),
|
||||
ffprobe_path=Path(_ffprobe),
|
||||
ffmpeg_path=_ffmpeg,
|
||||
ffprobe_path=_ffprobe,
|
||||
)
|
||||
if _ffmpeg is not None and _ffprobe is not None
|
||||
else None
|
||||
)
|
||||
session_calibrated_fusion_store = RecordedCalibratedFusionStore(
|
||||
jobs_root=REPOSITORY_ROOT / ".runtime" / "compute-jobs",
|
||||
perception_results_root=REPOSITORY_ROOT / ".runtime" / "compute-results",
|
||||
fusion_results_root=REPOSITORY_ROOT / ".runtime" / "compute-fusions",
|
||||
cache_root=session_store.data_dir / "calibrated-fusion-overlays",
|
||||
)
|
||||
session_previous_perception_overlay_store = RecordedPerceptionOverlayMux(
|
||||
session_calibrated_fusion_store, session_legacy_perception_overlay_store
|
||||
)
|
||||
session_integrated_perception_store = (
|
||||
IntegratedPerceptionOverlayStore(
|
||||
jobs_root=REPOSITORY_ROOT / ".runtime" / "compute-jobs",
|
||||
results_root=(
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e10"
|
||||
/ "worker-results"
|
||||
),
|
||||
lidar_packs_root=(
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e10"
|
||||
/ "lidar-packs"
|
||||
),
|
||||
cache_root=session_store.data_dir / "integrated-perception-overlays",
|
||||
ffmpeg_path=_ffmpeg,
|
||||
)
|
||||
if _ffmpeg is not None
|
||||
else None
|
||||
)
|
||||
session_perception_overlay_store = RecordedPerceptionOverlayMux(
|
||||
session_integrated_perception_store or session_previous_perception_overlay_store,
|
||||
(
|
||||
session_previous_perception_overlay_store
|
||||
if session_integrated_perception_store is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
session_perception_epoch_store = (
|
||||
RecordedPerceptionEpochStore(
|
||||
jobs_root=REPOSITORY_ROOT / ".runtime" / "compute-jobs",
|
||||
results_root=REPOSITORY_ROOT / ".runtime" / "compute-results",
|
||||
ffprobe_path=_ffprobe,
|
||||
)
|
||||
if _ffprobe is not None
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def _prepare_recorded_media_for_launch(
|
||||
@@ -320,6 +392,7 @@ app.include_router(
|
||||
recording_preparation_manager=session_recording_preparation_manager,
|
||||
media_inspector=session_recorded_media_inspector,
|
||||
perception_overlay_provider=session_perception_overlay_store,
|
||||
perception_media_provider=session_perception_epoch_store,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ _EPOCH_DIRECTORY = re.compile(r"^epoch-([1-9][0-9]*)$")
|
||||
_SEGMENT_FILE = re.compile(r"^([1-9][0-9]*)\.m4s$")
|
||||
_DEFAULT_COMMIT_INTERVAL_SECONDS = 0.25
|
||||
_DEFAULT_COMMIT_BYTES = 4 * 1024 * 1024
|
||||
_MAX_RECOVERY_INDEX_BYTES = 32 * 1024 * 1024
|
||||
_MAX_RECOVERY_SUMMARY_BYTES = 2 * 1024 * 1024
|
||||
_MAX_RECOVERY_INDEX_LINE_BYTES = 64 * 1024
|
||||
_MAX_RECOVERY_SEGMENT_BYTES = 8 * 1024 * 1024
|
||||
_MAX_RECOVERY_SEGMENTS = 500_000
|
||||
|
||||
_ACTIVE_ARCHIVES_LOCK = threading.Lock()
|
||||
_ACTIVE_ARCHIVES: set[Path] = set()
|
||||
@@ -394,9 +394,11 @@ def recover_incomplete_camera_archives(
|
||||
``interrupted`` summary is atomically written.
|
||||
|
||||
This is intentionally a startup/catalog-refresh operation, not a hot-path
|
||||
operation: it hashes media fragments. The callable is safe to repeat, but a
|
||||
composition layer should normally execute it once per server process before
|
||||
the first catalog import.
|
||||
operation. A clean sealed epoch is validated from its summary, streaming
|
||||
index and segment stat metadata without loading payloads; only an incomplete
|
||||
or damaged epoch enters fragment recovery and hashes candidate payloads. The
|
||||
callable is safe to repeat, but a composition layer should normally execute
|
||||
it once per server process before the first catalog import.
|
||||
"""
|
||||
|
||||
root = sessions_root.expanduser().resolve()
|
||||
@@ -468,30 +470,25 @@ def _recover_epoch(
|
||||
if segments_fd is None:
|
||||
return None
|
||||
try:
|
||||
old_index = _read_regular_at(
|
||||
if _sealed_epoch_is_valid_on_disk(
|
||||
epoch_fd,
|
||||
segments_fd,
|
||||
source_id=source_id,
|
||||
init=init,
|
||||
):
|
||||
return None
|
||||
old_index = _read_regular_at_current_size(
|
||||
epoch_fd,
|
||||
"index.jsonl",
|
||||
_MAX_RECOVERY_INDEX_BYTES,
|
||||
allow_empty=True,
|
||||
)
|
||||
old_summary = _read_regular_at(
|
||||
epoch_fd,
|
||||
"summary.json",
|
||||
_MAX_RECOVERY_INDEX_BYTES,
|
||||
_MAX_RECOVERY_SUMMARY_BYTES,
|
||||
allow_empty=False,
|
||||
)
|
||||
segment_payloads, segment_timestamps, orphans = _read_recovery_segments(
|
||||
segments_fd
|
||||
)
|
||||
if _sealed_epoch_is_valid(
|
||||
source_id=source_id,
|
||||
summary_bytes=old_summary,
|
||||
index_bytes=old_index,
|
||||
segment_payloads=segment_payloads,
|
||||
orphans=orphans,
|
||||
):
|
||||
return None
|
||||
|
||||
segment_timestamps, orphans = _read_recovery_segment_catalog(segments_fd)
|
||||
old_entries = _parse_index_prefix(old_index)
|
||||
old_by_sequence = {
|
||||
int(entry["sequence"]): entry
|
||||
@@ -502,9 +499,17 @@ def _recover_epoch(
|
||||
stream_hash = hashlib.sha256(init)
|
||||
valid_bytes = len(init)
|
||||
expected = 1
|
||||
while expected <= _MAX_RECOVERY_SEGMENTS:
|
||||
payload = segment_payloads.get(expected)
|
||||
while True:
|
||||
if expected not in segment_timestamps:
|
||||
break
|
||||
payload = _read_regular_at(
|
||||
segments_fd,
|
||||
f"{expected}.m4s",
|
||||
_MAX_RECOVERY_SEGMENT_BYTES,
|
||||
allow_empty=False,
|
||||
)
|
||||
if payload is None:
|
||||
orphans.append(f"{expected}.m4s")
|
||||
break
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
previous = old_by_sequence.get(expected)
|
||||
@@ -528,11 +533,10 @@ def _recover_epoch(
|
||||
valid_bytes += len(payload)
|
||||
expected += 1
|
||||
|
||||
valid_sequences = {int(entry["sequence"]) for entry in recovered_entries}
|
||||
orphans.extend(
|
||||
f"{sequence}.m4s"
|
||||
for sequence in segment_payloads
|
||||
if sequence not in valid_sequences
|
||||
for sequence in segment_timestamps
|
||||
if sequence >= expected
|
||||
)
|
||||
orphans = sorted(set(orphans))
|
||||
if not recovered_entries:
|
||||
@@ -611,48 +615,145 @@ def _recover_epoch(
|
||||
os.close(epoch_fd)
|
||||
|
||||
|
||||
def _sealed_epoch_is_valid(
|
||||
def _sealed_epoch_is_valid_on_disk(
|
||||
epoch_fd: int,
|
||||
segments_fd: int,
|
||||
*,
|
||||
source_id: str,
|
||||
summary_bytes: bytes | None,
|
||||
index_bytes: bytes | None,
|
||||
segment_payloads: dict[int, bytes],
|
||||
orphans: list[str],
|
||||
init: bytes,
|
||||
) -> bool:
|
||||
if not summary_bytes or index_bytes is None or orphans:
|
||||
"""Recognize a clean seal without loading a multi-hour archive into RAM.
|
||||
|
||||
Recovery only needs to prove that the durable commit envelope is complete.
|
||||
Full fragment digests and ISO-BMFF timing are revalidated by the recorded
|
||||
media preparation path before browser publication.
|
||||
"""
|
||||
|
||||
summary_bytes = _read_regular_at(
|
||||
epoch_fd,
|
||||
"summary.json",
|
||||
_MAX_RECOVERY_SUMMARY_BYTES,
|
||||
allow_empty=False,
|
||||
)
|
||||
if not summary_bytes:
|
||||
return False
|
||||
try:
|
||||
summary = json.loads(summary_bytes)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return False
|
||||
if not isinstance(summary, dict) or summary.get("source_id") != source_id:
|
||||
return False
|
||||
segment_count = summary.get("segment_count")
|
||||
segment_count = summary.get("segment_count") if isinstance(summary, dict) else None
|
||||
if (
|
||||
not isinstance(segment_count, int)
|
||||
not isinstance(summary, dict)
|
||||
or summary.get("schema_version") != CAMERA_ARCHIVE_SCHEMA
|
||||
or summary.get("source_id") != source_id
|
||||
or not isinstance(segment_count, int)
|
||||
or isinstance(segment_count, bool)
|
||||
or segment_count < 1
|
||||
or set(segment_payloads) != set(range(1, segment_count + 1))
|
||||
or summary.get("entry_count") != segment_count
|
||||
or summary.get("media_segment_count") != segment_count
|
||||
or summary.get("commit_policy") != CAMERA_COMMIT_POLICY
|
||||
or summary.get("init_sha256") != hashlib.sha256(init).hexdigest()
|
||||
):
|
||||
return False
|
||||
entries = _parse_index_prefix(index_bytes)
|
||||
if len(entries) != segment_count:
|
||||
return False
|
||||
return all(
|
||||
_index_entry_matches(
|
||||
entry,
|
||||
sequence,
|
||||
len(segment_payloads[sequence]),
|
||||
hashlib.sha256(segment_payloads[sequence]).hexdigest(),
|
||||
|
||||
try:
|
||||
descriptor = os.open(
|
||||
"index.jsonl",
|
||||
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
|
||||
dir_fd=epoch_fd,
|
||||
)
|
||||
for sequence, entry in enumerate(entries, start=1)
|
||||
except OSError:
|
||||
return False
|
||||
digest = hashlib.sha256()
|
||||
valid_bytes = len(init)
|
||||
try:
|
||||
before = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(before.st_mode) or before.st_size <= 0:
|
||||
return False
|
||||
with os.fdopen(descriptor, "rb") as stream:
|
||||
descriptor = -1
|
||||
for sequence in range(1, segment_count + 1):
|
||||
raw_line = stream.readline(_MAX_RECOVERY_INDEX_LINE_BYTES + 1)
|
||||
if (
|
||||
not raw_line
|
||||
or len(raw_line) > _MAX_RECOVERY_INDEX_LINE_BYTES
|
||||
or not raw_line.endswith(b"\n")
|
||||
):
|
||||
return False
|
||||
digest.update(raw_line)
|
||||
try:
|
||||
entry = json.loads(raw_line)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return False
|
||||
length = entry.get("length") if isinstance(entry, dict) else None
|
||||
if (
|
||||
not isinstance(length, int)
|
||||
or isinstance(length, bool)
|
||||
or not 0 < length <= _MAX_RECOVERY_SEGMENT_BYTES
|
||||
or not _index_entry_shape_matches(entry, sequence)
|
||||
):
|
||||
return False
|
||||
try:
|
||||
segment_stat = os.stat(
|
||||
f"{sequence}.m4s",
|
||||
dir_fd=segments_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
if not stat.S_ISREG(segment_stat.st_mode) or segment_stat.st_size != length:
|
||||
return False
|
||||
valid_bytes += length
|
||||
if stream.read(1):
|
||||
return False
|
||||
after = os.fstat(stream.fileno())
|
||||
if (
|
||||
(before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns)
|
||||
!= (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns)
|
||||
):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
try:
|
||||
segment_names = [
|
||||
name
|
||||
for name in os.listdir(segments_fd)
|
||||
if _SEGMENT_FILE.fullmatch(name) is not None
|
||||
]
|
||||
except OSError:
|
||||
return False
|
||||
if len(segment_names) != segment_count or any(
|
||||
name != f"{sequence}.m4s"
|
||||
for sequence, name in enumerate(
|
||||
sorted(segment_names, key=lambda name: int(name.removesuffix(".m4s"))),
|
||||
start=1,
|
||||
)
|
||||
):
|
||||
return False
|
||||
return (
|
||||
summary.get("index_sha256") == digest.hexdigest()
|
||||
and summary.get("valid_bytes") == valid_bytes
|
||||
)
|
||||
|
||||
|
||||
def _read_recovery_segments(
|
||||
def _index_entry_shape_matches(entry: object, sequence: int) -> bool:
|
||||
return (
|
||||
isinstance(entry, dict)
|
||||
and entry.get("schema_version") == CAMERA_INDEX_SCHEMA
|
||||
and entry.get("sequence") == sequence
|
||||
and entry.get("kind") == "media"
|
||||
and entry.get("path") == f"segments/{sequence}.m4s"
|
||||
and isinstance(entry.get("sha256"), str)
|
||||
and re.fullmatch(r"[a-f0-9]{64}", str(entry["sha256"])) is not None
|
||||
)
|
||||
|
||||
|
||||
def _read_recovery_segment_catalog(
|
||||
segments_fd: int,
|
||||
) -> tuple[dict[int, bytes], dict[int, int], list[str]]:
|
||||
payloads: dict[int, bytes] = {}
|
||||
) -> tuple[dict[int, int], list[str]]:
|
||||
timestamps: dict[int, int] = {}
|
||||
orphans: list[str] = []
|
||||
try:
|
||||
@@ -664,22 +765,22 @@ def _read_recovery_segments(
|
||||
if match is None:
|
||||
continue
|
||||
sequence = int(match.group(1))
|
||||
if name != f"{sequence}.m4s" or sequence in payloads:
|
||||
if name != f"{sequence}.m4s" or sequence in timestamps:
|
||||
orphans.append(name)
|
||||
continue
|
||||
read_result = _read_regular_with_metadata_at(
|
||||
segments_fd,
|
||||
name,
|
||||
_MAX_RECOVERY_SEGMENT_BYTES,
|
||||
allow_empty=False,
|
||||
)
|
||||
if read_result is None:
|
||||
try:
|
||||
metadata = os.stat(name, dir_fd=segments_fd, follow_symlinks=False)
|
||||
except OSError:
|
||||
orphans.append(name)
|
||||
continue
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or not 0 < metadata.st_size <= _MAX_RECOVERY_SEGMENT_BYTES
|
||||
):
|
||||
orphans.append(name)
|
||||
continue
|
||||
payload, metadata = read_result
|
||||
payloads[sequence] = payload
|
||||
timestamps[sequence] = metadata.st_mtime_ns
|
||||
return payloads, timestamps, orphans
|
||||
return timestamps, orphans
|
||||
|
||||
|
||||
def _parse_index_prefix(payload: bytes | None) -> list[dict[str, Any]]:
|
||||
@@ -908,6 +1009,28 @@ def _read_regular_at(
|
||||
return result[0] if result is not None else None
|
||||
|
||||
|
||||
def _read_regular_at_current_size(
|
||||
parent_fd: int,
|
||||
name: str,
|
||||
*,
|
||||
allow_empty: bool,
|
||||
) -> bytes | None:
|
||||
"""Read one private recovery artifact without a duration-derived ceiling."""
|
||||
|
||||
try:
|
||||
metadata = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
||||
except OSError:
|
||||
return None
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
return None
|
||||
return _read_regular_at(
|
||||
parent_fd,
|
||||
name,
|
||||
max(1, metadata.st_size),
|
||||
allow_empty=allow_empty,
|
||||
)
|
||||
|
||||
|
||||
def _read_regular_with_metadata_at(
|
||||
parent_fd: int,
|
||||
name: str,
|
||||
|
||||
@@ -328,7 +328,7 @@ class AcquisitionRecord:
|
||||
control_mode: ControlMode
|
||||
requested_streams: tuple[str, ...]
|
||||
target_host: str
|
||||
duration_seconds: float
|
||||
duration_seconds: float | None
|
||||
evidence_policy: Literal["required", "best-effort", "disabled"]
|
||||
state: AcquisitionState = "preparing"
|
||||
state_revision: int = 1
|
||||
|
||||
+361
-29
@@ -2,20 +2,21 @@ from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Annotated, Any, Literal, Protocol
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, field_validator, model_validator
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from k1link.compute import RecordedPerceptionOverlayError
|
||||
from k1link.compute import RecordedPerceptionOverlayError, RecordedPerceptionVideo
|
||||
from k1link.sessions import (
|
||||
RECORDED_MEDIA_MANIFEST_SCHEMA,
|
||||
LayoutConflictError,
|
||||
MaterializedRecording,
|
||||
RecordedMediaFile,
|
||||
@@ -42,6 +43,7 @@ from k1link.viewer.recorded import (
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
|
||||
DEFAULT_REPLAY_ACTION_ID = "stream.start-replay"
|
||||
RECORDED_MEDIA_STREAM_MANIFEST_SCHEMA = "missioncore.observation-recorded-media/v3"
|
||||
SAFE_SOURCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")
|
||||
SAFE_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
||||
@@ -103,7 +105,12 @@ class RecordedBlueprintRequest(StrictApiModel):
|
||||
point_size: float = Field(default=2.5, strict=True, ge=0.1, le=32.0)
|
||||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
|
||||
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
active_view: Literal["spatial", "perception", "metrics"] = "spatial"
|
||||
active_view: Literal["spatial", "perception", "perception3d", "metrics"] = "spatial"
|
||||
view_reset_generation: Literal[0, 1] = 0
|
||||
unified_perception: StrictBool = False
|
||||
show_detections_2d: StrictBool = False
|
||||
show_segmentation: StrictBool = False
|
||||
show_cuboids_3d: StrictBool = False
|
||||
|
||||
|
||||
class RecordedPerceptionRequest(StrictApiModel):
|
||||
@@ -254,6 +261,14 @@ class RecordedPerceptionOverlayProvider(Protocol):
|
||||
) -> bytes | None: ...
|
||||
|
||||
|
||||
class RecordedPerceptionMediaProvider(Protocol):
|
||||
def video(
|
||||
self,
|
||||
session_id: str,
|
||||
result_id: str | None = None,
|
||||
) -> RecordedPerceptionVideo | None: ...
|
||||
|
||||
|
||||
def build_session_router(
|
||||
store: SessionStore,
|
||||
*,
|
||||
@@ -263,6 +278,7 @@ def build_session_router(
|
||||
recording_preparation_manager: SessionRecordingPreparationManager | None = None,
|
||||
media_inspector: RecordedMediaInspector | None = None,
|
||||
perception_overlay_provider: RecordedPerceptionOverlayProvider | None = None,
|
||||
perception_media_provider: RecordedPerceptionMediaProvider | None = None,
|
||||
allow_synchronous_recording_fallback: bool = False,
|
||||
replay_action_id: str = DEFAULT_REPLAY_ACTION_ID,
|
||||
) -> APIRouter:
|
||||
@@ -463,6 +479,7 @@ def build_session_router(
|
||||
reserved.recording,
|
||||
command,
|
||||
reserved.recorded_media or (),
|
||||
perception_media_provider=perception_media_provider,
|
||||
)
|
||||
|
||||
if recording_materializer is not None and allow_synchronous_recording_fallback:
|
||||
@@ -477,7 +494,12 @@ def build_session_router(
|
||||
)
|
||||
except SessionIntegrityError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return _recording_launch_document(recording, command, recorded_media)
|
||||
return _recording_launch_document(
|
||||
recording,
|
||||
command,
|
||||
recorded_media,
|
||||
perception_media_provider=perception_media_provider,
|
||||
)
|
||||
if recording_materializer is not None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
@@ -544,6 +566,7 @@ def build_session_router(
|
||||
reserved.recording,
|
||||
snapshot.command,
|
||||
snapshot.recorded_media or (),
|
||||
perception_media_provider=perception_media_provider,
|
||||
),
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
@@ -778,6 +801,11 @@ def build_session_router(
|
||||
application_id=RECORDED_APPLICATION_ID,
|
||||
recording_id=request.recording_id,
|
||||
active_view=request.active_view,
|
||||
view_reset_generation=request.view_reset_generation,
|
||||
unified_perception=request.unified_perception,
|
||||
show_detections_2d=request.show_detections_2d,
|
||||
show_segmentation=request.show_segmentation,
|
||||
show_cuboids_3d=request.show_cuboids_3d,
|
||||
)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
@@ -853,6 +881,63 @@ def build_session_router(
|
||||
},
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/api/v1/observation-sessions/{session_id}/perception-media/"
|
||||
"{result_id}/manifest"
|
||||
)
|
||||
def get_recorded_perception_media_manifest(
|
||||
session_id: str,
|
||||
result_id: str,
|
||||
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
|
||||
) -> JSONResponse:
|
||||
video = _resolve_recorded_perception_video(
|
||||
store,
|
||||
catalog_refresher,
|
||||
perception_media_provider,
|
||||
session_id,
|
||||
result_id,
|
||||
)
|
||||
etag = f'"sha256:{video.sha256}"'
|
||||
_require_matching_digest(etag, if_match)
|
||||
return JSONResponse(
|
||||
content=_recorded_perception_manifest_document(video),
|
||||
headers={
|
||||
"Cache-Control": "private, no-cache",
|
||||
"ETag": etag,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
@router.api_route(
|
||||
"/api/v1/observation-sessions/{session_id}/perception-media/"
|
||||
"{result_id}/recording.mp4",
|
||||
methods=["GET", "HEAD"],
|
||||
)
|
||||
def stream_recorded_perception_media(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
result_id: str,
|
||||
generation: Annotated[str, Query(min_length=64, max_length=64)],
|
||||
range_header: Annotated[str | None, Header(alias="Range")] = None,
|
||||
) -> Response:
|
||||
video = _resolve_recorded_perception_video(
|
||||
store,
|
||||
catalog_refresher,
|
||||
perception_media_provider,
|
||||
session_id,
|
||||
result_id,
|
||||
)
|
||||
if SAFE_SHA256.fullmatch(generation) is None or generation != video.sha256:
|
||||
raise HTTPException(
|
||||
status_code=412,
|
||||
detail="Поколение видео сегментации было заменено.",
|
||||
)
|
||||
return _recorded_perception_video_response(
|
||||
video,
|
||||
range_header,
|
||||
head_only=request.method == "HEAD",
|
||||
)
|
||||
|
||||
@router.get("/api/v1/observation-sessions/{session_id}/media/{artifact_id}/manifest")
|
||||
def get_recorded_media_manifest(
|
||||
session_id: str,
|
||||
@@ -944,6 +1029,41 @@ def build_session_router(
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return _recorded_media_file_response(media_file, range_header)
|
||||
|
||||
@router.api_route(
|
||||
"/api/v1/observation-sessions/{session_id}/media/{artifact_id}/"
|
||||
"epochs/{epoch_ordinal}/recording.mp4",
|
||||
methods=["GET", "HEAD"],
|
||||
)
|
||||
def stream_recorded_media_epoch(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
artifact_id: str,
|
||||
epoch_ordinal: int,
|
||||
generation: Annotated[str, Query(min_length=64, max_length=64)],
|
||||
range_header: Annotated[str | None, Header(alias="Range")] = None,
|
||||
) -> Response:
|
||||
manifest = _resolve_recorded_media_manifest(
|
||||
store,
|
||||
recorded_media_inspector,
|
||||
recording_preparation_manager,
|
||||
allow_synchronous_recording_fallback,
|
||||
catalog_refresher,
|
||||
session_id,
|
||||
artifact_id,
|
||||
)
|
||||
if SAFE_SHA256.fullmatch(generation) is None or generation != manifest.generation_sha256:
|
||||
raise HTTPException(
|
||||
status_code=412,
|
||||
detail="Поколение записанного видео было заменено.",
|
||||
)
|
||||
return _recorded_media_epoch_response(
|
||||
recorded_media_inspector,
|
||||
manifest,
|
||||
epoch_ordinal,
|
||||
range_header,
|
||||
head_only=request.method == "HEAD",
|
||||
)
|
||||
|
||||
@router.get("/api/v1/workspace-layouts/{workspace_id}")
|
||||
def get_workspace_layout(workspace_id: str, response: Response) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -1259,9 +1379,16 @@ def _recording_launch_document(
|
||||
recording: MaterializedRecording,
|
||||
command: ReplayCommand,
|
||||
recorded_media: tuple[RecordedMediaManifest, ...] = (),
|
||||
*,
|
||||
perception_media_provider: RecordedPerceptionMediaProvider | None = None,
|
||||
) -> dict[str, Any]:
|
||||
del perception_media_provider
|
||||
encoded_session_id = quote(recording.session_id, safe="")
|
||||
source_url = f"/api/v1/observation-sessions/{encoded_session_id}/recording.rrd"
|
||||
media_sources = [
|
||||
_recorded_media_launch_source(manifest, index=index)
|
||||
for index, manifest in enumerate(recorded_media, start=1)
|
||||
]
|
||||
return {
|
||||
"schema_version": "missioncore.observation-session-replay/v2",
|
||||
"launch": {
|
||||
@@ -1280,13 +1407,7 @@ def _recording_launch_document(
|
||||
"speed": command.speed,
|
||||
"loop": command.loop,
|
||||
},
|
||||
"media_sources": [
|
||||
_recorded_media_launch_source(
|
||||
manifest,
|
||||
index=index,
|
||||
)
|
||||
for index, manifest in enumerate(recorded_media, start=1)
|
||||
],
|
||||
"media_sources": media_sources,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1334,6 +1455,137 @@ def _recorded_media_launch_source(
|
||||
}
|
||||
|
||||
|
||||
def _recorded_perception_launch_source(video: RecordedPerceptionVideo) -> dict[str, Any]:
|
||||
encoded_session_id = quote(video.session_id, safe="")
|
||||
encoded_result_id = quote(video.result_id, safe="")
|
||||
return {
|
||||
"id": video.public_source_id,
|
||||
"label": video.label,
|
||||
"modality": "video",
|
||||
"manifest_url": (
|
||||
f"/api/v1/observation-sessions/{encoded_session_id}/perception-media/"
|
||||
f"{encoded_result_id}/manifest"
|
||||
),
|
||||
"media_type": "video/mp4",
|
||||
"manifest_generation_sha256": video.sha256,
|
||||
"byte_length": video.byte_length,
|
||||
"timeline_start_seconds": video.timeline_start_seconds,
|
||||
"timeline_end_seconds": video.timeline_end_seconds,
|
||||
"seekable": True,
|
||||
"synchronization": "host-arrival-best-effort",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_recorded_perception_video(
|
||||
store: SessionStore,
|
||||
catalog_refresher: CatalogRefresher | None,
|
||||
provider: RecordedPerceptionMediaProvider | None,
|
||||
session_id: str,
|
||||
result_id: str,
|
||||
) -> RecordedPerceptionVideo:
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=404, detail="Видео сегментации не найдено.")
|
||||
try:
|
||||
_prepare_replay(
|
||||
store,
|
||||
catalog_refresher,
|
||||
session_id,
|
||||
1.0,
|
||||
False,
|
||||
False,
|
||||
)
|
||||
video = provider.video(session_id, result_id)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (SessionNotReplayableError, SessionIntegrityError) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Некорректный идентификатор результата сегментации.",
|
||||
) from exc
|
||||
if video is None:
|
||||
raise HTTPException(status_code=404, detail="Видео сегментации не найдено.")
|
||||
return video
|
||||
|
||||
|
||||
def _recorded_perception_manifest_document(
|
||||
video: RecordedPerceptionVideo,
|
||||
) -> dict[str, Any]:
|
||||
encoded_session_id = quote(video.session_id, safe="")
|
||||
encoded_result_id = quote(video.result_id, safe="")
|
||||
base = (
|
||||
f"/api/v1/observation-sessions/{encoded_session_id}/perception-media/"
|
||||
f"{encoded_result_id}"
|
||||
)
|
||||
return {
|
||||
"schema_version": RECORDED_MEDIA_STREAM_MANIFEST_SCHEMA,
|
||||
"source_id": video.public_source_id,
|
||||
"generation_sha256": video.sha256,
|
||||
"byte_length": video.byte_length,
|
||||
"timeline_start_seconds": video.timeline_start_seconds,
|
||||
"timeline_end_seconds": video.timeline_end_seconds,
|
||||
"synchronization": "host-arrival-best-effort",
|
||||
"epochs": [
|
||||
{
|
||||
"ordinal": 1,
|
||||
"timeline_start_seconds": video.timeline_start_seconds,
|
||||
"timeline_end_seconds": video.timeline_end_seconds,
|
||||
"media_type": video.media_type,
|
||||
"byte_length": video.byte_length,
|
||||
"stream_url": f"{base}/recording.mp4?generation={video.sha256}",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _recorded_perception_video_response(
|
||||
video: RecordedPerceptionVideo,
|
||||
range_header: str | None,
|
||||
*,
|
||||
head_only: bool,
|
||||
) -> Response:
|
||||
start, end = 0, video.byte_length - 1
|
||||
status_code = 200
|
||||
if range_header is not None:
|
||||
start, end = _parse_byte_range(range_header, video.byte_length)
|
||||
status_code = 206
|
||||
headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "private, max-age=31536000, immutable, no-transform",
|
||||
"ETag": f'"sha256:{video.sha256}"',
|
||||
"Content-Length": str(end - start + 1),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Disposition": 'inline; filename="recorded-perception.mp4"',
|
||||
}
|
||||
if status_code == 206:
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{video.byte_length}"
|
||||
if head_only:
|
||||
return Response(
|
||||
status_code=status_code,
|
||||
media_type=video.media_type,
|
||||
headers=headers,
|
||||
)
|
||||
return StreamingResponse(
|
||||
_iter_regular_file_range(video.path, start, end),
|
||||
status_code=status_code,
|
||||
media_type=video.media_type,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _iter_regular_file_range(path: Path, start: int, end: int) -> Iterator[bytes]:
|
||||
remaining = end - start + 1
|
||||
with path.open("rb") as stream:
|
||||
stream.seek(start)
|
||||
while remaining:
|
||||
chunk = stream.read(min(1024 * 1024, remaining))
|
||||
if not chunk:
|
||||
raise SessionIntegrityError("recorded perception video ended unexpectedly")
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
|
||||
|
||||
def _recorded_media_manifest_document(
|
||||
manifest: RecordedMediaManifest,
|
||||
) -> dict[str, Any]:
|
||||
@@ -1341,7 +1593,7 @@ def _recorded_media_manifest_document(
|
||||
encoded_artifact_id = quote(manifest.artifact_id, safe="")
|
||||
base = f"/api/v1/observation-sessions/{encoded_session_id}/media/{encoded_artifact_id}"
|
||||
return {
|
||||
"schema_version": RECORDED_MEDIA_MANIFEST_SCHEMA,
|
||||
"schema_version": RECORDED_MEDIA_STREAM_MANIFEST_SCHEMA,
|
||||
"source_id": manifest.public_source_id,
|
||||
"generation_sha256": manifest.generation_sha256,
|
||||
"byte_length": manifest.byte_length,
|
||||
@@ -1354,20 +1606,12 @@ def _recorded_media_manifest_document(
|
||||
"timeline_start_seconds": epoch.timeline_start_seconds,
|
||||
"timeline_end_seconds": epoch.timeline_end_seconds,
|
||||
"media_type": epoch.media_type,
|
||||
"init_url": f"{base}/epochs/{epoch.ordinal}/init.mp4",
|
||||
"init_byte_length": epoch.init_byte_length,
|
||||
"init_sha256": epoch.init_sha256,
|
||||
"segment_count": len(epoch.segments),
|
||||
"segment_url_prefix": f"{base}/epochs/{epoch.ordinal}/segments/",
|
||||
"segments": [
|
||||
{
|
||||
"sequence": segment.sequence,
|
||||
"url": (f"{base}/epochs/{epoch.ordinal}/segments/{segment.sequence}.m4s"),
|
||||
"byte_length": segment.byte_length,
|
||||
"sha256": segment.sha256,
|
||||
}
|
||||
for segment in epoch.segments
|
||||
],
|
||||
"byte_length": epoch.init_byte_length
|
||||
+ sum(segment.byte_length for segment in epoch.segments),
|
||||
"stream_url": (
|
||||
f"{base}/epochs/{epoch.ordinal}/recording.mp4"
|
||||
f"?generation={manifest.generation_sha256}"
|
||||
),
|
||||
}
|
||||
for epoch in manifest.epochs
|
||||
],
|
||||
@@ -1500,6 +1744,94 @@ def _recorded_media_file_response(
|
||||
)
|
||||
|
||||
|
||||
def _recorded_media_epoch_response(
|
||||
inspector: RecordedMediaInspector,
|
||||
manifest: RecordedMediaManifest,
|
||||
epoch_ordinal: int,
|
||||
range_header: str | None,
|
||||
*,
|
||||
head_only: bool,
|
||||
) -> Response:
|
||||
matches = tuple(epoch for epoch in manifest.epochs if epoch.ordinal == epoch_ordinal)
|
||||
if len(matches) != 1:
|
||||
raise HTTPException(status_code=404, detail="Эпоха записанного медиаканала не найдена.")
|
||||
epoch = matches[0]
|
||||
byte_length = epoch.init_byte_length + sum(
|
||||
segment.byte_length for segment in epoch.segments
|
||||
)
|
||||
start, end = (0, byte_length - 1)
|
||||
status_code = 200
|
||||
if range_header is not None:
|
||||
start, end = _parse_byte_range(range_header, byte_length)
|
||||
status_code = 206
|
||||
|
||||
etag = f'"generation:{manifest.generation_sha256}:epoch:{epoch.ordinal}"'
|
||||
headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "private, max-age=31536000, immutable, no-transform",
|
||||
"ETag": etag,
|
||||
"Content-Length": str(end - start + 1),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Disposition": (
|
||||
f'inline; filename="recorded-camera-{epoch.ordinal}.mp4"'
|
||||
),
|
||||
}
|
||||
if status_code == 206:
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{byte_length}"
|
||||
if head_only:
|
||||
return Response(
|
||||
status_code=status_code,
|
||||
media_type=epoch.media_type,
|
||||
headers=headers,
|
||||
)
|
||||
return StreamingResponse(
|
||||
_iter_recorded_media_epoch_range(
|
||||
inspector,
|
||||
manifest,
|
||||
epoch.ordinal,
|
||||
start,
|
||||
end,
|
||||
),
|
||||
status_code=status_code,
|
||||
media_type=epoch.media_type,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _iter_recorded_media_epoch_range(
|
||||
inspector: RecordedMediaInspector,
|
||||
manifest: RecordedMediaManifest,
|
||||
epoch_ordinal: int,
|
||||
start: int,
|
||||
end: int,
|
||||
) -> Iterator[bytes]:
|
||||
matches = tuple(epoch for epoch in manifest.epochs if epoch.ordinal == epoch_ordinal)
|
||||
if len(matches) != 1:
|
||||
raise SessionIntegrityError("recorded media epoch is unavailable")
|
||||
epoch = matches[0]
|
||||
offset = 0
|
||||
parts = chain(
|
||||
((epoch.init_byte_length, None),),
|
||||
((segment.byte_length, segment.sequence) for segment in epoch.segments),
|
||||
)
|
||||
for part_length, sequence in parts:
|
||||
part_end = offset + part_length - 1
|
||||
if part_end < start:
|
||||
offset += part_length
|
||||
continue
|
||||
if offset > end:
|
||||
break
|
||||
media_file = (
|
||||
inspector.get_init(manifest, epoch_ordinal)
|
||||
if sequence is None
|
||||
else inspector.get_segment(manifest, epoch_ordinal, sequence)
|
||||
)
|
||||
local_start = max(0, start - offset)
|
||||
local_end = min(part_length - 1, end - offset)
|
||||
yield media_file.payload[local_start : local_end + 1]
|
||||
offset += part_length
|
||||
|
||||
|
||||
def _parse_byte_range(value: str, byte_length: int) -> tuple[int, int]:
|
||||
match = re.fullmatch(r"bytes=(\d*)-(\d*)", value.strip())
|
||||
if match is None or byte_length < 1:
|
||||
|
||||
Reference in New Issue
Block a user