From 51ea86e8874a9dca90c15ded781b8d37934dc48f Mon Sep 17 00:00:00 2001 From: nnbcccscdscdsc <2709767634@qq.com> Date: Thu, 2 Apr 2026 15:29:49 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E8=AE=A1=E7=AE=97=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E5=B8=A7=E4=BC=A0=E6=9D=A5=E7=9A=84=E5=B7=AE=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/monitoring/services.py | 103 ++++++++++++++- config/omnisocket_demo.yaml | 4 +- frontend/src/App.vue | 19 +-- frontend/src/components/VideoPanel.vue | 170 +++++++++++++++++++++++-- frontend/src/lib/api.ts | 6 +- frontend/src/types.ts | 18 +++ 6 files changed, 294 insertions(+), 26 deletions(-) diff --git a/backend/monitoring/services.py b/backend/monitoring/services.py index 58277c1..59683dd 100644 --- a/backend/monitoring/services.py +++ b/backend/monitoring/services.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections import deque import json import math import os @@ -20,6 +21,8 @@ GEOSTREAM_STALE_SECONDS = 15 OMNISOCKET_CONFIG_PATH = PROJECT_ROOT / "config" / "omnisocket_demo.yaml" VIDEO_SOURCE_MODE = os.getenv("VIDEO_SOURCE_MODE", "auto").strip().lower() OMNISOCKET_FRAME_FRESH_SECONDS = 2.0 +VIDEO_TIMESTAMP_SAMPLE_SIZE = 10 +VIDEO_TIMESTAMP_MAX_SKEW_NS = 7 * 24 * 60 * 60 * 1_000_000_000 def utc_iso_now() -> str: @@ -38,6 +41,10 @@ class OmniSocketVideoReceiver: self._latest_frame: bytes | None = None self._latest_received_at = 0.0 self._latest_sequence: int | None = None + self._latest_latency_ms: float | None = None + self._latest_timestamp_unit: str | None = None + self._latest_timestamp_endianness: str | None = None + self._latency_samples_ms: deque[float] = deque(maxlen=VIDEO_TIMESTAMP_SAMPLE_SIZE) self._frames_received = 0 self._last_error = "" self._load_backend() @@ -194,7 +201,7 @@ class OmniSocketVideoReceiver: ) return session, int(video_cfg.get("buffer_bytes", 1024 * 1024)) - def _extract_jpeg_frame(self, frame: bytes) -> bytes | None: + def _extract_jpeg_payload(self, frame: bytes) -> bytes | None: # 同时兼容两种帧格式: # 1. 纯 JPEG 二进制 # 2. 前 8 字节是序号,后面才是真正的 JPEG 数据 @@ -204,11 +211,68 @@ class OmniSocketVideoReceiver: return frame[8:] return None + def _extract_jpeg_frame(self, frame: bytes) -> bytes | None: + jpeg_payload = self._extract_jpeg_payload(frame) + if jpeg_payload is None: + return None + + eoi_index = jpeg_payload.rfind(b"\xff\xd9") + if eoi_index < 0: + return jpeg_payload + + return jpeg_payload[: eoi_index + 2] + def _extract_sequence(self, frame: bytes) -> int | None: if len(frame) >= 8 and not frame.startswith(b"\xff\xd8"): return int.from_bytes(frame[:8], "big") return None + def _extract_frame_tail(self, frame: bytes) -> bytes: + jpeg_payload = self._extract_jpeg_payload(frame) + if jpeg_payload is None: + return b"" + + eoi_index = jpeg_payload.rfind(b"\xff\xd9") + if eoi_index < 0: + return b"" + + trailer_start = eoi_index + 2 + if trailer_start >= len(jpeg_payload): + return b"" + + return jpeg_payload[trailer_start:] + + def _extract_frame_timestamp(self, frame: bytes) -> tuple[int, str, str] | None: + trailer = self._extract_frame_tail(frame) + if len(trailer) < 8: + return None + + now_ns = time.time_ns() + raw_timestamp = trailer[-8:] + best_candidate: tuple[int, str, str] | None = None + best_distance_ns: int | None = None + + for endianness in ("big", "little"): + value = int.from_bytes(raw_timestamp, endianness, signed=True) + if value <= 0: + continue + + for unit, multiplier in ( + ("ns", 1), + ("us", 1_000), + ("ms", 1_000_000), + ("s", 1_000_000_000), + ): + timestamp_ns = value * multiplier + distance_ns = abs(now_ns - timestamp_ns) + if distance_ns > VIDEO_TIMESTAMP_MAX_SKEW_NS: + continue + if best_distance_ns is None or distance_ns < best_distance_ns: + best_distance_ns = distance_ns + best_candidate = (timestamp_ns, unit, endianness) + + return best_candidate + def _run(self) -> None: # 后台持续接收循环: # connect -> recv_into(buffer) -> 按 body_len 截出有效内容 -> 把最新 JPEG 帧缓存在内存里 @@ -234,11 +298,25 @@ class OmniSocketVideoReceiver: self._last_error = "received non-JPEG binary frame" continue + timestamp_meta = self._extract_frame_timestamp(frame) + latency_ms = None + if timestamp_meta is not None: + timestamp_ns, unit, endianness = timestamp_meta + latency_ms = round((time.time_ns() - timestamp_ns) / 1_000_000, 3) + else: + unit = None + endianness = None + with self._lock: # 缓存:这里只保留最新的一张 JPEG 帧,供 Web 接口直接返回给前端。 self._latest_frame = jpeg_frame self._latest_received_at = time.time() self._latest_sequence = self._extract_sequence(frame) + self._latest_latency_ms = latency_ms + self._latest_timestamp_unit = unit + self._latest_timestamp_endianness = endianness + if latency_ms is not None: + self._latency_samples_ms.append(latency_ms) self._frames_received += 1 except Exception as error: # pragma: no cover - 运行时集成路径 self._last_error = str(error) @@ -271,6 +349,26 @@ class OmniSocketVideoReceiver: has_recent_frame = self._latest_frame is not None and ( time.time() - self._latest_received_at <= OMNISOCKET_FRAME_FRESH_SECONDS ) + if has_recent_frame and self._latest_latency_ms is not None: + timing_status = { + "available": True, + "latest_delta_ms": self._latest_latency_ms, + "delta_samples_ms": list(reversed(self._latency_samples_ms)), + "sample_count": len(self._latency_samples_ms), + "sample_window_size": VIDEO_TIMESTAMP_SAMPLE_SIZE, + "timestamp_unit": self._latest_timestamp_unit, + "timestamp_endianness": self._latest_timestamp_endianness, + } + else: + timing_status = { + "available": False, + "latest_delta_ms": None, + "delta_samples_ms": [], + "sample_count": 0, + "sample_window_size": VIDEO_TIMESTAMP_SAMPLE_SIZE, + "timestamp_unit": None, + "timestamp_endianness": None, + } return { "backend_ready": self._session_cls is not None, "mode": VIDEO_SOURCE_MODE, @@ -284,6 +382,7 @@ class OmniSocketVideoReceiver: "relay_via": str(transport_cfg.get("relay_via", "")), "peer_id": str(video_cfg.get("peer_id", "")), "buffer_bytes": int(video_cfg.get("buffer_bytes", 0)), + "timing": timing_status, } @@ -305,6 +404,7 @@ class VideoFrameService: "frame_dir": str(JPEG_FRAME_DIR), "source_detail": f"peer stream active, frames={receiver_status['frames_received']}", "receiver": receiver_status, + "timing": receiver_status["timing"], } wait_detail = receiver_status["last_error"] or ( @@ -318,6 +418,7 @@ class VideoFrameService: "frame_dir": str(JPEG_FRAME_DIR), "source_detail": wait_detail, "receiver": receiver_status, + "timing": receiver_status["timing"], } def get_next_frame(self) -> bytes: diff --git a/config/omnisocket_demo.yaml b/config/omnisocket_demo.yaml index 531faf8..6199842 100644 --- a/config/omnisocket_demo.yaml +++ b/config/omnisocket_demo.yaml @@ -1,6 +1,6 @@ transport: - server_addr: "127.0.0.1:10909" - relay_via: "" + server_addr: "" + relay_via: "106.55.173.235:10909" bind_ip: "" bind_device: "" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e894d99..1cd0a0c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -63,24 +63,26 @@ const navItems = [ .app-shell { width: min(1440px, calc(100% - 32px)); margin: 0 auto; - padding: 22px 0 40px; + padding: 0 0 40px; } .topbar { position: sticky; - top: 16px; - z-index: 20; + top: 0; + z-index: 100; display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 14px 18px; margin-bottom: 24px; - border-radius: 24px; - background: rgba(8, 14, 26, 0.82); - border: 1px solid rgba(133, 147, 169, 0.2); - box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18); - backdrop-filter: blur(16px); + border-radius: 0 0 24px 24px; + background: linear-gradient(180deg, #0a1324 0%, #08101d 100%); + border: 1px solid rgba(133, 147, 169, 0.22); + border-top: none; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28); + overflow: hidden; + isolation: isolate; } :global(.panel) { @@ -164,7 +166,6 @@ const navItems = [ @media (max-width: 960px) { .topbar { - position: static; flex-direction: column; align-items: stretch; } diff --git a/frontend/src/components/VideoPanel.vue b/frontend/src/components/VideoPanel.vue index 6f43785..6a701aa 100644 --- a/frontend/src/components/VideoPanel.vue +++ b/frontend/src/components/VideoPanel.vue @@ -1,37 +1,82 @@