Preserve onboard preview recordings across backpressure and share the complete spatial scene

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 17:47:10 +03:00
parent 9b6534287a
commit 9bba44f7c4
30 changed files with 957 additions and 494 deletions
@@ -98,6 +98,7 @@ def project_sensor(snapshot, node_id):
"network_applied": connected or attempt.get("phase") == "network_applied",
"reason_code": None if connected else attempt.get("public_error_code"),
"acquisition_id": acquisition.get("acquisition_id"),
"acquisition_phase": acquisition.get("state"),
},
"live_settings": snapshot.get("viewer_settings", {}),
"frames": snapshot.get("metrics", {}),
@@ -149,6 +150,8 @@ class NodeK1Sensor:
return item
if action == "close-peer":
await self.peers.close(params.get("peer_id"))
if params.get("retire_view") is True:
await self.peers.release_view(params.get("view_id"))
return {"ok": True}
if action == "offer":
acquisition_id = item["control"]["acquisition_id"]
+66 -16
View File
@@ -8,12 +8,14 @@ import queue
import time
from contextlib import suppress
from itertools import chain
from uuid import uuid4
from uuid import UUID, uuid4
import aioice.ice
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
MEDIA_PROTOCOL = "missioncore.node-preview/v2"
from .node_rerun import PreviewResumeError
MEDIA_PROTOCOL = "missioncore.node-preview/v3"
MAX_PAYLOAD = 8 * 1024 * 1024
FRAGMENT_BYTES = 16384
logger = logging.getLogger(__name__)
@@ -60,11 +62,17 @@ class NodeMediaPeers:
async def offer(self, parameters):
admit_sdp(parameters.get("sdp"))
view_id, after = parameters.get("view_id"), parameters.get("after", 0)
if not isinstance(view_id, str) or str(UUID(view_id)) != view_id:
raise ValueError("Preview view identifier required")
if type(after) is not int or not 0 <= after <= 2**53 - 1:
raise ValueError("Invalid preview cursor")
if len(self.items) >= 2:
raise ValueError("Close another live viewer")
pc = RTCPeerConnection(RTCConfiguration(iceServers=[]))
identifier = "peer_" + uuid4().hex
entry = {"pc": pc, "seen": time.monotonic(), "tasks": [], "labels": set()}
entry = {"pc": pc, "seen": time.monotonic(), "tasks": [], "labels": set(),
"view_id": view_id, "after": after, "subscriber": None}
self.items[identifier] = entry
@pc.on("datachannel")
@@ -78,6 +86,14 @@ class NodeMediaPeers:
def message(value):
if value == "keepalive":
entry["seen"] = time.monotonic()
elif channel.label == "rrd" and isinstance(value, str) and len(value) < 80:
try:
ack = json.loads(value)
sequence = ack.get("ack")
if type(sequence) is int and entry["subscriber"] is not None:
entry["subscriber"].acknowledge(sequence)
except (ValueError, AttributeError):
pass
entry["tasks"].append(asyncio.create_task(self.deliver(identifier, channel)))
@@ -110,7 +126,7 @@ class NodeMediaPeers:
await self.close(identifier)
raise
async def send(self, channel, payload):
async def send(self, channel, payload, alive=None):
if not 0 < len(payload) <= MAX_PAYLOAD:
raise RuntimeError("Preview fragment exceeds bound")
# Each binary_stream.read() is an independent RRD. SCTP messages are
@@ -118,9 +134,9 @@ class NodeMediaPeers:
parts = (payload[offset:offset + FRAGMENT_BYTES]
for offset in range(0, len(payload), FRAGMENT_BYTES))
for part in chain((b"MCF1" + len(payload).to_bytes(4, "big"),), parts):
deadline = time.monotonic() + 2
while channel.readyState != "open" or channel.bufferedAmount > 1024 * 1024:
if channel.readyState in {"closed", "closing"} or time.monotonic() > deadline:
while channel.readyState != "open" or channel.bufferedAmount > 256 * 1024:
if (channel.readyState in {"closed", "closing"}
or (alive is not None and not alive())):
raise RuntimeError("Preview consumer unavailable")
await asyncio.sleep(0.01)
channel.send(part)
@@ -166,32 +182,58 @@ class NodeMediaPeers:
try:
entry = self.items[identifier]
if channel.label == "rrd":
subscriber = await asyncio.to_thread(self.hub.subscribe)
# Admission only takes a short in-process lock. Keep it on this
# task so cancellation cannot orphan an attached subscription.
subscriber = self.hub.subscribe(entry["view_id"], entry["after"])
entry["subscriber"] = subscriber
else:
lease = await self.camera_delivery(identifier, channel)
if lease is None:
return
while identifier in self.items and time.monotonic() - entry["seen"] < 30:
def alive():
return identifier in self.items and time.monotonic() - entry["seen"] < 30
while alive():
if subscriber:
payload = await asyncio.to_thread(subscriber.read)
batch = subscriber.next_batch(wait=False)
if batch is None:
break
if not batch:
await asyncio.sleep(0.025)
continue
sequence, payload, frame = batch
channel.send(json.dumps({"type": "rrd-batch", "sequence": sequence,
"lidar": subscriber.snapshot(frame)}))
else:
try:
segment = await asyncio.to_thread(lease.segments.get, 0.5)
except queue.Empty:
continue
if segment is None:
break
self.camera.release_delivery(lease, client_closed=True)
lease = None
lease = await self.camera_delivery(identifier, channel)
if lease is None:
break
continue
kind, payload = segment
if kind == "media":
self.camera.mark_streaming(lease)
if payload is None:
break
if payload:
await self.send(channel, payload)
await self.send(channel, payload, alive)
if subscriber:
# Ordered after native bytes. Age is source arrival age,
# not time spent replaying an encoded preview backlog.
channel.send(json.dumps(subscriber.snapshot()))
# One in-flight complete native batch. Network stalls
# pause this disposable delivery; the recorder keeps running.
while alive() and subscriber.pending is not None:
await asyncio.sleep(0.01)
except PreviewResumeError:
if channel.readyState == "open":
channel.send(json.dumps({"type": "preview-unavailable", "code": "resume-expired"}))
# Let the receiver consume the terminal reason and close its peer.
deadline = time.monotonic() + 1
while channel.readyState == "open" and time.monotonic() < deadline:
await asyncio.sleep(0.025)
except asyncio.CancelledError:
pass
except Exception as error:
@@ -199,7 +241,7 @@ class NodeMediaPeers:
channel.label, type(error).__name__)
finally:
if subscriber:
subscriber.close()
subscriber.release()
if lease:
self.camera.release_delivery(lease, client_closed=True)
if channel.label == "camera":
@@ -216,6 +258,14 @@ class NodeMediaPeers:
with suppress(Exception):
await entry["pc"].close()
async def release_view(self, view_id):
if not isinstance(view_id, str) or str(UUID(view_id)) != view_id:
raise ValueError("Invalid preview view identifier")
for identifier, entry in list(self.items.items()):
if entry["view_id"] == view_id:
await self.close(identifier)
self.hub.release_view(view_id)
async def close_all(self):
for identifier in list(self.items):
await self.close(identifier)
+129 -28
View File
@@ -1,8 +1,8 @@
"""Bounded live RRD publication for paired Node viewers, without a TCP listener.
Every viewer receives a fresh native recording including StoreInfo/blueprint.
Latest-value queues discard decoded preview frames before encoding; encoded
RRD bytes are never dropped inside a stream. Slow viewers are closed instead.
Each view owns one recording for the acquisition, including across peer recovery.
Decoded frames coalesce before encoding; a bounded encoded outbox waits for the
viewer. Only complete, acknowledged RRD batches leave that outbox.
"""
import logging
@@ -17,13 +17,25 @@ from k1link.viewer.rerun_bridge import RerunBridge
MAX_ENCODED_CHUNK = 8 * 1024 * 1024
LIVE_FRAME_MAX_AGE_SECONDS = 2.0
RESUME_GRACE_SECONDS = 300.0
logger = logging.getLogger(__name__)
_CURRENT_FRAME = object()
class PreviewResumeError(ValueError):
"""A view must be explicitly reopened; silently resetting history is forbidden."""
class RrdSubscriber:
def __init__(self, settings_provider):
def __init__(self, settings_provider, path_provider=None):
self.closed = threading.Event()
self.inputs = queue.Queue(maxsize=2)
self.inputs = {}
self.input_ready = threading.Condition()
self.delivery_lock = threading.Lock()
self.pending = None
self.batch_sequence = 0
self.detached_at = None
self.path_provider = path_provider
self.output = queue.Queue(maxsize=2)
self.settings_provider = settings_provider
self.last_frame = None
@@ -33,22 +45,55 @@ class RrdSubscriber:
def offer(self, envelope):
if self.closed.is_set():
return
with suppress(queue.Full):
if self.inputs.full():
with suppress(queue.Empty):
self.inputs.get_nowait()
self.inputs.put_nowait(envelope)
with self.input_ready:
# Keep one latest envelope per modality: a pose burst must not
# starve PCL, and no network wait reaches the acquisition producer.
self.inputs[type(envelope)] = envelope
self.input_ready.notify()
def read(self):
def attach(self, after=0):
if self.closed.is_set() or not self.delivery_lock.acquire(blocking=False):
raise RuntimeError("Preview is closed or still attached")
if after != self.batch_sequence and not (
self.pending is not None and after == self.batch_sequence - 1):
self.delivery_lock.release()
raise PreviewResumeError("Preview cursor does not match recording")
self.acknowledge(after)
self.detached_at = None
return self
def release(self):
self.detached_at = time.monotonic()
self.delivery_lock.release()
def next_batch(self, *, wait=True):
if self.pending is None:
value = self._read(wait=wait)
if not value:
return value
self.batch_sequence += 1
self.pending = (self.batch_sequence, *value)
return self.pending
def acknowledge(self, sequence):
if self.pending is not None and sequence == self.pending[0]:
self.pending = None
def _read(self, *, wait=True):
if self.closed.is_set():
return None
try:
return self.output.get(timeout=0.5)
return self.output.get(timeout=0.5) if wait else self.output.get_nowait()
except queue.Empty:
return b""
def snapshot(self):
frame = self.last_frame
def read(self):
# Kept for native sink inspection; media delivery uses acknowledged batches.
value = self._read()
return value[0] if value else value
def snapshot(self, frame=_CURRENT_FRAME):
frame = self.last_frame if frame is _CURRENT_FRAME else frame
return {"type": "lidar-state", "sequence": frame[0] if frame else 0,
"age_ms": max(0, (time.monotonic_ns() - frame[1]) / 1_000_000) if frame else None,
"points": frame[2] if frame else 0}
@@ -66,7 +111,8 @@ class RrdSubscriber:
return "webrtc+rrd://" + str(uuid4())
try:
bridge = RerunBridge(settings_provider=self.settings_provider, recording_output=output)
bridge = PreviewRerunBridge(settings_provider=self.settings_provider,
recording_output=output, path_provider=self.path_provider)
bridge.begin_session()
while not self.closed.is_set():
payload = binary.read()
@@ -75,13 +121,21 @@ class RrdSubscriber:
if payload and len(payload) > MAX_ENCODED_CHUNK:
break
if payload:
# Bound both bytes and waiting time. The archive/producer
# never waits for this disposable preview subscription.
self.output.put(payload, timeout=0.5)
try:
envelope = self.inputs.get(timeout=0.1)
except queue.Empty:
continue
# Backpressure is a pause, never EOF. At most two encoded
# batches plus this one and the in-flight batch are retained.
# Do not drop encoded bytes or restart a native recording.
while not self.closed.is_set():
try:
self.output.put((payload, self.last_frame), timeout=0.1)
break
except queue.Full:
continue
with self.input_ready:
if not self.inputs:
self.input_ready.wait(timeout=0.1)
if not self.inputs:
continue
envelope = self.inputs.pop(next(iter(self.inputs)))
received = envelope.context.received_monotonic_ns
if (received is not None
and (time.monotonic_ns() - received) / 1_000_000_000
@@ -104,10 +158,28 @@ class RrdSubscriber:
binary.read()
class PreviewRerunBridge(RerunBridge):
def __init__(self, *, path_provider=None, **kwargs):
self.path_provider = path_provider
super().__init__(**kwargs)
def _append_trajectory_pose(self, position, source_time_ns):
if self.path_provider is None:
return super()._append_trajectory_pose(position, source_time_ns)
# Route history belongs to the acquisition, including movement during
# preview congestion. The primary bridge already bounds its point count.
path = self.path_provider()
if self._path == path:
return False
self._path = path
return True
class NodeRerunBridge(RerunBridge):
def __init__(self, **kwargs):
self.lock = threading.Lock()
self.subscribers = []
self.views = {}
self.latest = {}
def output(recording):
@@ -124,6 +196,7 @@ class NodeRerunBridge(RerunBridge):
self.binary.read()
with self.lock:
self.latest[type(envelope)] = (time.monotonic(), envelope)
self._expire_views()
self.subscribers = [v for v in self.subscribers if not v.closed.is_set()]
for subscriber in self.subscribers:
subscriber.offer(envelope)
@@ -132,24 +205,48 @@ class NodeRerunBridge(RerunBridge):
super().process_perception(frame)
self.binary.read()
def subscribe(self):
def _expire_views(self):
for key, value in list(self.views.items()):
if (value.closed.is_set() or (value.detached_at is not None
and time.monotonic() - value.detached_at > RESUME_GRACE_SECONDS)):
value.close()
del self.views[key]
def subscribe(self, view_id=None, after=0):
with self.lock:
self._expire_views()
if self._closed:
raise RuntimeError("Live acquisition is not active")
if view_id in self.views:
return self.views[view_id].attach(after)
if after:
raise PreviewResumeError("Preview recording expired; reopen the view")
self.subscribers = [v for v in self.subscribers if not v.closed.is_set()]
if self._closed or len(self.subscribers) >= 2:
raise RuntimeError("Live viewer unavailable")
subscriber = RrdSubscriber(self._settings_provider)
if len(self.subscribers) >= 2:
raise RuntimeError("Close another live viewer")
subscriber = RrdSubscriber(self._settings_provider, lambda: list(self._path))
for observed, envelope in self.latest.values():
if time.monotonic() - observed <= LIVE_FRAME_MAX_AGE_SECONDS:
subscriber.offer(envelope)
self.subscribers.append(subscriber)
if view_id is not None:
self.views[view_id] = subscriber
subscriber.attach()
return subscriber
def release_view(self, view_id):
with self.lock:
subscriber = self.views.pop(view_id, None)
if subscriber is not None:
subscriber.close()
def close(self):
with self.lock:
for subscriber in self.subscribers:
subscriber.close()
self.subscribers.clear()
self.latest.clear()
self.views.clear()
super().close()
self.binary.read()
@@ -163,8 +260,12 @@ class NodeRerunHub:
self.bridge = bridge
return bridge
def subscribe(self):
def subscribe(self, view_id=None, after=0):
bridge = self.bridge
if bridge is None:
raise RuntimeError("Live acquisition is not active")
return bridge.subscribe()
return bridge.subscribe(view_id, after)
def release_view(self, view_id):
if self.bridge is not None:
self.bridge.release_view(view_id)