Preserve onboard preview recordings across backpressure and share the complete spatial scene
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user