2138 lines
82 KiB
Python
2138 lines
82 KiB
Python
from __future__ import annotations
|
|
|
|
import inspect
|
|
import re
|
|
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, 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 (
|
|
RecordedPerceptionOverlayArtifact,
|
|
RecordedPerceptionOverlayError,
|
|
RecordedPerceptionVideo,
|
|
)
|
|
from k1link.sessions import (
|
|
LayoutConflictError,
|
|
MaterializedRecording,
|
|
RecordedMediaFile,
|
|
RecordedMediaInspector,
|
|
RecordedMediaManifest,
|
|
RecordingMaterializationError,
|
|
RecordingPreparationQueueFull,
|
|
RecordingPreparationSnapshot,
|
|
ReplayCommand,
|
|
SessionIntegrityError,
|
|
SessionNotFoundError,
|
|
SessionNotReplayableError,
|
|
SessionRecordingPreparationManager,
|
|
SessionStore,
|
|
validate_recorded_media_timeline,
|
|
)
|
|
from k1link.sessions.canonical_lab_spatial import (
|
|
CANONICAL_LAB_SPATIAL_PROFILE,
|
|
canonical_lab_spatial_frame,
|
|
)
|
|
from k1link.sessions.plugin_contract import RecordedPointColorRenderer
|
|
from k1link.viewer.recorded import (
|
|
APPLICATION_ID as RECORDED_APPLICATION_ID,
|
|
)
|
|
from k1link.viewer.recorded import (
|
|
RecordedBlueprintError,
|
|
recorded_blueprint_rrd,
|
|
)
|
|
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
|
|
|
DEFAULT_REPLAY_ACTION_ID = "stream.start-replay"
|
|
RECORDED_MEDIA_STREAM_MANIFEST_SCHEMA = "missioncore.observation-recorded-media/v4"
|
|
RECORDED_PERCEPTION_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
|
|
REVALIDATED_RECORDING_CACHE_CONTROL = "private, no-cache, no-transform"
|
|
IMMUTABLE_RECORDING_CACHE_CONTROL = "private, max-age=31536000, immutable, no-transform"
|
|
|
|
|
|
class _ReleasingFileResponse(FileResponse):
|
|
"""Release a cache pin exactly once after every ASGI completion path."""
|
|
|
|
def __init__(
|
|
self,
|
|
*args: Any,
|
|
release: Callable[[], None],
|
|
**kwargs: Any,
|
|
) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
self._release = release
|
|
self._release_guard = Lock()
|
|
self._released = False
|
|
|
|
def _release_once(self) -> None:
|
|
with self._release_guard:
|
|
if self._released:
|
|
return
|
|
self._released = True
|
|
self._release()
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
try:
|
|
await super().__call__(scope, receive, send)
|
|
finally:
|
|
# Starlette background tasks do not run when `send` is cancelled
|
|
# or raises. The finally block makes the response lifetime the
|
|
# authoritative pin lifetime for success, disconnect and failure.
|
|
self._release_once()
|
|
|
|
|
|
class StrictApiModel(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
class ReplayRequest(StrictApiModel):
|
|
speed: float = Field(default=1.0, ge=0.0, le=100.0)
|
|
loop: bool = False
|
|
|
|
|
|
class RecordedBlueprintRequest(StrictApiModel):
|
|
application_id: Literal["nodedc_mission_core_recorded"]
|
|
recording_id: str = Field(
|
|
min_length=1,
|
|
max_length=128,
|
|
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$",
|
|
)
|
|
blueprint_session_id: str = Field(
|
|
min_length=32,
|
|
max_length=32,
|
|
pattern=r"^[a-f0-9]{32}$",
|
|
)
|
|
accumulation_seconds: float = Field(strict=True, ge=0.0, le=3600.0)
|
|
show_points: StrictBool
|
|
show_trajectory: StrictBool
|
|
show_grid: StrictBool
|
|
point_size: float = Field(default=2.5, strict=True, ge=0.1, le=32.0)
|
|
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
|
|
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", "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
|
|
follow_trajectory: StrictBool = False
|
|
|
|
|
|
class RecordedPerceptionRequest(StrictApiModel):
|
|
application_id: Literal["nodedc_mission_core_recorded"]
|
|
recording_id: str = Field(
|
|
min_length=1,
|
|
max_length=128,
|
|
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$",
|
|
)
|
|
|
|
|
|
class RecordedPerceptionPreparationStatus(StrictApiModel):
|
|
state: Literal["idle", "preparing", "ready", "unavailable", "error"]
|
|
phase: Literal[
|
|
"idle",
|
|
"cache-lookup",
|
|
"queued",
|
|
"artifact-validation",
|
|
"rendering",
|
|
"cache-write",
|
|
"ready",
|
|
"unavailable",
|
|
"error",
|
|
]
|
|
elapsed_seconds: float = Field(ge=0.0)
|
|
byte_length: int | None = Field(default=None, ge=0)
|
|
|
|
|
|
class RecordedPointColorsRequest(StrictApiModel):
|
|
application_id: Literal["nodedc_mission_core_recorded"]
|
|
recording_id: str = Field(
|
|
min_length=1,
|
|
max_length=128,
|
|
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$",
|
|
)
|
|
color_mode: Literal["intensity", "height", "distance", "rgb", "class"]
|
|
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"]
|
|
custom_color: str = Field(pattern=r"^#[0-9A-Fa-f]{6}$")
|
|
|
|
|
|
class SceneSettingsDocument(StrictApiModel):
|
|
projection: Literal["3d", "2d", "map"]
|
|
point_size: float = Field(ge=0.1, le=32.0)
|
|
color_mode: Literal["intensity", "height", "distance", "rgb", "class"]
|
|
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"]
|
|
custom_color: str = Field(pattern=r"^#[0-9A-Fa-f]{6}$")
|
|
accumulation_seconds: float = Field(ge=0.0, le=3600.0)
|
|
show_points: bool
|
|
show_trajectory: bool
|
|
show_grid: bool
|
|
show_labels: bool
|
|
show_camera_frustums: bool
|
|
|
|
|
|
class LegacyToolWindowsDocument(StrictApiModel):
|
|
sources_open: bool
|
|
display_open: bool
|
|
layers_open: bool
|
|
order: list[Literal["sources", "display", "layers"]]
|
|
|
|
@field_validator("order")
|
|
@classmethod
|
|
def validate_order(cls, value: list[str]) -> list[str]:
|
|
if len(value) != 3 or len(value) != len(set(value)):
|
|
raise ValueError("tool window order must be a permutation of all three tools")
|
|
return value
|
|
|
|
|
|
class NormalizedWindowRect(StrictApiModel):
|
|
x: float = Field(ge=0.0, le=1.0)
|
|
y: float = Field(ge=0.0, le=1.0)
|
|
width: float = Field(gt=0.0, le=1.0)
|
|
height: float = Field(gt=0.0, le=1.0)
|
|
|
|
@model_validator(mode="after")
|
|
def validate_bounds(self) -> NormalizedWindowRect:
|
|
epsilon = 1e-9
|
|
if self.x + self.width > 1.0 + epsilon or self.y + self.height > 1.0 + epsilon:
|
|
raise ValueError("normalized window rectangle escapes the viewport")
|
|
return self
|
|
|
|
|
|
class ViewportSize(StrictApiModel):
|
|
width: float = Field(gt=0.0, le=100_000.0)
|
|
height: float = Field(gt=0.0, le=100_000.0)
|
|
|
|
|
|
class LegacyLayoutV1Document(StrictApiModel):
|
|
version: Literal[1]
|
|
revision: int = Field(ge=0, le=MAX_SAFE_INTEGER)
|
|
workspace_id: Literal["observation.spatial"]
|
|
scene_settings: SceneSettingsDocument
|
|
tool_windows: LegacyToolWindowsDocument
|
|
visible_source_ids: list[str]
|
|
active_floating_source_id: str | None
|
|
window_rects: dict[str, NormalizedWindowRect]
|
|
viewport_size: ViewportSize
|
|
|
|
|
|
class LayoutPutRequest(StrictApiModel):
|
|
version: Literal[2]
|
|
revision: int = Field(ge=0, le=MAX_SAFE_INTEGER)
|
|
workspace_id: Literal["observation.spatial"]
|
|
scene_settings: SceneSettingsDocument
|
|
visible_source_ids: list[str]
|
|
active_floating_source_id: str | None
|
|
window_rects: dict[str, NormalizedWindowRect]
|
|
viewport_size: ViewportSize
|
|
|
|
@field_validator("visible_source_ids")
|
|
@classmethod
|
|
def validate_visible_source_ids(cls, value: list[str]) -> list[str]:
|
|
if len(value) > 256 or len(value) != len(set(value)):
|
|
raise ValueError("visible source identifiers must be unique and bounded")
|
|
for source_id in value:
|
|
_validate_source_id(source_id)
|
|
return value
|
|
|
|
@field_validator("active_floating_source_id")
|
|
@classmethod
|
|
def validate_active_source_id(cls, value: str | None) -> str | None:
|
|
if value is not None:
|
|
_validate_source_id(value)
|
|
return value
|
|
|
|
@field_validator("window_rects")
|
|
@classmethod
|
|
def validate_window_rect_ids(
|
|
cls,
|
|
value: dict[str, NormalizedWindowRect],
|
|
) -> dict[str, NormalizedWindowRect]:
|
|
if len(value) > 256:
|
|
raise ValueError("window rectangle catalog exceeds its boundary")
|
|
for source_id in value:
|
|
_validate_source_id(source_id)
|
|
return value
|
|
|
|
@model_validator(mode="after")
|
|
def validate_active_source_visibility(self) -> LayoutPutRequest:
|
|
if (
|
|
self.active_floating_source_id is not None
|
|
and self.active_floating_source_id not in self.visible_source_ids
|
|
):
|
|
raise ValueError("active floating source must be visible")
|
|
return self
|
|
|
|
|
|
class ReplayLauncher(Protocol):
|
|
def __call__(
|
|
self,
|
|
command: ReplayCommand,
|
|
) -> Mapping[str, Any] | Awaitable[Mapping[str, Any]]: ...
|
|
|
|
|
|
class RecordingMaterializer(Protocol):
|
|
def __call__(self, command: ReplayCommand) -> MaterializedRecording: ...
|
|
|
|
|
|
class CatalogRefresher(Protocol):
|
|
"""Synchronous app-owned recovery + discovery transaction hook.
|
|
|
|
The host composes hardware-specific recovery before generic session
|
|
discovery, then passes the resulting callable here. Return values stay
|
|
internal and are intentionally ignored by the API.
|
|
"""
|
|
|
|
def __call__(self) -> object: ...
|
|
|
|
|
|
class RecordedPerceptionOverlayProvider(Protocol):
|
|
def render(
|
|
self,
|
|
session_id: str,
|
|
*,
|
|
application_id: str,
|
|
recording_id: str,
|
|
) -> bytes | None: ...
|
|
|
|
|
|
class RecordedPerceptionMediaProvider(Protocol):
|
|
def video(
|
|
self,
|
|
session_id: str,
|
|
result_id: str | None = None,
|
|
) -> RecordedPerceptionVideo | None: ...
|
|
|
|
|
|
def build_session_router(
|
|
store: SessionStore,
|
|
*,
|
|
catalog_refresher: CatalogRefresher | None = None,
|
|
replay_launcher: ReplayLauncher | None = None,
|
|
recording_materializer: RecordingMaterializer | None = None,
|
|
recording_preparation_manager: SessionRecordingPreparationManager | None = None,
|
|
media_inspector: RecordedMediaInspector | None = None,
|
|
perception_overlay_provider: RecordedPerceptionOverlayProvider | None = None,
|
|
perception_media_provider: RecordedPerceptionMediaProvider | None = None,
|
|
point_color_renderers: Mapping[str, RecordedPointColorRenderer] | None = None,
|
|
allow_synchronous_recording_fallback: bool = False,
|
|
replay_action_id: str = DEFAULT_REPLAY_ACTION_ID,
|
|
) -> APIRouter:
|
|
"""Build generic host APIs without exposing filesystem locators to clients."""
|
|
|
|
router = APIRouter(tags=["observation-sessions"])
|
|
recorded_media_inspector = media_inspector or RecordedMediaInspector()
|
|
|
|
@router.get("/api/v1/observation-sessions")
|
|
def list_observation_sessions(
|
|
limit: int = Query(default=20, ge=1, le=100),
|
|
cursor: str | None = Query(default=None, max_length=128),
|
|
scope: Literal["all", "source", "laboratory"] = "all",
|
|
) -> dict[str, Any]:
|
|
try:
|
|
_refresh_catalog(catalog_refresher)
|
|
page = store.list_recent(limit=limit, cursor=cursor, scope=scope)
|
|
return {
|
|
"items": [
|
|
{
|
|
"id": item.session_id,
|
|
"label": item.display_name,
|
|
"started_at_utc": item.started_at_utc,
|
|
"completed_at_utc": item.completed_at_utc,
|
|
"status": item.status,
|
|
"modalities": list(item.modalities),
|
|
"duration_seconds": item.duration_seconds or 0.0,
|
|
"replayable": item.replayable,
|
|
**({"lab": item.lab.as_dict()} if item.lab is not None else {}),
|
|
**(
|
|
{
|
|
"preparation": _catalog_preparation_document(
|
|
recording_preparation_manager,
|
|
item.session_id,
|
|
item.replayable,
|
|
)
|
|
}
|
|
if recording_preparation_manager is not None
|
|
else {}
|
|
),
|
|
}
|
|
for item in page.items
|
|
if item.started_at_utc is not None
|
|
]
|
|
}
|
|
except SessionNotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail="Некорректные параметры запроса.") from exc
|
|
|
|
@router.get("/api/v1/observation-sessions/{session_id}")
|
|
def get_observation_session(session_id: str) -> dict[str, Any]:
|
|
try:
|
|
_refresh_catalog(catalog_refresher)
|
|
return store.get_session(session_id).as_dict()
|
|
except SessionNotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="Некорректный идентификатор сессии.",
|
|
) from exc
|
|
|
|
@router.delete(
|
|
"/api/v1/observation-sessions/{session_id}",
|
|
status_code=204,
|
|
)
|
|
async def delete_observation_session(session_id: str) -> Response:
|
|
try:
|
|
store.get_session(session_id)
|
|
recorded_artifacts = store.list_recorded_media(session_id)
|
|
except SessionNotFoundError:
|
|
# DELETE is idempotent. Multiple mounted catalogs or another
|
|
# operator tab may have already removed the exact session.
|
|
return Response(status_code=204)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="Некорректный идентификатор сессии.",
|
|
) from exc
|
|
|
|
if recording_preparation_manager is not None:
|
|
snapshot = recording_preparation_manager.status(session_id)
|
|
if snapshot is not None and snapshot.state in {
|
|
"queued",
|
|
"validating",
|
|
"exporting",
|
|
"finalizing",
|
|
}:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Дождитесь завершения подготовки записи перед удалением.",
|
|
)
|
|
await run_in_threadpool(
|
|
recording_preparation_manager.release_launch_reservation,
|
|
session_id,
|
|
)
|
|
|
|
cache_deleter = getattr(recording_materializer, "delete_cached", None)
|
|
if callable(cache_deleter):
|
|
try:
|
|
cache_deleted = await run_in_threadpool(cache_deleter, session_id)
|
|
except (OSError, RecordingMaterializationError) as exc:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Подготовленную запись пока нельзя удалить.",
|
|
) from exc
|
|
if cache_deleted is not True:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Запись сейчас открыта в интерфейсе. Закройте её и повторите удаление.",
|
|
)
|
|
|
|
if recording_preparation_manager is not None and not recording_preparation_manager.discard(
|
|
session_id
|
|
):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Подготовка записи ещё выполняется.",
|
|
)
|
|
|
|
try:
|
|
await run_in_threadpool(
|
|
recorded_media_inspector.delete_prepared,
|
|
session_id,
|
|
(artifact.artifact_id for artifact in recorded_artifacts),
|
|
)
|
|
await run_in_threadpool(store.delete_session, session_id)
|
|
except SessionNotFoundError:
|
|
# A concurrent idempotent delete won the race after both callers
|
|
# resolved the catalog row.
|
|
return Response(status_code=204)
|
|
except (OSError, SessionIntegrityError) as exc:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Сервер не смог безопасно удалить файлы этой сессии.",
|
|
) from exc
|
|
return Response(status_code=204)
|
|
|
|
@router.post("/api/v1/observation-sessions/{session_id}/replay")
|
|
async def replay_observation_session(
|
|
session_id: str,
|
|
request: Annotated[ReplayRequest | None, Body()] = None,
|
|
) -> Any:
|
|
replay_request = request or ReplayRequest()
|
|
try:
|
|
command = await run_in_threadpool(
|
|
_prepare_replay,
|
|
store,
|
|
catalog_refresher,
|
|
session_id,
|
|
replay_request.speed,
|
|
replay_request.loop,
|
|
True,
|
|
)
|
|
except SessionNotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
except SessionNotReplayableError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
except 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 recording_preparation_manager is not None:
|
|
try:
|
|
snapshot = recording_preparation_manager.status(command.session_id)
|
|
if snapshot is None:
|
|
snapshot = await run_in_threadpool(
|
|
recording_preparation_manager.restore_published,
|
|
command,
|
|
)
|
|
if snapshot is None or snapshot.state in {"failed", "cancelled"}:
|
|
# A terminal preparation describes one attempt, not the
|
|
# durable session. A fresh POST /replay is explicit retry
|
|
# intent and must replace that attempt. This also recovers
|
|
# the camera-finalization race where the RRD is already
|
|
# published but the media archive seals moments later.
|
|
snapshot = recording_preparation_manager.enqueue(
|
|
command,
|
|
retry_failed=True,
|
|
)
|
|
except RecordingPreparationQueueFull as exc:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Очередь подготовки записей переполнена.",
|
|
) from exc
|
|
if snapshot.state != "ready" or snapshot.recording is None:
|
|
return _preparation_response(snapshot)
|
|
reserved = recording_preparation_manager.reserve_ready(
|
|
command.session_id,
|
|
preparation_id=snapshot.preparation_id,
|
|
)
|
|
if reserved is None or reserved.recording is None:
|
|
try:
|
|
replacement = recording_preparation_manager.enqueue(
|
|
command,
|
|
retry_failed=True,
|
|
)
|
|
except RecordingPreparationQueueFull as exc:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Очередь подготовки записей переполнена.",
|
|
) from exc
|
|
return _preparation_response(replacement)
|
|
return _recording_launch_document(
|
|
reserved.recording,
|
|
command,
|
|
reserved.recorded_media or (),
|
|
perception_media_provider=perception_media_provider,
|
|
)
|
|
|
|
if recording_materializer is not None and allow_synchronous_recording_fallback:
|
|
recording = await _materialize(recording_materializer, command)
|
|
try:
|
|
recorded_media = await run_in_threadpool(
|
|
_inspect_recorded_media,
|
|
store,
|
|
recorded_media_inspector,
|
|
command,
|
|
recording,
|
|
)
|
|
except SessionIntegrityError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
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,
|
|
detail="Сервис фоновой подготовки записей не настроен.",
|
|
)
|
|
|
|
runtime_state: dict[str, Any] | None = None
|
|
dispatched = replay_launcher is not None
|
|
if replay_launcher is not None:
|
|
launched = replay_launcher(command)
|
|
result = await launched if inspect.isawaitable(launched) else launched
|
|
runtime_state = dict(result)
|
|
return {
|
|
"schema_version": "missioncore.observation-session-replay/v1",
|
|
"launch": {
|
|
"kind": "plugin-action",
|
|
"plugin_id": command.plugin_id,
|
|
"action_id": replay_action_id,
|
|
"session_id": command.session_id,
|
|
"speed": command.speed,
|
|
"loop": command.loop,
|
|
"dispatched": dispatched,
|
|
},
|
|
"runtime_state": runtime_state,
|
|
}
|
|
|
|
@router.get("/api/v1/observation-sessions/{session_id}/recording-preparation")
|
|
async def get_recording_preparation(
|
|
session_id: str,
|
|
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
|
|
) -> Any:
|
|
if recording_preparation_manager is None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Сервис фоновой подготовки записей не настроен.",
|
|
)
|
|
snapshot = recording_preparation_manager.status(session_id)
|
|
if snapshot is None:
|
|
if if_match is None:
|
|
raise HTTPException(
|
|
status_code=428,
|
|
detail="Укажите ETag подготовки в заголовке If-Match.",
|
|
)
|
|
# A polling URL is generation-bound. It must never silently create
|
|
# a replacement and bind an old browser poller to the new job.
|
|
raise HTTPException(
|
|
status_code=412,
|
|
detail="Подготовка была заменена более новой операцией.",
|
|
)
|
|
_require_matching_preparation(snapshot, if_match)
|
|
if snapshot.state == "ready" and snapshot.recording is not None:
|
|
reserved = recording_preparation_manager.reserve_ready(
|
|
session_id,
|
|
preparation_id=snapshot.preparation_id,
|
|
)
|
|
if reserved is None or reserved.recording is None:
|
|
raise HTTPException(
|
|
status_code=412,
|
|
detail="Подготовка была заменена более новой операцией.",
|
|
)
|
|
snapshot = reserved
|
|
return JSONResponse(
|
|
content=_recording_launch_document(
|
|
reserved.recording,
|
|
snapshot.command,
|
|
snapshot.recorded_media or (),
|
|
perception_media_provider=perception_media_provider,
|
|
),
|
|
headers={
|
|
"Cache-Control": "no-store",
|
|
"ETag": _preparation_etag(snapshot.preparation_id),
|
|
"X-Content-Type-Options": "nosniff",
|
|
},
|
|
)
|
|
return _preparation_response(snapshot)
|
|
|
|
@router.delete(
|
|
"/api/v1/observation-sessions/{session_id}/recording-preparation",
|
|
status_code=204,
|
|
)
|
|
def delete_recording_preparation(
|
|
session_id: str,
|
|
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
|
|
) -> Response:
|
|
if recording_preparation_manager is None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Сервис фоновой подготовки записей не настроен.",
|
|
)
|
|
snapshot = recording_preparation_manager.status(session_id)
|
|
if snapshot is None:
|
|
raise HTTPException(status_code=404, detail="Подготовка записи не найдена.")
|
|
_require_matching_preparation(snapshot, if_match)
|
|
recording_preparation_manager.cancel(
|
|
session_id,
|
|
preparation_id=snapshot.preparation_id,
|
|
)
|
|
return Response(status_code=204)
|
|
|
|
@router.get("/api/v1/observation-sessions/{session_id}/recording.rrd")
|
|
async def get_observation_session_recording(
|
|
session_id: str,
|
|
generation: Annotated[str | None, Query(max_length=64)] = None,
|
|
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
|
|
if_none_match: Annotated[str | None, Header()] = None,
|
|
) -> Response:
|
|
if generation is not None and SAFE_SHA256.fullmatch(generation) is None:
|
|
raise HTTPException(
|
|
status_code=412,
|
|
detail="Поколение подготовленной записи не совпадает.",
|
|
)
|
|
if recording_materializer is None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Сервис seekable-записей не настроен.",
|
|
)
|
|
release_recording: Callable[[], None] | None
|
|
if recording_preparation_manager is not None:
|
|
if if_match is None and generation is None:
|
|
raise HTTPException(
|
|
status_code=428,
|
|
detail="Укажите digest записи через generation или If-Match.",
|
|
)
|
|
snapshot = recording_preparation_manager.status(session_id)
|
|
if snapshot is None:
|
|
try:
|
|
command = await run_in_threadpool(
|
|
_prepare_replay,
|
|
store,
|
|
catalog_refresher,
|
|
session_id,
|
|
1.0,
|
|
False,
|
|
False,
|
|
)
|
|
snapshot = await run_in_threadpool(
|
|
recording_preparation_manager.restore_published,
|
|
command,
|
|
)
|
|
if snapshot is None:
|
|
snapshot = recording_preparation_manager.enqueue(command)
|
|
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 RecordingPreparationQueueFull as exc:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Очередь подготовки записей переполнена.",
|
|
) from exc
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="Некорректный идентификатор сессии.",
|
|
) from exc
|
|
if snapshot.state in {
|
|
"queued",
|
|
"validating",
|
|
"exporting",
|
|
"finalizing",
|
|
}:
|
|
return _preparation_response(snapshot)
|
|
if snapshot.state != "ready" or snapshot.recording is None:
|
|
return _preparation_response(snapshot)
|
|
recording = snapshot.recording
|
|
etag = f'"sha256:{recording.sha256}"'
|
|
if generation is not None:
|
|
_require_matching_recording_generation(recording.sha256, generation)
|
|
if if_match is not None:
|
|
_require_matching_digest(etag, if_match)
|
|
pinned = recording_preparation_manager.pin_ready(
|
|
session_id,
|
|
preparation_id=snapshot.preparation_id,
|
|
)
|
|
if pinned is None:
|
|
raise HTTPException(
|
|
status_code=412,
|
|
detail="Подготовленная запись была заменена.",
|
|
)
|
|
snapshot, pinned_release = pinned
|
|
release_recording = pinned_release
|
|
pinned_recording = snapshot.recording
|
|
if pinned_recording is None:
|
|
release_recording()
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Подготовленная запись сессии недоступна.",
|
|
)
|
|
recording = pinned_recording
|
|
elif allow_synchronous_recording_fallback:
|
|
try:
|
|
command = await run_in_threadpool(
|
|
_prepare_replay,
|
|
store,
|
|
catalog_refresher,
|
|
session_id,
|
|
1.0,
|
|
False,
|
|
False,
|
|
)
|
|
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
|
|
recording, release_recording = await _materialize_pinned(
|
|
recording_materializer,
|
|
command,
|
|
)
|
|
try:
|
|
if generation is not None:
|
|
_require_matching_recording_generation(recording.sha256, generation)
|
|
if if_match is not None:
|
|
_require_matching_digest(f'"sha256:{recording.sha256}"', if_match)
|
|
except HTTPException:
|
|
if release_recording is not None:
|
|
release_recording()
|
|
raise
|
|
else:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Сервис фоновой подготовки записей не настроен.",
|
|
)
|
|
etag = f'"sha256:{recording.sha256}"'
|
|
cache_control = (
|
|
IMMUTABLE_RECORDING_CACHE_CONTROL
|
|
if generation is not None
|
|
else REVALIDATED_RECORDING_CACHE_CONTROL
|
|
)
|
|
if _if_none_match_matches(if_none_match, etag):
|
|
if release_recording is not None:
|
|
release_recording()
|
|
return Response(
|
|
status_code=304,
|
|
headers={
|
|
"Cache-Control": cache_control,
|
|
"ETag": etag,
|
|
"X-Content-Type-Options": "nosniff",
|
|
},
|
|
)
|
|
try:
|
|
stat_result = recording.path.stat()
|
|
except OSError as exc:
|
|
if release_recording is not None:
|
|
release_recording()
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Подготовленная запись сессии недоступна.",
|
|
) from exc
|
|
response_type = _ReleasingFileResponse if release_recording is not None else FileResponse
|
|
response_kwargs: dict[str, Any] = {}
|
|
if release_recording is not None:
|
|
response_kwargs["release"] = release_recording
|
|
return response_type(
|
|
recording.path,
|
|
media_type=recording.media_type,
|
|
filename=f"{recording.session_id}.rrd",
|
|
content_disposition_type="inline",
|
|
stat_result=stat_result,
|
|
headers={
|
|
"Cache-Control": cache_control,
|
|
"ETag": etag,
|
|
"Content-Length": str(recording.byte_length),
|
|
"X-Content-Type-Options": "nosniff",
|
|
},
|
|
**response_kwargs,
|
|
)
|
|
|
|
@router.get(
|
|
"/api/v1/observation-sessions/{session_id}/canonical-lab/spatial-frame"
|
|
)
|
|
async def get_observation_session_canonical_lab_spatial_frame(
|
|
session_id: str,
|
|
generation: Annotated[str, Query(min_length=64, max_length=64)],
|
|
time_ns: Annotated[int, Query(ge=0, le=MAX_SAFE_INTEGER)],
|
|
profile: Literal["source-paced-ground-v3"],
|
|
) -> JSONResponse:
|
|
"""Serve one body-frame sample for the canonical recorded-LAB clock.
|
|
|
|
The camera media clock owns playback. Spatial evidence is sampled from
|
|
the same immutable recording instead of starting a second Rerun clock.
|
|
"""
|
|
|
|
if SAFE_SHA256.fullmatch(generation) is None:
|
|
raise HTTPException(
|
|
status_code=412,
|
|
detail="Поколение spatial-записи не совпадает.",
|
|
)
|
|
if recording_preparation_manager is None:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Сервис canonical LAB spatial playback не настроен.",
|
|
)
|
|
snapshot = recording_preparation_manager.status(session_id)
|
|
if snapshot is None or snapshot.state != "ready" or snapshot.recording is None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Запись canonical LAB ещё не подготовлена.",
|
|
)
|
|
_require_matching_recording_generation(snapshot.recording.sha256, generation)
|
|
pinned = recording_preparation_manager.pin_ready(
|
|
session_id,
|
|
preparation_id=snapshot.preparation_id,
|
|
)
|
|
if pinned is None:
|
|
raise HTTPException(
|
|
status_code=412,
|
|
detail="Подготовленная spatial-запись была заменена.",
|
|
)
|
|
pinned_snapshot, release_recording = pinned
|
|
try:
|
|
recording = pinned_snapshot.recording
|
|
if recording is None:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Подготовленная spatial-запись недоступна.",
|
|
)
|
|
payload = await run_in_threadpool(
|
|
canonical_lab_spatial_frame,
|
|
recording.path,
|
|
generation,
|
|
time_ns,
|
|
)
|
|
except (OSError, ValueError) as exc:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Canonical LAB spatial frame не прошёл проверку.",
|
|
) from exc
|
|
finally:
|
|
release_recording()
|
|
return JSONResponse(
|
|
payload,
|
|
headers={
|
|
"Cache-Control": "private, max-age=31536000, immutable",
|
|
"ETag": (
|
|
f'"{generation}:{CANONICAL_LAB_SPATIAL_PROFILE}:'
|
|
f'{payload["source_time_ns"]}"'
|
|
),
|
|
"X-Content-Type-Options": "nosniff",
|
|
},
|
|
)
|
|
|
|
@router.post("/api/v1/observation-sessions/{session_id}/blueprint.rrd")
|
|
async def get_observation_session_blueprint(
|
|
session_id: str,
|
|
request: RecordedBlueprintRequest,
|
|
) -> Response:
|
|
try:
|
|
await run_in_threadpool(
|
|
_prepare_replay,
|
|
store,
|
|
catalog_refresher,
|
|
session_id,
|
|
1.0,
|
|
False,
|
|
False,
|
|
)
|
|
payload = await run_in_threadpool(
|
|
recorded_blueprint_rrd,
|
|
RerunSceneSettings(
|
|
point_size=request.point_size,
|
|
color_mode=request.color_mode,
|
|
palette=request.palette,
|
|
custom_color=request.custom_color,
|
|
accumulation_seconds=request.accumulation_seconds,
|
|
show_points=request.show_points,
|
|
show_trajectory=request.show_trajectory,
|
|
show_grid=request.show_grid,
|
|
),
|
|
application_id=RECORDED_APPLICATION_ID,
|
|
recording_id=request.recording_id,
|
|
blueprint_session_id=request.blueprint_session_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,
|
|
follow_trajectory=request.follow_trajectory,
|
|
)
|
|
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 RecordedBlueprintError as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Не удалось подготовить настройки визуализатора.",
|
|
) from exc
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="Некорректный идентификатор сессии.",
|
|
) from exc
|
|
return Response(
|
|
content=payload,
|
|
media_type="application/vnd.rerun.rrd",
|
|
headers={
|
|
"Cache-Control": "no-store",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Content-Disposition": 'inline; filename="blueprint.rrd"',
|
|
},
|
|
)
|
|
|
|
@router.post("/api/v1/observation-sessions/{session_id}/perception.rrd")
|
|
async def get_observation_session_perception(
|
|
session_id: str,
|
|
request: RecordedPerceptionRequest,
|
|
) -> Response:
|
|
if perception_overlay_provider is None:
|
|
return Response(status_code=204, headers={"Cache-Control": "no-store"})
|
|
try:
|
|
await run_in_threadpool(
|
|
_prepare_replay,
|
|
store,
|
|
catalog_refresher,
|
|
session_id,
|
|
1.0,
|
|
False,
|
|
False,
|
|
)
|
|
materializer = getattr(perception_overlay_provider, "materialize", None)
|
|
payload = await run_in_threadpool(
|
|
materializer if callable(materializer) else perception_overlay_provider.render,
|
|
session_id,
|
|
application_id=request.application_id,
|
|
recording_id=request.recording_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 RecordedPerceptionOverlayError as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Не удалось подготовить слой распознавания.",
|
|
) from exc
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="Некорректный идентификатор сессии.",
|
|
) from exc
|
|
if payload is None:
|
|
return Response(status_code=204, headers={"Cache-Control": "no-store"})
|
|
if isinstance(payload, RecordedPerceptionOverlayArtifact):
|
|
try:
|
|
metadata = payload.path.stat()
|
|
except OSError as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Не удалось подготовить слой распознавания.",
|
|
) from exc
|
|
if not metadata.st_size == payload.byte_length:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Не удалось подготовить слой распознавания.",
|
|
)
|
|
return FileResponse(
|
|
path=payload.path,
|
|
media_type="application/vnd.rerun.rrd",
|
|
filename="perception.rrd",
|
|
content_disposition_type="inline",
|
|
stat_result=metadata,
|
|
headers={
|
|
"Cache-Control": "private, no-cache, no-transform",
|
|
"Content-Length": str(payload.byte_length),
|
|
"ETag": f'"sha256:{payload.sha256}"',
|
|
"X-Content-Type-Options": "nosniff",
|
|
},
|
|
)
|
|
return Response(
|
|
content=payload,
|
|
media_type="application/vnd.rerun.rrd",
|
|
headers={
|
|
"Cache-Control": "private, no-cache, no-transform",
|
|
"Content-Length": str(len(payload)),
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Content-Disposition": 'inline; filename="perception.rrd"',
|
|
},
|
|
)
|
|
|
|
@router.get("/api/v1/observation-sessions/{session_id}/perception/status")
|
|
async def get_observation_session_perception_status(
|
|
session_id: str,
|
|
response: Response,
|
|
recording_id: Annotated[
|
|
str,
|
|
Query(
|
|
min_length=1,
|
|
max_length=128,
|
|
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$",
|
|
),
|
|
],
|
|
) -> dict[str, Any]:
|
|
try:
|
|
await run_in_threadpool(
|
|
_prepare_replay,
|
|
store,
|
|
catalog_refresher,
|
|
session_id,
|
|
1.0,
|
|
False,
|
|
False,
|
|
)
|
|
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
|
|
status_provider = (
|
|
getattr(perception_overlay_provider, "status", None)
|
|
if perception_overlay_provider is not None
|
|
else None
|
|
)
|
|
value: object = (
|
|
await run_in_threadpool(
|
|
status_provider,
|
|
session_id,
|
|
recording_id=recording_id,
|
|
)
|
|
if callable(status_provider)
|
|
else {
|
|
"state": "idle",
|
|
"phase": "idle",
|
|
"elapsed_seconds": 0.0,
|
|
"byte_length": None,
|
|
}
|
|
)
|
|
response.headers["Cache-Control"] = "no-store"
|
|
try:
|
|
return RecordedPerceptionPreparationStatus.model_validate(value).model_dump()
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Сервис слоя распознавания вернул некорректный статус.",
|
|
) from exc
|
|
|
|
@router.post("/api/v1/observation-sessions/{session_id}/point-colors.rrd")
|
|
async def get_observation_session_point_colors(
|
|
session_id: str,
|
|
request: RecordedPointColorsRequest,
|
|
) -> Response:
|
|
try:
|
|
command = await run_in_threadpool(
|
|
_prepare_replay,
|
|
store,
|
|
catalog_refresher,
|
|
session_id,
|
|
1.0,
|
|
False,
|
|
False,
|
|
)
|
|
point_color_renderer = (point_color_renderers or {}).get(command.plugin_id)
|
|
if point_color_renderer is None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Сервис архивной окраски точек не настроен.",
|
|
)
|
|
payload = await run_in_threadpool(
|
|
point_color_renderer,
|
|
command,
|
|
application_id=request.application_id,
|
|
recording_id=request.recording_id,
|
|
color_mode=request.color_mode,
|
|
palette=request.palette,
|
|
custom_color=request.custom_color,
|
|
)
|
|
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
|
|
except RuntimeError as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Не удалось подготовить окраску облака точек.",
|
|
) from exc
|
|
return Response(
|
|
content=payload,
|
|
media_type="application/vnd.rerun.rrd",
|
|
headers={
|
|
"Cache-Control": "private, no-cache, no-transform",
|
|
"Content-Length": str(len(payload)),
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Content-Disposition": 'inline; filename="point-colors.rrd"',
|
|
},
|
|
)
|
|
|
|
@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,
|
|
artifact_id: str,
|
|
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
|
|
) -> JSONResponse:
|
|
manifest = _resolve_recorded_media_manifest(
|
|
store,
|
|
recorded_media_inspector,
|
|
recording_preparation_manager,
|
|
allow_synchronous_recording_fallback,
|
|
catalog_refresher,
|
|
session_id,
|
|
artifact_id,
|
|
)
|
|
manifest_etag = f'"sha256:{manifest.generation_sha256}"'
|
|
_require_matching_digest(manifest_etag, if_match)
|
|
return JSONResponse(
|
|
content=_recorded_media_manifest_document(manifest),
|
|
headers={
|
|
"Cache-Control": "private, no-cache",
|
|
"ETag": manifest_etag,
|
|
"X-Content-Type-Options": "nosniff",
|
|
},
|
|
)
|
|
|
|
@router.get(
|
|
"/api/v1/observation-sessions/{session_id}/media/{artifact_id}/"
|
|
"epochs/{epoch_ordinal}/init.mp4"
|
|
)
|
|
def get_recorded_media_init(
|
|
session_id: str,
|
|
artifact_id: str,
|
|
epoch_ordinal: int,
|
|
generation: Annotated[str | None, Query(min_length=64, max_length=64)] = None,
|
|
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
|
|
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,
|
|
)
|
|
expected_sha256 = _recorded_media_init_sha256(manifest, epoch_ordinal)
|
|
if generation is None:
|
|
_require_matching_digest(f'"sha256:{expected_sha256}"', if_match)
|
|
elif SAFE_SHA256.fullmatch(generation) is None or generation != manifest.generation_sha256:
|
|
raise HTTPException(
|
|
status_code=412,
|
|
detail="Поколение записанного видео было заменено.",
|
|
)
|
|
try:
|
|
media_file = recorded_media_inspector.get_init(manifest, epoch_ordinal)
|
|
except SessionIntegrityError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
return _recorded_media_file_response(media_file, range_header)
|
|
|
|
@router.get(
|
|
"/api/v1/observation-sessions/{session_id}/media/{artifact_id}/"
|
|
"epochs/{epoch_ordinal}/segments/{segment_sequence}.m4s"
|
|
)
|
|
def get_recorded_media_segment(
|
|
session_id: str,
|
|
artifact_id: str,
|
|
epoch_ordinal: int,
|
|
segment_sequence: int,
|
|
generation: Annotated[str | None, Query(min_length=64, max_length=64)] = None,
|
|
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
|
|
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,
|
|
)
|
|
expected_sha256 = _recorded_media_segment_sha256(
|
|
manifest,
|
|
epoch_ordinal,
|
|
segment_sequence,
|
|
)
|
|
if generation is None:
|
|
_require_matching_digest(f'"sha256:{expected_sha256}"', if_match)
|
|
elif SAFE_SHA256.fullmatch(generation) is None or generation != manifest.generation_sha256:
|
|
raise HTTPException(
|
|
status_code=412,
|
|
detail="Поколение записанного видео было заменено.",
|
|
)
|
|
try:
|
|
media_file = recorded_media_inspector.get_segment(
|
|
manifest,
|
|
epoch_ordinal,
|
|
segment_sequence,
|
|
)
|
|
except SessionIntegrityError as exc:
|
|
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:
|
|
stored = store.get_layout(workspace_id)
|
|
document = _layout_document(
|
|
stored.workspace_id,
|
|
stored.schema_version,
|
|
stored.revision,
|
|
stored.layout,
|
|
)
|
|
response.headers["ETag"] = f'"{stored.revision}"'
|
|
return document
|
|
except SessionNotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail="Некорректный workspace id.") from exc
|
|
|
|
@router.put("/api/v1/workspace-layouts/{workspace_id}")
|
|
def put_workspace_layout(
|
|
workspace_id: str,
|
|
request: LayoutPutRequest,
|
|
response: Response,
|
|
if_match: Annotated[str, Header(alias="If-Match")],
|
|
) -> dict[str, Any]:
|
|
header_revision = _parse_if_match(if_match)
|
|
if workspace_id != request.workspace_id:
|
|
raise HTTPException(status_code=422, detail="Workspace id не совпадает с документом.")
|
|
if header_revision != request.revision:
|
|
raise HTTPException(status_code=412, detail="Ревизия документа устарела.")
|
|
payload = request.model_dump(
|
|
mode="json",
|
|
exclude={"version", "revision", "workspace_id"},
|
|
)
|
|
try:
|
|
stored = store.save_layout(
|
|
workspace_id,
|
|
schema_version=request.version,
|
|
expected_revision=request.revision,
|
|
name="Пространственная сцена",
|
|
layout=payload,
|
|
)
|
|
response.headers["ETag"] = f'"{stored.revision}"'
|
|
return _layout_document(
|
|
stored.workspace_id,
|
|
stored.schema_version,
|
|
stored.revision,
|
|
stored.layout,
|
|
)
|
|
except LayoutConflictError as exc:
|
|
raise HTTPException(status_code=412, detail=str(exc)) from exc
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail="Некорректная компоновка.") from exc
|
|
|
|
return router
|
|
|
|
|
|
def _refresh_catalog(refresher: CatalogRefresher | None) -> None:
|
|
if refresher is None:
|
|
return
|
|
try:
|
|
result = refresher()
|
|
if inspect.isawaitable(result):
|
|
if inspect.iscoroutine(result):
|
|
result.close()
|
|
raise TypeError("catalog refresher must be synchronous")
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Не удалось обновить каталог сохранённых сессий.",
|
|
) from exc
|
|
|
|
|
|
def _prepare_replay(
|
|
store: SessionStore,
|
|
catalog_refresher: CatalogRefresher | None,
|
|
session_id: str,
|
|
speed: float,
|
|
loop: bool,
|
|
refresh_first: bool,
|
|
) -> ReplayCommand:
|
|
if refresh_first:
|
|
_refresh_catalog(catalog_refresher)
|
|
try:
|
|
return store.prepare_replay(session_id, speed=speed, loop=loop)
|
|
except SessionNotFoundError:
|
|
if refresh_first or catalog_refresher is None:
|
|
raise
|
|
_refresh_catalog(catalog_refresher)
|
|
return store.prepare_replay(session_id, speed=speed, loop=loop)
|
|
|
|
|
|
def _validate_source_id(source_id: str) -> None:
|
|
if SAFE_SOURCE_ID.fullmatch(source_id) is None:
|
|
raise ValueError("source identifier has an invalid shape")
|
|
|
|
|
|
def _parse_if_match(value: str) -> int:
|
|
normalized = value.strip()
|
|
if len(normalized) < 3 or normalized[0] != '"' or normalized[-1] != '"':
|
|
raise HTTPException(status_code=422, detail="If-Match должен содержать quoted revision.")
|
|
revision = normalized[1:-1]
|
|
if not revision.isascii() or not revision.isdecimal():
|
|
raise HTTPException(status_code=422, detail="If-Match содержит некорректную ревизию.")
|
|
return int(revision)
|
|
|
|
|
|
def _layout_document(
|
|
workspace_id: str,
|
|
schema_version: int,
|
|
revision: int,
|
|
layout: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
if schema_version == 1:
|
|
try:
|
|
legacy = LegacyLayoutV1Document.model_validate(
|
|
{
|
|
"version": 1,
|
|
"revision": revision,
|
|
"workspace_id": workspace_id,
|
|
**layout,
|
|
}
|
|
)
|
|
except ValueError as exc:
|
|
raise SessionIntegrityError(
|
|
"stored workspace layout violates schema version 1"
|
|
) from exc
|
|
layout = legacy.model_dump(
|
|
mode="json",
|
|
exclude={"version", "revision", "workspace_id", "tool_windows"},
|
|
)
|
|
schema_version = 2
|
|
if schema_version != 2:
|
|
raise SessionIntegrityError("stored workspace layout schema is unsupported")
|
|
try:
|
|
document = LayoutPutRequest.model_validate(
|
|
{
|
|
"version": 2,
|
|
"revision": revision,
|
|
"workspace_id": workspace_id,
|
|
**layout,
|
|
}
|
|
)
|
|
except ValueError as exc:
|
|
raise SessionIntegrityError("stored workspace layout violates schema version 2") from exc
|
|
return document.model_dump(mode="json")
|
|
|
|
|
|
ReplayLauncherCallable = Callable[
|
|
[ReplayCommand],
|
|
Mapping[str, Any] | Awaitable[Mapping[str, Any]],
|
|
]
|
|
|
|
|
|
async def _materialize(
|
|
materializer: RecordingMaterializer,
|
|
command: ReplayCommand,
|
|
) -> MaterializedRecording:
|
|
try:
|
|
recording = await run_in_threadpool(materializer, command)
|
|
except RecordingMaterializationError as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Не удалось подготовить запись сессии.",
|
|
) from exc
|
|
if not isinstance(recording, MaterializedRecording):
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Сервис записи вернул некорректный результат.",
|
|
)
|
|
return recording
|
|
|
|
|
|
async def _materialize_pinned(
|
|
materializer: RecordingMaterializer,
|
|
command: ReplayCommand,
|
|
) -> tuple[MaterializedRecording, Callable[[], None] | None]:
|
|
pin = getattr(materializer, "materialize_pinned", None)
|
|
if not callable(pin):
|
|
return await _materialize(materializer, command), None
|
|
try:
|
|
result = await run_in_threadpool(pin, command)
|
|
except RecordingMaterializationError as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Не удалось подготовить запись сессии.",
|
|
) from exc
|
|
if (
|
|
not isinstance(result, tuple)
|
|
or len(result) != 2
|
|
or not isinstance(result[0], MaterializedRecording)
|
|
or not callable(result[1])
|
|
):
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Сервис записи вернул некорректный pinned-результат.",
|
|
)
|
|
return result[0], result[1]
|
|
|
|
|
|
def _preparation_response(snapshot: RecordingPreparationSnapshot) -> JSONResponse:
|
|
encoded_session_id = quote(snapshot.session_id, safe="")
|
|
status_url = f"/api/v1/observation-sessions/{encoded_session_id}/recording-preparation"
|
|
preparation: dict[str, Any] = {
|
|
"preparation_id": snapshot.preparation_id,
|
|
"session_id": snapshot.session_id,
|
|
"state": snapshot.state,
|
|
"progress": snapshot.progress,
|
|
"updated_at_utc": snapshot.updated_at_utc,
|
|
"status_url": status_url,
|
|
"cancellable": snapshot.cancellable,
|
|
}
|
|
terminal_failure = snapshot.state in {"failed", "cancelled"}
|
|
if terminal_failure:
|
|
preparation["retryable"] = snapshot.retryable
|
|
preparation["error"] = snapshot.error or "Подготовка записи отменена."
|
|
headers = {
|
|
"Location": status_url,
|
|
"Cache-Control": "no-store",
|
|
"ETag": _preparation_etag(snapshot.preparation_id),
|
|
"X-Content-Type-Options": "nosniff",
|
|
}
|
|
if not terminal_failure:
|
|
headers["Retry-After"] = "1"
|
|
return JSONResponse(
|
|
status_code=409 if terminal_failure else 202,
|
|
content={
|
|
"schema_version": "missioncore.observation-session-preparation/v1",
|
|
"preparation": preparation,
|
|
},
|
|
headers=headers,
|
|
)
|
|
|
|
|
|
def _preparation_etag(preparation_id: str) -> str:
|
|
return f'"{preparation_id}"'
|
|
|
|
|
|
def _require_matching_preparation(
|
|
snapshot: RecordingPreparationSnapshot,
|
|
if_match: str | None,
|
|
) -> None:
|
|
if if_match is None:
|
|
raise HTTPException(
|
|
status_code=428,
|
|
detail="Укажите ETag подготовки в заголовке If-Match.",
|
|
)
|
|
if if_match.strip() != _preparation_etag(snapshot.preparation_id):
|
|
raise HTTPException(
|
|
status_code=412,
|
|
detail="Подготовка была заменена более новой операцией.",
|
|
)
|
|
|
|
|
|
def _require_matching_digest(expected_etag: str, if_match: str | None) -> None:
|
|
if if_match is None:
|
|
raise HTTPException(
|
|
status_code=428,
|
|
detail="Укажите digest поколения в заголовке If-Match.",
|
|
)
|
|
if if_match.strip() != expected_etag:
|
|
raise HTTPException(
|
|
status_code=412,
|
|
detail="Запрошенное поколение данных устарело.",
|
|
)
|
|
|
|
|
|
def _require_matching_recording_generation(
|
|
expected_sha256: str,
|
|
generation: str,
|
|
) -> None:
|
|
if SAFE_SHA256.fullmatch(generation) is None or generation != expected_sha256:
|
|
raise HTTPException(
|
|
status_code=412,
|
|
detail="Поколение подготовленной записи не совпадает.",
|
|
)
|
|
|
|
|
|
def _catalog_preparation_document(
|
|
manager: SessionRecordingPreparationManager | None,
|
|
session_id: str,
|
|
replayable: bool,
|
|
) -> dict[str, Any] | None:
|
|
if manager is None or not replayable:
|
|
return None
|
|
snapshot = manager.status(session_id)
|
|
if snapshot is None:
|
|
return None
|
|
document: dict[str, Any] = {
|
|
"preparation_id": snapshot.preparation_id,
|
|
"state": snapshot.state,
|
|
"progress": snapshot.progress,
|
|
"updated_at_utc": snapshot.updated_at_utc,
|
|
"cancellable": snapshot.cancellable,
|
|
"retryable": snapshot.retryable,
|
|
}
|
|
if snapshot.error is not None:
|
|
document["error"] = snapshot.error
|
|
return document
|
|
|
|
|
|
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": {
|
|
"kind": "rerun-recording",
|
|
"session_id": recording.session_id,
|
|
"source_url": source_url,
|
|
"viewer_source_url": f"{source_url}?generation={recording.sha256}",
|
|
"media_type": recording.media_type,
|
|
"timeline": recording.timeline,
|
|
"timeline_start_seconds": recording.timeline_start_ns / 1_000_000_000,
|
|
"timeline_end_seconds": recording.timeline_end_ns / 1_000_000_000,
|
|
"seekable": True,
|
|
"byte_length": recording.byte_length,
|
|
"sha256": recording.sha256,
|
|
"playback": {
|
|
"speed": command.speed,
|
|
"loop": command.loop,
|
|
},
|
|
"media_sources": media_sources,
|
|
},
|
|
}
|
|
|
|
|
|
def _inspect_recorded_media(
|
|
store: SessionStore,
|
|
inspector: RecordedMediaInspector,
|
|
command: ReplayCommand,
|
|
recording: MaterializedRecording,
|
|
) -> tuple[RecordedMediaManifest, ...]:
|
|
manifests = tuple(
|
|
inspector.inspect(artifact, command)
|
|
for artifact in store.list_recorded_media(command.session_id)
|
|
)
|
|
validate_recorded_media_timeline(
|
|
manifests,
|
|
recording_start_seconds=recording.timeline_start_ns / 1_000_000_000,
|
|
recording_end_seconds=recording.timeline_end_ns / 1_000_000_000,
|
|
)
|
|
return manifests
|
|
|
|
|
|
def _recorded_media_launch_source(
|
|
manifest: RecordedMediaManifest,
|
|
*,
|
|
index: int,
|
|
) -> dict[str, Any]:
|
|
encoded_session_id = quote(manifest.session_id, safe="")
|
|
encoded_artifact_id = quote(manifest.artifact_id, safe="")
|
|
return {
|
|
"id": manifest.public_source_id,
|
|
"label": f"Записанная камера {index}",
|
|
"modality": "video",
|
|
"manifest_url": (
|
|
f"/api/v1/observation-sessions/{encoded_session_id}/media/"
|
|
f"{encoded_artifact_id}/manifest"
|
|
),
|
|
"media_type": "video/mp4",
|
|
"manifest_generation_sha256": manifest.generation_sha256,
|
|
"byte_length": manifest.byte_length,
|
|
"timeline_start_seconds": manifest.timeline_start_seconds,
|
|
"timeline_end_seconds": manifest.timeline_end_seconds,
|
|
"seekable": True,
|
|
"synchronization": manifest.synchronization,
|
|
}
|
|
|
|
|
|
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/{encoded_result_id}"
|
|
return {
|
|
"schema_version": RECORDED_PERCEPTION_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]:
|
|
encoded_session_id = quote(manifest.session_id, safe="")
|
|
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_STREAM_MANIFEST_SCHEMA,
|
|
"source_id": manifest.public_source_id,
|
|
"generation_sha256": manifest.generation_sha256,
|
|
"byte_length": manifest.byte_length,
|
|
"timeline_start_seconds": manifest.timeline_start_seconds,
|
|
"timeline_end_seconds": manifest.timeline_end_seconds,
|
|
"synchronization": manifest.synchronization,
|
|
"epochs": [
|
|
{
|
|
"ordinal": epoch.ordinal,
|
|
"timeline_start_seconds": epoch.timeline_start_seconds,
|
|
"timeline_end_seconds": epoch.timeline_end_seconds,
|
|
"media_type": epoch.media_type,
|
|
"byte_length": epoch.init_byte_length
|
|
+ sum(segment.byte_length for segment in epoch.segments),
|
|
"segment_count": len(epoch.segments),
|
|
"random_access_sequences": sorted(
|
|
segment.sequence for segment in epoch.segments if segment.random_access
|
|
),
|
|
"segment_end_times_seconds": [
|
|
segment.end_time_seconds for segment in epoch.segments
|
|
],
|
|
"stream_url": (
|
|
f"{base}/epochs/{epoch.ordinal}/recording.mp4"
|
|
f"?generation={manifest.generation_sha256}"
|
|
),
|
|
}
|
|
for epoch in manifest.epochs
|
|
],
|
|
}
|
|
|
|
|
|
def _resolve_recorded_media_manifest(
|
|
store: SessionStore,
|
|
inspector: RecordedMediaInspector,
|
|
manager: SessionRecordingPreparationManager | None,
|
|
allow_synchronous_fallback: bool,
|
|
catalog_refresher: CatalogRefresher | None,
|
|
session_id: str,
|
|
artifact_id: str,
|
|
) -> RecordedMediaManifest:
|
|
if manager is not None:
|
|
try:
|
|
_validate_source_id(artifact_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="Некорректный идентификатор записанного медиаканала.",
|
|
) from exc
|
|
snapshot = manager.status(session_id)
|
|
if snapshot is None or snapshot.state != "ready" or snapshot.recorded_media is None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Медиаканал ещё не подготовлен.",
|
|
)
|
|
matches = tuple(
|
|
manifest for manifest in snapshot.recorded_media if manifest.artifact_id == artifact_id
|
|
)
|
|
if len(matches) != 1:
|
|
raise HTTPException(status_code=404, detail="Записанный медиаканал не найден.")
|
|
return matches[0]
|
|
if not allow_synchronous_fallback:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Сервис фоновой подготовки медиаканалов не настроен.",
|
|
)
|
|
try:
|
|
command = _prepare_replay(
|
|
store,
|
|
catalog_refresher,
|
|
session_id,
|
|
1.0,
|
|
False,
|
|
False,
|
|
)
|
|
artifact = store.get_recorded_media(session_id, artifact_id)
|
|
return inspector.inspect(artifact, command)
|
|
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
|
|
|
|
|
|
def _recorded_media_init_sha256(
|
|
manifest: RecordedMediaManifest,
|
|
epoch_ordinal: int,
|
|
) -> str:
|
|
matches = tuple(epoch for epoch in manifest.epochs if epoch.ordinal == epoch_ordinal)
|
|
if len(matches) != 1:
|
|
raise HTTPException(status_code=404, detail="Эпоха записанного медиаканала не найдена.")
|
|
return matches[0].init_sha256
|
|
|
|
|
|
def _recorded_media_segment_sha256(
|
|
manifest: RecordedMediaManifest,
|
|
epoch_ordinal: int,
|
|
segment_sequence: int,
|
|
) -> str:
|
|
matches = tuple(epoch for epoch in manifest.epochs if epoch.ordinal == epoch_ordinal)
|
|
if len(matches) != 1:
|
|
raise HTTPException(status_code=404, detail="Эпоха записанного медиаканала не найдена.")
|
|
segments = tuple(
|
|
segment for segment in matches[0].segments if segment.sequence == segment_sequence
|
|
)
|
|
if len(segments) != 1:
|
|
raise HTTPException(status_code=404, detail="Сегмент записанного медиаканала не найден.")
|
|
return segments[0].sha256
|
|
|
|
|
|
def _if_none_match_matches(value: str | None, etag: str) -> bool:
|
|
if value is None:
|
|
return False
|
|
# RFC 9110 requires weak comparison for If-None-Match. The recording ETag
|
|
# is content-addressed, so accepting its weak spelling is safe and lets a
|
|
# browser reuse an already-downloaded multi-hundred-megabyte RRD.
|
|
expected = etag.removeprefix("W/")
|
|
for candidate in value.split(","):
|
|
normalized = candidate.strip()
|
|
if normalized == "*" or normalized.removeprefix("W/") == expected:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _recorded_media_file_response(
|
|
media_file: RecordedMediaFile,
|
|
range_header: str | None,
|
|
) -> Response:
|
|
payload = media_file.payload
|
|
status_code = 200
|
|
content_range: str | None = None
|
|
if range_header is not None:
|
|
start, end = _parse_byte_range(range_header, len(payload))
|
|
payload = payload[start : end + 1]
|
|
status_code = 206
|
|
content_range = f"bytes {start}-{end}/{media_file.byte_length}"
|
|
headers = {
|
|
"Accept-Ranges": "bytes",
|
|
"Cache-Control": "private, max-age=31536000, immutable, no-transform",
|
|
"ETag": f'"sha256:{media_file.sha256}"',
|
|
"Content-Length": str(len(payload)),
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Content-Disposition": f'inline; filename="{media_file.filename}"',
|
|
}
|
|
if content_range is not None:
|
|
headers["Content-Range"] = content_range
|
|
return Response(
|
|
content=payload,
|
|
status_code=status_code,
|
|
media_type=media_file.media_type,
|
|
headers=headers,
|
|
)
|
|
|
|
|
|
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:
|
|
raise HTTPException(
|
|
status_code=416,
|
|
detail="Некорректный Range для записанного медиасегмента.",
|
|
headers={"Content-Range": f"bytes */{byte_length}"},
|
|
)
|
|
raw_start, raw_end = match.groups()
|
|
if not raw_start and not raw_end:
|
|
raise HTTPException(
|
|
status_code=416,
|
|
detail="Некорректный Range для записанного медиасегмента.",
|
|
headers={"Content-Range": f"bytes */{byte_length}"},
|
|
)
|
|
if not raw_start:
|
|
suffix = int(raw_end)
|
|
if suffix < 1:
|
|
raise HTTPException(
|
|
status_code=416,
|
|
detail="Некорректный Range для записанного медиасегмента.",
|
|
headers={"Content-Range": f"bytes */{byte_length}"},
|
|
)
|
|
start = max(0, byte_length - suffix)
|
|
return start, byte_length - 1
|
|
start = int(raw_start)
|
|
end = byte_length - 1 if not raw_end else int(raw_end)
|
|
if start >= byte_length or end < start:
|
|
raise HTTPException(
|
|
status_code=416,
|
|
detail="Range выходит за границы записанного медиасегмента.",
|
|
headers={"Content-Range": f"bytes */{byte_length}"},
|
|
)
|
|
return start, min(end, byte_length - 1)
|