feat(observatory): cache sealed camera and TGS replay on Core
This commit is contained in:
@@ -15,6 +15,7 @@ import hashlib
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -282,8 +283,9 @@ def canonical_lab_replay(
|
|||||||
or not _is_sha256(base_generation_sha256)
|
or not _is_sha256(base_generation_sha256)
|
||||||
or _sha256(base) != base_generation_sha256
|
or _sha256(base) != base_generation_sha256
|
||||||
or not _artifact_is_regular(overlay)
|
or not _artifact_is_regular(overlay)
|
||||||
or not result_id.startswith("lab-v1-vegetation-shadow-")
|
or re.fullmatch(
|
||||||
or len(result_id) != len("lab-v1-vegetation-shadow-") + 64
|
r"(?:lab-v1-vegetation-shadow|m49-tgs-portable-review)-[a-f0-9]{64}", result_id
|
||||||
|
) is None
|
||||||
or not recording_id
|
or not recording_id
|
||||||
or len(recording_id) > 128
|
or len(recording_id) > 128
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""Bounded, cached native replay packaging on Core; no inference or Worker IO."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from collections import OrderedDict
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import rerun as rr
|
||||||
|
|
||||||
|
from k1link.laboratory.canonical_rerun_overlay import (
|
||||||
|
APPLICATION_ID,
|
||||||
|
CanonicalLabOverlayArtifact,
|
||||||
|
CanonicalLabReplayArtifact,
|
||||||
|
canonical_lab_replay,
|
||||||
|
canonical_recording_id,
|
||||||
|
)
|
||||||
|
from k1link.observatory.portable_result_view import PortableResultViewService
|
||||||
|
from k1link.observatory.portable_tgs_replay import (
|
||||||
|
PortableReplayError,
|
||||||
|
load_tgs_data,
|
||||||
|
log_tgs,
|
||||||
|
read_json,
|
||||||
|
stat_identity,
|
||||||
|
verified_file,
|
||||||
|
)
|
||||||
|
from k1link.observatory.source_admission import PORTABLE_SOURCE_DOCUMENT_DIRECTORY
|
||||||
|
from k1link.sessions.media import RecordedMediaEpoch, RecordedMediaInspector
|
||||||
|
|
||||||
|
RENDERER_VERSION = "portable-tgs-camera-rerun-0.36.3-v1"
|
||||||
|
_SHA = re.compile(r"^[a-f0-9]{64}$")
|
||||||
|
_RESULT = re.compile(r"^m49-tgs-portable-review-[a-f0-9]{64}$")
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class PortableReplayService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
view: PortableResultViewService,
|
||||||
|
data_dir: Path,
|
||||||
|
media: RecordedMediaInspector,
|
||||||
|
recording_source: Callable[[str], tuple[Path, str] | None],
|
||||||
|
ffmpeg_path: Path,
|
||||||
|
) -> None:
|
||||||
|
self.view = view
|
||||||
|
self.data_dir = data_dir
|
||||||
|
self.media = media
|
||||||
|
self.recording_source = recording_source
|
||||||
|
self.ffmpeg_path = ffmpeg_path
|
||||||
|
self.cache = data_dir / "observatory-portable-replay-cache"
|
||||||
|
self._verified: OrderedDict[tuple[Path, str], tuple[int, ...]] = OrderedDict()
|
||||||
|
|
||||||
|
def _key(self, result_id: str, base_sha: str) -> tuple[str, dict[str, Any]]:
|
||||||
|
if _RESULT.fullmatch(result_id) is None or _SHA.fullmatch(base_sha) is None:
|
||||||
|
raise PortableReplayError("unsupported replay identity")
|
||||||
|
view = cast(dict[str, Any], self.view.read(result_id))
|
||||||
|
identity = [RENDERER_VERSION, result_id, base_sha, view["artifact_manifest_id"]]
|
||||||
|
return hashlib.sha256("\0".join(identity).encode()).hexdigest(), view
|
||||||
|
|
||||||
|
def cached(self, result_id: str, base_sha: str) -> CanonicalLabReplayArtifact | None:
|
||||||
|
key, _ = self._key(result_id, base_sha)
|
||||||
|
sidecar = self.cache / f"{key}.json"
|
||||||
|
if not sidecar.exists():
|
||||||
|
return None
|
||||||
|
metadata = read_json(sidecar, 8192)
|
||||||
|
digest = metadata.get("sha256")
|
||||||
|
if (
|
||||||
|
metadata.get("key") != key
|
||||||
|
or not isinstance(digest, str)
|
||||||
|
or _SHA.fullmatch(digest) is None
|
||||||
|
):
|
||||||
|
raise PortableReplayError("replay cache identity changed")
|
||||||
|
name = metadata.get("file")
|
||||||
|
if not isinstance(name, str) or not re.fullmatch(r"[a-f0-9]{64}\.replay\.rrd", name):
|
||||||
|
raise PortableReplayError("replay cache path is unsafe")
|
||||||
|
path = self.cache / name
|
||||||
|
# Warm open reads the already sealed derivative, never source media or
|
||||||
|
# TGS arrays. Hashing is bounded by the one-RRD browser admission cap.
|
||||||
|
if (
|
||||||
|
path.is_symlink()
|
||||||
|
or not path.is_file()
|
||||||
|
or path.stat().st_size != metadata["byte_length"]
|
||||||
|
):
|
||||||
|
raise PortableReplayError("replay cache length changed")
|
||||||
|
if not 4 <= metadata["byte_length"] <= 1024 * 1024 * 1024:
|
||||||
|
raise PortableReplayError("replay cache exceeds bounds")
|
||||||
|
stamp = stat_identity(path.stat())
|
||||||
|
fingerprint = (path, digest)
|
||||||
|
if self._verified.get(fingerprint) != stamp:
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
if (
|
||||||
|
hashlib.file_digest(stream, "sha256").hexdigest() != digest
|
||||||
|
or stat_identity(path.stat()) != stamp
|
||||||
|
):
|
||||||
|
raise PortableReplayError("replay cache digest changed")
|
||||||
|
self._verified[fingerprint] = stamp
|
||||||
|
while len(self._verified) > 32:
|
||||||
|
self._verified.popitem(last=False)
|
||||||
|
return CanonicalLabReplayArtifact(path, metadata["byte_length"], digest)
|
||||||
|
|
||||||
|
def prepare(self, result_id: str, base_sha: str) -> CanonicalLabReplayArtifact:
|
||||||
|
# Serialize packaging on the constrained operator host, including
|
||||||
|
# concurrent clicks. Waiting readers cannot start a duplicate render.
|
||||||
|
with _LOCK:
|
||||||
|
cached = self.cached(result_id, base_sha)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
key, view = self._key(result_id, base_sha)
|
||||||
|
base = self.recording_source(view["source_session_id"])
|
||||||
|
if base is None or base[1] != base_sha:
|
||||||
|
raise PortableReplayError("source recording generation is not ready")
|
||||||
|
binding = self.view.sessions.get_lab_instance(result_id)
|
||||||
|
if binding is None:
|
||||||
|
raise PortableReplayError("result binding disappeared")
|
||||||
|
source = binding.provenance["source"]
|
||||||
|
bundle_sha = source["bundle_sha256"]
|
||||||
|
if not isinstance(bundle_sha, str) or _SHA.fullmatch(bundle_sha) is None:
|
||||||
|
raise PortableReplayError("source bundle identity is invalid")
|
||||||
|
bundle_path = self.data_dir / PORTABLE_SOURCE_DOCUMENT_DIRECTORY / f"{bundle_sha}.json"
|
||||||
|
bundle = read_json(bundle_path, 32 * 1024 * 1024)
|
||||||
|
verified_file(bundle_path, bundle_sha, bundle_path.stat().st_size)
|
||||||
|
if bundle["source_session_id"] != view["source_session_id"]:
|
||||||
|
raise PortableReplayError("source bundle belongs to another session")
|
||||||
|
_, catalog_sha = self.view.sessions.get_session_with_catalog_snapshot(
|
||||||
|
view["source_session_id"]
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
catalog_sha != source["catalog_sha256"]
|
||||||
|
or catalog_sha != bundle["source_catalog_sha256"]
|
||||||
|
):
|
||||||
|
raise PortableReplayError("source catalog changed since calculation")
|
||||||
|
epoch = self._camera(view["source_session_id"], bundle)
|
||||||
|
data = load_tgs_data(view, self.view.artifacts, source_bundle_sha256=bundle_sha)
|
||||||
|
starts = [epoch.timeline_start_seconds] + [
|
||||||
|
epoch.timeline_start_seconds + part.end_time_seconds for part in epoch.segments[:-1]
|
||||||
|
]
|
||||||
|
if len(starts) != len(data.rows) or any(
|
||||||
|
abs(start - row["session_seconds"]) > 1e-8
|
||||||
|
for start, row in zip(starts, data.rows, strict=True)
|
||||||
|
):
|
||||||
|
raise PortableReplayError("camera and costmap anchor clocks disagree")
|
||||||
|
recording_id = canonical_recording_id(base[0])
|
||||||
|
self.cache.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
if self.cache.is_symlink() or shutil.disk_usage(self.cache).free < 3 * 1024**3:
|
||||||
|
raise PortableReplayError("replay cache has insufficient safe space")
|
||||||
|
with tempfile.TemporaryDirectory(prefix=".pack-", dir=self.cache) as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
proxy = self._video(epoch, root)
|
||||||
|
output = root / "overlay.rrd"
|
||||||
|
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
|
||||||
|
try:
|
||||||
|
recording.set_sinks(rr.FileSink(output, write_footer=True))
|
||||||
|
_log_video(recording, proxy, epoch)
|
||||||
|
log_tgs(recording, data, epoch.timeline_end_seconds)
|
||||||
|
recording.flush(timeout_sec=180)
|
||||||
|
finally:
|
||||||
|
recording.disconnect()
|
||||||
|
with output.open("rb") as stream:
|
||||||
|
digest = hashlib.file_digest(stream, "sha256").hexdigest()
|
||||||
|
artifact = canonical_lab_replay(
|
||||||
|
base[0],
|
||||||
|
base_generation_sha256=base_sha,
|
||||||
|
overlay=CanonicalLabOverlayArtifact(output, output.stat().st_size, digest),
|
||||||
|
result_id=result_id,
|
||||||
|
recording_id=recording_id,
|
||||||
|
cache_root=self.cache,
|
||||||
|
)
|
||||||
|
metadata = {
|
||||||
|
"key": key,
|
||||||
|
"file": artifact.path.name,
|
||||||
|
"sha256": artifact.sha256,
|
||||||
|
"byte_length": artifact.byte_length,
|
||||||
|
}
|
||||||
|
staged = self.cache / f".{key}.json"
|
||||||
|
staged.write_text(json.dumps(metadata, sort_keys=True), encoding="utf-8")
|
||||||
|
os.chmod(staged, 0o600)
|
||||||
|
os.replace(staged, self.cache / f"{key}.json")
|
||||||
|
return artifact
|
||||||
|
|
||||||
|
def _camera(self, session_id: str, bundle: dict[str, Any]) -> RecordedMediaEpoch:
|
||||||
|
camera = bundle["camera"]
|
||||||
|
artifact = self.view.sessions.get_recorded_media(session_id, camera["artifact_id"])
|
||||||
|
command = self.view.sessions.prepare_replay(session_id, speed=1.0, loop=False)
|
||||||
|
media = self.media.restore_prepared(artifact, command)
|
||||||
|
if (
|
||||||
|
media is None
|
||||||
|
or media.generation_sha256 != camera["generation_sha256"]
|
||||||
|
or len(media.epochs) != 1
|
||||||
|
):
|
||||||
|
raise PortableReplayError("exact source camera is not prepared")
|
||||||
|
epoch, expected = media.epochs[0], camera["epoch"]
|
||||||
|
actual = {
|
||||||
|
"ordinal": epoch.ordinal,
|
||||||
|
"media_type": epoch.media_type,
|
||||||
|
"init": {"byte_length": epoch.init_byte_length, "sha256": epoch.init_sha256},
|
||||||
|
"timeline_start_seconds": epoch.timeline_start_seconds,
|
||||||
|
"timeline_end_seconds": epoch.timeline_end_seconds,
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"sequence": item.sequence,
|
||||||
|
"byte_length": item.byte_length,
|
||||||
|
"sha256": item.sha256,
|
||||||
|
"random_access": item.random_access,
|
||||||
|
"end_time_seconds": item.end_time_seconds,
|
||||||
|
}
|
||||||
|
for item in epoch.segments
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if actual != expected:
|
||||||
|
raise PortableReplayError("source camera epoch changed")
|
||||||
|
return epoch
|
||||||
|
|
||||||
|
def _video(self, epoch: RecordedMediaEpoch, root: Path) -> Path:
|
||||||
|
files = [(epoch.init_path, epoch.init_sha256, epoch.init_byte_length)] + [
|
||||||
|
(part.path, part.sha256, part.byte_length) for part in epoch.segments
|
||||||
|
]
|
||||||
|
video_key = hashlib.sha256(
|
||||||
|
(
|
||||||
|
"camera-proxy-demux-timebase-v2\0" + "\0".join(digest for _, digest, _ in files)
|
||||||
|
).encode()
|
||||||
|
).hexdigest()
|
||||||
|
saved = self.cache / f"{video_key}.camera.mp4"
|
||||||
|
saved_manifest = self.cache / f"{video_key}.camera.json"
|
||||||
|
if saved_manifest.exists():
|
||||||
|
metadata = read_json(saved_manifest, 8192)
|
||||||
|
return verified_file(saved, metadata["sha256"], metadata["byte_length"])
|
||||||
|
if sum(size for _, _, size in files) > 768 * 1024 * 1024:
|
||||||
|
raise PortableReplayError("camera packaging exceeds the current viewer bound")
|
||||||
|
source, proxy = root / "source.mp4", root / "camera.mp4"
|
||||||
|
with source.open("wb") as target:
|
||||||
|
for path, digest, size in files:
|
||||||
|
verified_file(path, digest, size)
|
||||||
|
with path.open("rb") as part:
|
||||||
|
shutil.copyfileobj(part, target, 1024 * 1024)
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
str(self.ffmpeg_path),
|
||||||
|
"-nostdin",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-i",
|
||||||
|
str(source),
|
||||||
|
"-an",
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-threads",
|
||||||
|
"2",
|
||||||
|
"-preset",
|
||||||
|
"veryfast",
|
||||||
|
"-crf",
|
||||||
|
"28",
|
||||||
|
"-g",
|
||||||
|
"20",
|
||||||
|
"-keyint_min",
|
||||||
|
"20",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
"-fps_mode",
|
||||||
|
"passthrough",
|
||||||
|
"-enc_time_base",
|
||||||
|
"-1",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
str(proxy),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=900,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if completed.returncode or not proxy.is_file() or proxy.stat().st_size > 192 * 1024 * 1024:
|
||||||
|
raise PortableReplayError("PTS-preserving camera packaging failed")
|
||||||
|
with proxy.open("rb") as stream:
|
||||||
|
digest = hashlib.file_digest(stream, "sha256").hexdigest()
|
||||||
|
metadata = {"sha256": digest, "byte_length": proxy.stat().st_size}
|
||||||
|
os.chmod(proxy, 0o600)
|
||||||
|
os.replace(proxy, saved)
|
||||||
|
staged = root / "camera.json"
|
||||||
|
staged.write_text(json.dumps(metadata), encoding="utf-8")
|
||||||
|
os.chmod(staged, 0o600)
|
||||||
|
os.replace(staged, saved_manifest)
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
def _log_video(recording: rr.RecordingStream, proxy: Path, epoch: RecordedMediaEpoch) -> None:
|
||||||
|
video = rr.AssetVideo(path=proxy)
|
||||||
|
timestamps = video.read_frame_timestamps_nanos()
|
||||||
|
if (
|
||||||
|
not len(timestamps)
|
||||||
|
or np.any(np.diff(timestamps) <= 0)
|
||||||
|
or timestamps[0] < 0
|
||||||
|
or abs(
|
||||||
|
float(timestamps[-1]) / 1e9
|
||||||
|
- (epoch.timeline_end_seconds - epoch.timeline_start_seconds)
|
||||||
|
)
|
||||||
|
> 2
|
||||||
|
):
|
||||||
|
raise PortableReplayError(
|
||||||
|
f"video presentation timestamps changed: count={len(timestamps)}, "
|
||||||
|
f"first={timestamps[0] if len(timestamps) else None}, "
|
||||||
|
f"last={timestamps[-1] if len(timestamps) else None}, "
|
||||||
|
f"nonincreasing={int(np.count_nonzero(np.diff(timestamps) <= 0))}, "
|
||||||
|
f"expected_duration={epoch.timeline_end_seconds - epoch.timeline_start_seconds}"
|
||||||
|
)
|
||||||
|
# Native decoded PTS, never fragment count / nominal FPS. Rerun latest-at
|
||||||
|
# holds the prior sample across missing decodable fragments.
|
||||||
|
recording.log("/perception/camera/image", video, static=True)
|
||||||
|
origin = round(epoch.timeline_start_seconds * 1e9)
|
||||||
|
for timestamp in timestamps:
|
||||||
|
recording.set_time("session_time", duration=np.timedelta64(origin + int(timestamp), "ns"))
|
||||||
|
recording.log(
|
||||||
|
"/perception/camera/image", rr.VideoFrameReference(nanoseconds=int(timestamp))
|
||||||
|
)
|
||||||
|
recording.set_time(
|
||||||
|
"session_time", duration=np.timedelta64(round(epoch.timeline_end_seconds * 1e9), "ns")
|
||||||
|
)
|
||||||
|
recording.log("/perception/camera/image", rr.Clear(recursive=False))
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Read-only projection of sealed portable TGS evidence onto the source clock.
|
||||||
|
|
||||||
|
This adapter never calls a model, a Worker, or a legacy LAB result. Local
|
||||||
|
costmap cells are translated by the exact source pose, not by a viewer eye.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import rerun as rr
|
||||||
|
|
||||||
|
from k1link.artifact_gateway import CentralArtifactStore
|
||||||
|
from k1link.observatory.m49_portable_source import read_m49_source_index
|
||||||
|
from k1link.observatory.portable_result_contract import canonical_json
|
||||||
|
|
||||||
|
RESULT_SCHEMA = "missioncore.recorded-tgs-costmap-review/v2"
|
||||||
|
MAX_ARTIFACT_BYTES = 512 * 1024 * 1024
|
||||||
|
MAX_FRAMES = 250_000
|
||||||
|
MAX_CELLS = 100_000
|
||||||
|
STATE_CODES = {
|
||||||
|
"UNOBSERVED": 0,
|
||||||
|
"GROUND_SUPPORT": 1,
|
||||||
|
"NONGROUND_OCCUPIED": 2,
|
||||||
|
"UNKNOWN_REJECTED": 3,
|
||||||
|
}
|
||||||
|
# Evidence colors, not control states: same four categories as the result.
|
||||||
|
STATE_COLORS = (
|
||||||
|
(110, 115, 125, 90),
|
||||||
|
(108, 182, 115, 130),
|
||||||
|
(239, 107, 112, 180),
|
||||||
|
(229, 186, 83, 150),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PortableReplayError(ValueError):
|
||||||
|
"""Published evidence cannot safely produce a viewer derivative."""
|
||||||
|
|
||||||
|
|
||||||
|
def stat_identity(value: os.stat_result) -> tuple[int, ...]:
|
||||||
|
# stat_result tuple equality omits nanosecond precision on macOS.
|
||||||
|
return (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns)
|
||||||
|
|
||||||
|
|
||||||
|
def verified_file(path: Path, digest: str, size: int) -> Path:
|
||||||
|
if path.is_symlink() or not path.is_file() or not 0 < size <= MAX_ARTIFACT_BYTES:
|
||||||
|
raise PortableReplayError("replay artifact is outside bounds")
|
||||||
|
before = path.stat()
|
||||||
|
if before.st_size != size:
|
||||||
|
raise PortableReplayError("replay artifact length changed")
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
actual = hashlib.file_digest(stream, "sha256").hexdigest()
|
||||||
|
if actual != digest or stat_identity(before) != stat_identity(path.stat()):
|
||||||
|
raise PortableReplayError("replay artifact integrity changed")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: Path, limit: int = 8 * 1024 * 1024) -> dict[str, Any]:
|
||||||
|
if path.is_symlink() or not path.is_file() or not 0 < path.stat().st_size <= limit:
|
||||||
|
raise PortableReplayError("replay document is outside bounds")
|
||||||
|
value = json.loads(path.read_bytes())
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise PortableReplayError("replay document is not an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TgsReplayData:
|
||||||
|
rows: tuple[dict[str, Any], ...]
|
||||||
|
centers: np.ndarray[Any, Any]
|
||||||
|
states: np.ndarray[Any, Any]
|
||||||
|
z_bounds: np.ndarray[Any, Any]
|
||||||
|
cell_size_m: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def start_seconds(self) -> float:
|
||||||
|
return float(self.rows[0]["session_seconds"])
|
||||||
|
|
||||||
|
|
||||||
|
def load_tgs_data(
|
||||||
|
view: dict[str, Any], store: CentralArtifactStore, *, source_bundle_sha256: str | None = None
|
||||||
|
) -> TgsReplayData:
|
||||||
|
doc = view["result_document"]
|
||||||
|
if doc.get("schema_version") != RESULT_SCHEMA or doc.get("result_id") != view["result_id"]:
|
||||||
|
raise PortableReplayError("result has no supported TGS replay contract")
|
||||||
|
costmap = doc["costmap"]
|
||||||
|
count, cells = doc["timeline"]["frame_count"], costmap["cell_count"]
|
||||||
|
size = costmap["cell_size_m"]
|
||||||
|
if (
|
||||||
|
type(count) is not int
|
||||||
|
or not 0 < count <= MAX_FRAMES
|
||||||
|
or type(cells) is not int
|
||||||
|
or not 0 < cells <= MAX_CELLS
|
||||||
|
or not isinstance(size, (int, float))
|
||||||
|
or isinstance(size, bool)
|
||||||
|
or not math.isfinite(size)
|
||||||
|
or not 0 < size <= 10
|
||||||
|
or costmap["coordinate_frame"] != "map-gravity-local"
|
||||||
|
or costmap["state_codes"] != STATE_CODES
|
||||||
|
):
|
||||||
|
raise PortableReplayError("TGS grid contract is invalid")
|
||||||
|
members = {item["role"]: item for item in view["artifacts"]}
|
||||||
|
|
||||||
|
def artifact(role: str) -> Path:
|
||||||
|
member = members[role]
|
||||||
|
return verified_file(
|
||||||
|
store.object_path(member["sha256"]), member["sha256"], member["byte_length"]
|
||||||
|
)
|
||||||
|
|
||||||
|
stage = read_json(artifact("source-stage-manifest"))
|
||||||
|
if (
|
||||||
|
stage["identity_sha256"] != doc["source_stage"]["identity_sha256"]
|
||||||
|
or members["source-stage-manifest"]["sha256"] != doc["source_stage"]["manifest_sha256"]
|
||||||
|
or stage["identity"]["source"]["source_session_id"] != view["source_session_id"]
|
||||||
|
or stage["identity"]["profile"]["profile_sha256"] != doc["profile"]["profile_sha256"]
|
||||||
|
or hashlib.sha256(canonical_json(stage["identity"])).hexdigest() != stage["identity_sha256"]
|
||||||
|
or (
|
||||||
|
source_bundle_sha256 is not None
|
||||||
|
and stage["identity"]["source"]["source_bundle_sha256"] != source_bundle_sha256
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise PortableReplayError("source stage identity changed")
|
||||||
|
rows = tuple(read_m49_source_index(artifact("source-stage-index"), expected_frame_count=count))
|
||||||
|
with artifact("frame-index").open("rb") as stream:
|
||||||
|
for row in rows:
|
||||||
|
line = stream.readline(65537)
|
||||||
|
if len(line) > 65536:
|
||||||
|
raise PortableReplayError("TGS frame row exceeds bounds")
|
||||||
|
frame = json.loads(line)
|
||||||
|
for key in (
|
||||||
|
"timeline_frame_index",
|
||||||
|
"source_frame_index",
|
||||||
|
"session_seconds",
|
||||||
|
"sample_available",
|
||||||
|
"available_slot",
|
||||||
|
):
|
||||||
|
if frame.get(key) != row[key]:
|
||||||
|
raise PortableReplayError("TGS frame and source clocks disagree")
|
||||||
|
if stream.read(1):
|
||||||
|
raise PortableReplayError("TGS frame index has extra rows")
|
||||||
|
centers = np.load(artifact("costmap-cell-centers"), mmap_mode="r", allow_pickle=False)
|
||||||
|
states = np.load(artifact("costmap-states"), mmap_mode="r", allow_pickle=False)
|
||||||
|
bounds = np.load(artifact("costmap-z-bounds"), mmap_mode="r", allow_pickle=False)
|
||||||
|
if (
|
||||||
|
centers.shape != (cells, 2)
|
||||||
|
or centers.dtype != np.dtype("<f4")
|
||||||
|
or states.shape != (count, cells)
|
||||||
|
or states.dtype != np.dtype("u1")
|
||||||
|
or bounds.shape != (count, cells, 2)
|
||||||
|
or bounds.dtype != np.dtype("<f4")
|
||||||
|
or not np.isfinite(centers).all()
|
||||||
|
):
|
||||||
|
raise PortableReplayError("TGS arrays violate the sealed grid shape")
|
||||||
|
for index, row in enumerate(rows):
|
||||||
|
state, z = states[index], bounds[index]
|
||||||
|
observed = state != 0
|
||||||
|
if (
|
||||||
|
np.any(state > 3)
|
||||||
|
or not np.isnan(z[~observed]).all()
|
||||||
|
or not np.isfinite(z[observed]).all()
|
||||||
|
or np.any(z[observed, 1] < z[observed, 0])
|
||||||
|
or (not row["sample_available"] and np.any(observed))
|
||||||
|
):
|
||||||
|
raise PortableReplayError("TGS states contradict support or height evidence")
|
||||||
|
return TgsReplayData(rows, centers, states, bounds, float(size))
|
||||||
|
|
||||||
|
|
||||||
|
def world_cells(
|
||||||
|
data: TgsReplayData, index: int, state: int
|
||||||
|
) -> tuple[np.ndarray[Any, Any], np.ndarray[Any, Any]]:
|
||||||
|
"""Return measured volumes only; unobserved cells have no measured height."""
|
||||||
|
row = data.rows[index]
|
||||||
|
selected = data.states[index] == state
|
||||||
|
if state == 0 or not row["sample_available"]:
|
||||||
|
return np.empty((0, 3)), np.empty((0, 3))
|
||||||
|
z = data.z_bounds[index, selected]
|
||||||
|
pose = np.asarray(row["position_map_m"], dtype=np.float64)
|
||||||
|
centers = np.column_stack((data.centers[selected], z.mean(axis=1))) + pose
|
||||||
|
# Planar support stays planar: do not invent a physical thickness.
|
||||||
|
sizes = np.column_stack((np.full((len(z), 2), data.cell_size_m), z[:, 1] - z[:, 0]))
|
||||||
|
return centers, sizes
|
||||||
|
|
||||||
|
|
||||||
|
def log_tgs(recording: rr.RecordingStream, data: TgsReplayData, end_seconds: float) -> None:
|
||||||
|
if not data.start_seconds < end_seconds or data.rows[-1]["session_seconds"] >= end_seconds:
|
||||||
|
raise PortableReplayError("TGS coverage is outside the admitted camera epoch")
|
||||||
|
for index, row in enumerate(data.rows):
|
||||||
|
recording.set_time(
|
||||||
|
"session_time",
|
||||||
|
duration=np.timedelta64(round(float(row["session_seconds"]) * 1e9), "ns"),
|
||||||
|
)
|
||||||
|
# Explicit per-frame clears prevent stale obstacles surviving a missing
|
||||||
|
# LiDAR sample or a backwards seek. No source-cloud entity is replaced.
|
||||||
|
for state, name in (
|
||||||
|
(1, "ground_support"),
|
||||||
|
(2, "nonground_occupied"),
|
||||||
|
(3, "unknown_rejected"),
|
||||||
|
):
|
||||||
|
centers, sizes = world_cells(data, index, state)
|
||||||
|
recording.log(
|
||||||
|
f"/world/costmap/{name}",
|
||||||
|
rr.Boxes3D(
|
||||||
|
centers=centers, sizes=sizes, colors=STATE_COLORS[state], fill_mode="solid"
|
||||||
|
)
|
||||||
|
if len(centers)
|
||||||
|
else rr.Clear(recursive=False),
|
||||||
|
)
|
||||||
|
recording.set_time("session_time", duration=np.timedelta64(round(end_seconds * 1e9), "ns"))
|
||||||
|
recording.log("/world/costmap", rr.Clear(recursive=True))
|
||||||
@@ -54,6 +54,7 @@ from k1link.observatory.portable_queue_binding import (
|
|||||||
PortableQueueBindingError,
|
PortableQueueBindingError,
|
||||||
PortableRecordedQueueBindingService,
|
PortableRecordedQueueBindingService,
|
||||||
)
|
)
|
||||||
|
from k1link.observatory.portable_replay import PortableReplayService
|
||||||
from k1link.observatory.portable_result_cache import PortableResultCache
|
from k1link.observatory.portable_result_cache import PortableResultCache
|
||||||
from k1link.observatory.portable_result_contract import (
|
from k1link.observatory.portable_result_contract import (
|
||||||
PortableCalculationProfileRegistry,
|
PortableCalculationProfileRegistry,
|
||||||
@@ -212,6 +213,7 @@ from k1link.web.plugin_runtime import (
|
|||||||
PluginRuntimeUnavailableError,
|
PluginRuntimeUnavailableError,
|
||||||
)
|
)
|
||||||
from k1link.web.polygon_api import build_polygon_router, configured_polygon_runs_root
|
from k1link.web.polygon_api import build_polygon_router, configured_polygon_runs_root
|
||||||
|
from k1link.web.portable_replay_api import build_portable_replay_router
|
||||||
from k1link.web.runtime_diagnostics import configure_scanner_diagnostics
|
from k1link.web.runtime_diagnostics import configure_scanner_diagnostics
|
||||||
from k1link.web.runtime_readiness import (
|
from k1link.web.runtime_readiness import (
|
||||||
BackgroundReconcilerReadiness,
|
BackgroundReconcilerReadiness,
|
||||||
@@ -1053,6 +1055,21 @@ async def device_plugin_events(websocket: WebSocket, plugin_id: str) -> None:
|
|||||||
await websocket.close(code=1011, reason="Device plugin state stream failed")
|
await websocket.close(code=1011, reason="Device plugin state stream failed")
|
||||||
|
|
||||||
|
|
||||||
|
if session_artifact_gateway is not None and _ffmpeg is not None:
|
||||||
|
app.include_router(
|
||||||
|
build_portable_replay_router(
|
||||||
|
PortableReplayService(
|
||||||
|
view=PortableResultViewService(
|
||||||
|
sessions=session_store, artifacts=session_artifact_gateway.store
|
||||||
|
),
|
||||||
|
data_dir=session_store.data_dir,
|
||||||
|
media=session_recorded_media_inspector,
|
||||||
|
recording_source=_canonical_lab_recording_source,
|
||||||
|
ffmpeg_path=_ffmpeg,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
for legacy_router in plugin_environment.legacy_routers:
|
for legacy_router in plugin_environment.legacy_routers:
|
||||||
app.include_router(legacy_router)
|
app.include_router(legacy_router)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Same-origin immutable replay derivatives; GET never starts packaging."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Path, Query, Response
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from k1link.laboratory.canonical_rerun_overlay import CanonicalLabOverlayError
|
||||||
|
from k1link.observatory.portable_replay import PortableReplayService
|
||||||
|
from k1link.observatory.portable_result_view import PortableResultViewError
|
||||||
|
|
||||||
|
ResultId = Annotated[str, Path(pattern=r"^m49-tgs-portable-review-[a-f0-9]{64}$")]
|
||||||
|
BaseSha = Annotated[str, Path(pattern=r"^[a-f0-9]{64}$")]
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_portable_replay_router(service: PortableReplayService) -> APIRouter:
|
||||||
|
router = APIRouter(tags=["observatory"])
|
||||||
|
path = "/api/v1/observatory/portable-results/{result_id}/replays/{base_sha}/recording.rrd"
|
||||||
|
|
||||||
|
@router.head(path)
|
||||||
|
def prepare(result_id: ResultId, base_sha: BaseSha) -> Response:
|
||||||
|
try:
|
||||||
|
artifact = service.prepare(result_id, base_sha)
|
||||||
|
except (
|
||||||
|
ValueError,
|
||||||
|
OSError,
|
||||||
|
KeyError,
|
||||||
|
TypeError,
|
||||||
|
CanonicalLabOverlayError,
|
||||||
|
PortableResultViewError,
|
||||||
|
) as exc:
|
||||||
|
_LOG.exception("Portable replay packaging rejected")
|
||||||
|
raise HTTPException(
|
||||||
|
409, "Сохранённый результат не удалось подготовить к просмотру."
|
||||||
|
) from exc
|
||||||
|
return Response(
|
||||||
|
media_type="application/vnd.rerun.rrd",
|
||||||
|
headers={
|
||||||
|
"Content-Length": str(artifact.byte_length),
|
||||||
|
"ETag": f'"{artifact.sha256}"',
|
||||||
|
"X-Rerun-Format": "RRF2",
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(path)
|
||||||
|
def read(
|
||||||
|
result_id: ResultId,
|
||||||
|
base_sha: BaseSha,
|
||||||
|
generation: Annotated[str, Query(pattern=r"^[a-f0-9]{64}$")],
|
||||||
|
) -> FileResponse:
|
||||||
|
try:
|
||||||
|
artifact = service.cached(result_id, base_sha)
|
||||||
|
except (ValueError, OSError, KeyError, TypeError, PortableResultViewError) as exc:
|
||||||
|
raise HTTPException(409, "Кэш результата не прошёл проверку.") from exc
|
||||||
|
if artifact is None:
|
||||||
|
raise HTTPException(409, "Сохранённый просмотр ещё не подготовлен.")
|
||||||
|
if artifact.sha256 != generation:
|
||||||
|
raise HTTPException(412, "Версия сохранённого просмотра изменилась.")
|
||||||
|
return FileResponse(
|
||||||
|
artifact.path,
|
||||||
|
media_type="application/vnd.rerun.rrd",
|
||||||
|
headers={
|
||||||
|
"ETag": f'"{artifact.sha256}"',
|
||||||
|
"X-Rerun-Format": "RRF2",
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
import rerun as rr
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from k1link.laboratory.canonical_rerun_overlay import CanonicalLabReplayArtifact
|
||||||
|
from k1link.observatory.portable_replay import PortableReplayService
|
||||||
|
from k1link.observatory.portable_tgs_replay import (
|
||||||
|
PortableReplayError,
|
||||||
|
TgsReplayData,
|
||||||
|
log_tgs,
|
||||||
|
verified_file,
|
||||||
|
world_cells,
|
||||||
|
)
|
||||||
|
from k1link.web.portable_replay_api import build_portable_replay_router
|
||||||
|
|
||||||
|
RESULT = "m49-tgs-portable-review-" + "a" * 64
|
||||||
|
BASE = "b" * 64
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"damage", [None, "frame-time", "source-bundle", "nan-height", "state", "missing"]
|
||||||
|
)
|
||||||
|
def test_array_admission_checks_clocks_support_and_identity(tmp_path, monkeypatch, damage):
|
||||||
|
from k1link.artifact_gateway import CentralArtifactStore
|
||||||
|
from k1link.observatory.portable_result_contract import canonical_json
|
||||||
|
from k1link.observatory.portable_tgs_replay import STATE_CODES, load_tgs_data
|
||||||
|
|
||||||
|
store = CentralArtifactStore(tmp_path / "store", create=True)
|
||||||
|
members = []
|
||||||
|
|
||||||
|
def member(role, payload):
|
||||||
|
path = tmp_path / role
|
||||||
|
path.write_bytes(payload)
|
||||||
|
item = store.publish_file(path)
|
||||||
|
members.append({"role": role, "sha256": item.sha256, "byte_length": item.byte_length})
|
||||||
|
return item.sha256
|
||||||
|
|
||||||
|
source_row = {
|
||||||
|
"timeline_frame_index": 0,
|
||||||
|
"source_frame_index": 1,
|
||||||
|
"session_seconds": 1.0,
|
||||||
|
"sample_available": damage != "missing",
|
||||||
|
"available_slot": 0,
|
||||||
|
"position_map_m": [10.0, 20.0, 3.0],
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"k1link.observatory.portable_tgs_replay.read_m49_source_index",
|
||||||
|
lambda *args, **kwargs: (source_row,),
|
||||||
|
)
|
||||||
|
member("source-stage-index", b"test")
|
||||||
|
frame = dict(source_row)
|
||||||
|
if damage == "frame-time":
|
||||||
|
frame["session_seconds"] = 2.0
|
||||||
|
member("frame-index", canonical_json(frame) + b"\n")
|
||||||
|
identity = {
|
||||||
|
"source": {"source_session_id": "source", "source_bundle_sha256": BASE},
|
||||||
|
"profile": {"profile_sha256": "c" * 64},
|
||||||
|
}
|
||||||
|
stage_sha = hashlib.sha256(canonical_json(identity)).hexdigest()
|
||||||
|
stage_manifest = member(
|
||||||
|
"source-stage-manifest",
|
||||||
|
canonical_json({"identity": identity, "identity_sha256": stage_sha}),
|
||||||
|
)
|
||||||
|
import io
|
||||||
|
|
||||||
|
for role, array in (
|
||||||
|
("costmap-cell-centers", np.array([[1.0, 2.0]], dtype="<f4")),
|
||||||
|
("costmap-states", np.array([[4 if damage == "state" else 2]], dtype="u1")),
|
||||||
|
(
|
||||||
|
"costmap-z-bounds",
|
||||||
|
np.array([[[np.nan if damage == "nan-height" else -1.0, 1.0]]], dtype="<f4"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
np.save(buffer, array, allow_pickle=False)
|
||||||
|
member(role, buffer.getvalue())
|
||||||
|
view = {
|
||||||
|
"result_id": RESULT,
|
||||||
|
"source_session_id": "source",
|
||||||
|
"artifacts": members,
|
||||||
|
"result_document": {
|
||||||
|
"schema_version": "missioncore.recorded-tgs-costmap-review/v2",
|
||||||
|
"result_id": RESULT,
|
||||||
|
"timeline": {"frame_count": 1},
|
||||||
|
"costmap": {
|
||||||
|
"cell_count": 1,
|
||||||
|
"cell_size_m": 0.45,
|
||||||
|
"coordinate_frame": "map-gravity-local",
|
||||||
|
"state_codes": STATE_CODES,
|
||||||
|
},
|
||||||
|
"profile": identity["profile"],
|
||||||
|
"source_stage": {"identity_sha256": stage_sha, "manifest_sha256": stage_manifest},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if damage is None:
|
||||||
|
assert load_tgs_data(view, store, source_bundle_sha256=BASE).states[0, 0] == 2
|
||||||
|
else:
|
||||||
|
with pytest.raises(PortableReplayError):
|
||||||
|
load_tgs_data(
|
||||||
|
view, store, source_bundle_sha256="d" * 64 if damage == "source-bundle" else BASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def data() -> TgsReplayData:
|
||||||
|
return TgsReplayData(
|
||||||
|
(
|
||||||
|
{"session_seconds": 1.0, "sample_available": True, "position_map_m": [10, 20, 3]},
|
||||||
|
{"session_seconds": 2.0, "sample_available": False, "position_map_m": None},
|
||||||
|
),
|
||||||
|
np.array([[1.0, 2.0], [2.0, 3.0]]),
|
||||||
|
np.array([[2, 0], [0, 0]], dtype="u1"),
|
||||||
|
np.array([[[-1.0, 1.0], [np.nan, np.nan]], [[np.nan, np.nan], [np.nan, np.nan]]]),
|
||||||
|
0.45,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_world_cells_use_exact_pose_once_and_missing_is_not_free():
|
||||||
|
replay = data()
|
||||||
|
centers, sizes = world_cells(replay, 0, 2)
|
||||||
|
np.testing.assert_allclose(centers, [[11.0, 22.0, 3.0]])
|
||||||
|
np.testing.assert_allclose(sizes, [[0.45, 0.45, 2.0]])
|
||||||
|
assert world_cells(replay, 0, 0)[0].shape == (0, 3)
|
||||||
|
assert world_cells(replay, 1, 2)[0].shape == (0, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tgs_native_serialization_clears_missing_and_coverage_end(tmp_path):
|
||||||
|
path = tmp_path / "scene.rrd"
|
||||||
|
recording = rr.RecordingStream("nodedc_mission_core_recorded", recording_id="test")
|
||||||
|
calls = []
|
||||||
|
native_log = recording.log
|
||||||
|
|
||||||
|
def capture(entity, value):
|
||||||
|
calls.append((entity, value))
|
||||||
|
native_log(entity, value)
|
||||||
|
|
||||||
|
recording.log = capture
|
||||||
|
recording.set_sinks(rr.FileSink(path, write_footer=True))
|
||||||
|
log_tgs(recording, data(), 2.1)
|
||||||
|
recording.flush()
|
||||||
|
recording.disconnect()
|
||||||
|
assert path.read_bytes().startswith(b"RRF2")
|
||||||
|
assert sum(isinstance(value, rr.Boxes3D) for _, value in calls) == 1
|
||||||
|
assert all(isinstance(value, rr.Clear) for _, value in calls[3:])
|
||||||
|
assert calls[-1][0] == "/world/costmap"
|
||||||
|
assert not any(entity.startswith("/world/points") for entity, _ in calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_coverage_cannot_end_before_last_tgs_sample():
|
||||||
|
with pytest.raises(PortableReplayError):
|
||||||
|
log_tgs(SimpleNamespace(), data(), 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_artifact_corruption_and_symlinks_are_rejected(tmp_path):
|
||||||
|
path = tmp_path / "artifact"
|
||||||
|
path.write_bytes(b"first")
|
||||||
|
sha = hashlib.sha256(b"first").hexdigest()
|
||||||
|
assert verified_file(path, sha, 5) == path
|
||||||
|
path.write_bytes(b"other")
|
||||||
|
with pytest.raises(PortableReplayError):
|
||||||
|
verified_file(path, sha, 5)
|
||||||
|
link = tmp_path / "link"
|
||||||
|
link.symlink_to(path)
|
||||||
|
with pytest.raises(PortableReplayError):
|
||||||
|
verified_file(link, hashlib.sha256(b"other").hexdigest(), 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_proxy_keeps_demux_time_base_and_reuses_its_own_sealed_cache(tmp_path, monkeypatch):
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
source = tmp_path / "init.mp4"
|
||||||
|
source.write_bytes(b"synthetic")
|
||||||
|
epoch = SimpleNamespace(
|
||||||
|
init_path=source,
|
||||||
|
init_sha256=hashlib.sha256(b"synthetic").hexdigest(),
|
||||||
|
init_byte_length=9,
|
||||||
|
segments=[],
|
||||||
|
)
|
||||||
|
service = PortableReplayService(
|
||||||
|
view=None,
|
||||||
|
data_dir=tmp_path,
|
||||||
|
media=None,
|
||||||
|
recording_source=lambda _: None,
|
||||||
|
ffmpeg_path=Path("ffmpeg"),
|
||||||
|
)
|
||||||
|
service.cache.mkdir()
|
||||||
|
staging = tmp_path / "staging"
|
||||||
|
staging.mkdir()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def run(command, **kwargs):
|
||||||
|
calls.append(command)
|
||||||
|
assert command[command.index("-enc_time_base") + 1] == "-1"
|
||||||
|
assert command[command.index("-fps_mode") + 1] == "passthrough"
|
||||||
|
assert "-r" not in command and "-frames:v" not in command
|
||||||
|
Path(command[-1]).write_bytes(b"synthetic-proxy")
|
||||||
|
return SimpleNamespace(returncode=0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", run)
|
||||||
|
first = service._video(epoch, staging)
|
||||||
|
assert first.parent == service.cache
|
||||||
|
assert service._video(epoch, staging) == first
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_api_get_never_prepares_and_range_is_generation_fenced(tmp_path):
|
||||||
|
path = tmp_path / "replay.rrd"
|
||||||
|
path.write_bytes(b"RRF2test")
|
||||||
|
sha = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
artifact = CanonicalLabReplayArtifact(path, 8, sha)
|
||||||
|
prepared = []
|
||||||
|
|
||||||
|
def prepare(*args):
|
||||||
|
prepared.append(args)
|
||||||
|
return artifact
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(
|
||||||
|
build_portable_replay_router(
|
||||||
|
SimpleNamespace(prepare=prepare, cached=lambda *args: artifact if prepared else None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
url = f"/api/v1/observatory/portable-results/{RESULT}/replays/{BASE}/recording.rrd"
|
||||||
|
assert client.get(url, params={"generation": sha}).status_code == 409
|
||||||
|
assert not prepared
|
||||||
|
response = client.head(url)
|
||||||
|
assert response.status_code == 200 and response.headers["etag"] == f'"{sha}"'
|
||||||
|
assert response.headers["x-rerun-format"] == "RRF2"
|
||||||
|
response = client.get(url, params={"generation": sha}, headers={"Range": "bytes=0-3"})
|
||||||
|
assert response.status_code == 206 and response.content == b"RRF2"
|
||||||
|
assert client.get(url, params={"generation": "c" * 64}).status_code == 412
|
||||||
|
assert len(prepared) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_warm_reopen_does_not_prepare_and_detects_same_size_corruption(tmp_path, monkeypatch):
|
||||||
|
import json
|
||||||
|
|
||||||
|
service = PortableReplayService(
|
||||||
|
view=SimpleNamespace(read=lambda _: {"artifact_manifest_id": "d" * 64}),
|
||||||
|
data_dir=tmp_path,
|
||||||
|
media=None,
|
||||||
|
recording_source=lambda _: None,
|
||||||
|
ffmpeg_path=Path("ffmpeg"),
|
||||||
|
)
|
||||||
|
key, _ = service._key(RESULT, BASE)
|
||||||
|
service.cache.mkdir()
|
||||||
|
path = service.cache / ("e" * 64 + ".replay.rrd")
|
||||||
|
path.write_bytes(b"RRF2test")
|
||||||
|
sha = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
(service.cache / f"{key}.json").write_text(
|
||||||
|
json.dumps({"key": key, "file": path.name, "sha256": sha, "byte_length": 8})
|
||||||
|
)
|
||||||
|
assert service.prepare(RESULT, BASE).sha256 == sha
|
||||||
|
monkeypatch.setattr(hashlib, "file_digest", lambda *_: pytest.fail("warm byte rehash"))
|
||||||
|
assert service.prepare(RESULT, BASE).sha256 == sha
|
||||||
|
monkeypatch.undo()
|
||||||
|
path.write_bytes(b"RRF2fail")
|
||||||
|
with pytest.raises(PortableReplayError):
|
||||||
|
service.cached(RESULT, BASE)
|
||||||
Reference in New Issue
Block a user