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:
DCCONSTRUCTIONS
2026-07-23 00:23:28 +03:00
parent ada2a55ee6
commit b53d6d5a45
221 changed files with 55923 additions and 1357 deletions
+79 -6
View File
@@ -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,
)
)
+183 -60
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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: