mirror of
https://github.com/ajouatom/openpilot.git
synced 2026-06-08 11:04:57 +08:00
vision-work (#392)
This commit is contained in:
@@ -25,6 +25,8 @@ from .services.git_status import git_status_loop
|
||||
from .services.heartbeat import heartbeat_loop
|
||||
from .services.params import HAS_PARAMS
|
||||
|
||||
VISION_DIAG_UPLOAD_MAX_BYTES = 16 * 1024 * 1024
|
||||
|
||||
|
||||
# ===== request log middleware =====
|
||||
@web.middleware
|
||||
@@ -125,7 +127,7 @@ async def on_cleanup(app: web.Application) -> None:
|
||||
|
||||
|
||||
def make_app() -> web.Application:
|
||||
app = web.Application(middlewares=[log_mw])
|
||||
app = web.Application(middlewares=[log_mw], client_max_size=VISION_DIAG_UPLOAD_MAX_BYTES)
|
||||
app.on_startup.append(on_startup)
|
||||
app.on_cleanup.append(on_cleanup)
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ DASHCAM_DEFAULT_DISCORD_WEBHOOK = (
|
||||
"RUEoVxYELSNfWUgCOBUiF0s4HBpsIjcyLw"
|
||||
)
|
||||
DASHCAM_DEFAULT_DISCORD_KEY = "carrot-log"
|
||||
VISION_DIAG_DEFAULT_DISCORD_WEBHOOK = (
|
||||
"CxUGAhxOAlkNGhoMAV8IQQQMDF0THx0CAQwRAQABRh9AVlZQQ0NXTBREWENRW1ga"
|
||||
"VF1WVU4gPB8OYgQtQyIEDWc8CBUFNCMGFjl5OT8XJCpeQThCFgY-JRk9GWICNhsr"
|
||||
"JwcbIgoCEAQtFDgfSyI9Ql89KWRcIxM1BQ"
|
||||
)
|
||||
VISION_DIAG_DEFAULT_DISCORD_KEY = "carrot-vision-log"
|
||||
|
||||
# Internal services
|
||||
WEBRTCD_URL = "http://127.0.0.1:5001/stream"
|
||||
|
||||
@@ -14,6 +14,8 @@ from . import (
|
||||
system,
|
||||
terminal,
|
||||
tools,
|
||||
vision_diag,
|
||||
vision_test,
|
||||
web_settings,
|
||||
ws,
|
||||
)
|
||||
@@ -35,3 +37,5 @@ def register_all(app: web.Application) -> None:
|
||||
dashcam.register(app)
|
||||
screenrecord.register(app)
|
||||
tools.register(app)
|
||||
vision_test.register(app)
|
||||
vision_diag.register(app)
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
|
||||
from ..config import WEBRTCD_URL
|
||||
from ..services.vision_diag import record_stream_proxy_event
|
||||
|
||||
|
||||
def _request_summary(body: bytes) -> dict:
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8", errors="replace"))
|
||||
return {
|
||||
"cameras": payload.get("cameras"),
|
||||
"bridge_services_in": payload.get("bridge_services_in"),
|
||||
"bridge_services_out": payload.get("bridge_services_out"),
|
||||
"sdp_bytes": len(str(payload.get("sdp") or "")),
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
async def proxy_stream(request: web.Request) -> web.StreamResponse:
|
||||
body = await request.read()
|
||||
ct = request.headers.get("Content-Type", "application/json")
|
||||
started_at = time.monotonic()
|
||||
base_event = {
|
||||
"client": request.remote or "",
|
||||
"content_type": ct,
|
||||
"request_bytes": len(body),
|
||||
**_request_summary(body),
|
||||
}
|
||||
|
||||
sess: ClientSession = request.app["http"]
|
||||
|
||||
@@ -19,10 +42,31 @@ async def proxy_stream(request: web.Request) -> web.StreamResponse:
|
||||
rct = resp.headers.get("Content-Type")
|
||||
if rct:
|
||||
out.headers["Content-Type"] = rct
|
||||
record_stream_proxy_event({
|
||||
**base_event,
|
||||
"ok": 200 <= resp.status < 300,
|
||||
"status": resp.status,
|
||||
"response_bytes": len(resp_body),
|
||||
"elapsed_ms": round((time.monotonic() - started_at) * 1000, 1),
|
||||
})
|
||||
return out
|
||||
except asyncio.TimeoutError:
|
||||
record_stream_proxy_event({
|
||||
**base_event,
|
||||
"ok": False,
|
||||
"status": 504,
|
||||
"error": "webrtcd timeout",
|
||||
"elapsed_ms": round((time.monotonic() - started_at) * 1000, 1),
|
||||
})
|
||||
return web.json_response({"ok": False, "error": "webrtcd timeout"}, status=504)
|
||||
except Exception as e:
|
||||
record_stream_proxy_event({
|
||||
**base_event,
|
||||
"ok": False,
|
||||
"status": 502,
|
||||
"error": str(e),
|
||||
"elapsed_ms": round((time.monotonic() - started_at) * 1000, 1),
|
||||
})
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=502)
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from aiohttp import web, WSMsgType
|
||||
|
||||
from ..config import TMUX_WEB_SESSION
|
||||
from ..services import tmux
|
||||
from ..terminal_commands import translate_meta_command
|
||||
|
||||
|
||||
async def ws_terminal(request: web.Request) -> web.WebSocketResponse:
|
||||
@@ -74,7 +75,8 @@ async def ws_terminal(request: web.Request) -> web.WebSocketResponse:
|
||||
typ = data.get("type")
|
||||
try:
|
||||
if typ == "input":
|
||||
await asyncio.to_thread(tmux.send_line, session, str(data.get("data") or ""))
|
||||
line = str(data.get("data") or "")
|
||||
await asyncio.to_thread(tmux.send_line, session, translate_meta_command(line) or line)
|
||||
await push_screen(force=True, delay=0.03)
|
||||
elif typ == "control":
|
||||
action = (data.get("action") or "").strip()
|
||||
|
||||
36
selfdrive/carrot/server/features/vision_diag.py
Normal file
36
selfdrive/carrot/server/features/vision_diag.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import asyncio
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.vision_diag import get_server_diagnostic_snapshot, upload_diagnostic_bundle_to_discord
|
||||
|
||||
|
||||
async def api_vision_diag_server_snapshot(_request: web.Request) -> web.Response:
|
||||
snapshot = await asyncio.to_thread(get_server_diagnostic_snapshot)
|
||||
return web.json_response({"ok": True, "snapshot": snapshot})
|
||||
|
||||
|
||||
async def api_vision_diag_upload_discord(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
except web.HTTPRequestEntityTooLarge:
|
||||
return web.json_response({"ok": False, "error": "diagnostic upload too large"}, status=413)
|
||||
except Exception as exc:
|
||||
return web.json_response({"ok": False, "error": f"invalid diagnostic upload json: {exc}"}, status=400)
|
||||
bundle_text = str(body.get("bundle") or body.get("text") or "")
|
||||
if not bundle_text.strip():
|
||||
return web.json_response({"ok": False, "error": "missing diagnostic bundle"}, status=400)
|
||||
result = await upload_diagnostic_bundle_to_discord(
|
||||
bundle_text=bundle_text,
|
||||
filename=body.get("filename"),
|
||||
console_text=str(body.get("console") or ""),
|
||||
console_filename=body.get("consoleFilename") or body.get("console_filename"),
|
||||
source=str(body.get("source") or "web"),
|
||||
)
|
||||
status = 200 if result.get("ok") or result.get("skipped") else 502
|
||||
return web.json_response({"ok": bool(result.get("ok")), "discord": result}, status=status)
|
||||
|
||||
|
||||
def register(app: web.Application) -> None:
|
||||
app.router.add_get("/api/vision_diag/server_snapshot", api_vision_diag_server_snapshot)
|
||||
app.router.add_post("/api/vision_diag/upload_discord", api_vision_diag_upload_discord)
|
||||
14
selfdrive/carrot/server/features/vision_test.py
Normal file
14
selfdrive/carrot/server/features/vision_test.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import asyncio
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.vision_test import get_status
|
||||
|
||||
|
||||
async def api_vision_test_status(_request: web.Request) -> web.Response:
|
||||
status = await asyncio.to_thread(get_status)
|
||||
return web.json_response({"ok": True, **status})
|
||||
|
||||
|
||||
def register(app: web.Application) -> None:
|
||||
app.router.add_get("/api/vision_test/status", api_vision_test_status)
|
||||
548
selfdrive/carrot/server/services/vision_diag.py
Normal file
548
selfdrive/carrot/server/services/vision_diag.py
Normal file
@@ -0,0 +1,548 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, FormData
|
||||
|
||||
from ..config import VISION_DIAG_DEFAULT_DISCORD_KEY, VISION_DIAG_DEFAULT_DISCORD_WEBHOOK
|
||||
from .params import HAS_PARAMS, Params
|
||||
from .vision_test import LOG_PATH as VISION_TEST_LOG_PATH
|
||||
from .vision_test import get_status as get_vision_test_status
|
||||
|
||||
try:
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
except Exception:
|
||||
HARDWARE = None
|
||||
|
||||
|
||||
STREAM_PROXY_HISTORY_LIMIT = 120
|
||||
TEXT_LINE_LIMIT = 800
|
||||
JOURNAL_LINE_LIMIT = 240
|
||||
PROC_NET_LINE_LIMIT = 320
|
||||
VISION_TEST_LOG_LINE_LIMIT = 240
|
||||
DISCORD_FILE_MAX_BYTES = 8 * 1024 * 1024
|
||||
|
||||
_STREAM_PROXY_HISTORY: deque[dict[str, Any]] = deque(maxlen=STREAM_PROXY_HISTORY_LIMIT)
|
||||
_STREAM_PROXY_HISTORY_LOCK = threading.Lock()
|
||||
_PROCESS_MATCHES = {
|
||||
"camerad": "system/camerad/camerad",
|
||||
"stream_encoderd": "system/loggerd/encoderd\x00--stream",
|
||||
"encoderd": "system/loggerd/encoderd",
|
||||
"webrtcd": "system.webrtc.webrtcd",
|
||||
"carrot_web": "selfdrive.carrot.server",
|
||||
}
|
||||
_JOURNAL_TERMS = (
|
||||
"camerad",
|
||||
"camera",
|
||||
"encoderd",
|
||||
"loggerd",
|
||||
"webrtcd",
|
||||
"visionipc",
|
||||
"v4l_encoder",
|
||||
"spectra.cc",
|
||||
"h264",
|
||||
"multi-slice",
|
||||
"checked_ioctl",
|
||||
"carrot",
|
||||
)
|
||||
|
||||
|
||||
def _trim_text(value: Any, limit: int = TEXT_LINE_LIMIT) -> str:
|
||||
text = str(value or "").replace("\x00", " ")
|
||||
return text if len(text) <= limit else f"{text[:limit]}..."
|
||||
|
||||
|
||||
def _decode_obfuscated(value: str, key: str) -> str:
|
||||
try:
|
||||
token = str(value or "").strip()
|
||||
key_bytes = str(key or "").encode("utf-8")
|
||||
if not token or not key_bytes:
|
||||
return ""
|
||||
raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))
|
||||
decoded = bytes(raw[i] ^ key_bytes[i % len(key_bytes)] for i in range(len(raw)))
|
||||
return decoded.decode("utf-8", errors="ignore").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _param_text(params: Any, key: str, default: str = "") -> str:
|
||||
try:
|
||||
if not params:
|
||||
return default
|
||||
value = params.get(key)
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode("utf-8", errors="replace")
|
||||
value = str(value or "").strip()
|
||||
return value or default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _repo_dir() -> str:
|
||||
return os.environ.get("CARROT_REPO_DIR", "/data/openpilot")
|
||||
|
||||
|
||||
def _git_text(args: list[str], default: str = "unknown") -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=_repo_dir(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=4,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
value = (result.stdout or "").strip()
|
||||
return value or default
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def _device_serial(params: Any) -> str:
|
||||
for key in ("HardwareSerial", "DeviceSerial", "Serial", "CarrotSerial"):
|
||||
value = _param_text(params, key, "")
|
||||
if value:
|
||||
return value
|
||||
for key in ("CARROT_DEVICE_SERIAL", "DEVICE_SERIAL", "SERIAL"):
|
||||
value = os.environ.get(key, "").strip()
|
||||
if value:
|
||||
return value
|
||||
try:
|
||||
getter = getattr(HARDWARE, "get_serial", None) if HARDWARE is not None else None
|
||||
if callable(getter):
|
||||
value = str(getter() or "").strip()
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _diagnostic_metadata(params: Any | None = None) -> dict[str, str]:
|
||||
return {
|
||||
"carName": _param_text(params, "CarName", "none"),
|
||||
"dongleId": _param_text(params, "DongleId", "unknown"),
|
||||
"serial": _device_serial(params),
|
||||
"branch": _git_text(["branch", "--show-current"]),
|
||||
"commit": _git_text(["rev-parse", "--short", "HEAD"]),
|
||||
"commitDate": _git_text(["show", "-s", "--date=format:%Y-%m-%d %H:%M:%S", "--format=%cd", "HEAD"]),
|
||||
}
|
||||
|
||||
|
||||
def vision_diag_discord_webhook_url(params: Any | None = None) -> str:
|
||||
for key in ("CARROT_VISION_DIAG_DISCORD_WEBHOOK_URL", "CARROT_DISCORD_WEBHOOK_URL", "DISCORD_WEBHOOK_URL"):
|
||||
value = os.environ.get(key, "").strip()
|
||||
if value:
|
||||
return value
|
||||
for key in (
|
||||
"CarrotVisionDiagDiscordWebhookUrl",
|
||||
"CarrotVisionDiagDiscordWebhookURL",
|
||||
"CarrotDiscordWebhookUrl",
|
||||
"CarrotDiscordWebhookURL",
|
||||
"DiscordWebhookUrl",
|
||||
"DiscordWebhookURL",
|
||||
):
|
||||
value = _param_text(params, key, "")
|
||||
if value:
|
||||
return value
|
||||
if os.environ.get("CARROT_VISION_DIAG_DISCORD_WEBHOOK_DISABLE", "").strip().lower() in {"1", "true", "yes", "on"}:
|
||||
return ""
|
||||
return _decode_obfuscated(VISION_DIAG_DEFAULT_DISCORD_WEBHOOK, VISION_DIAG_DEFAULT_DISCORD_KEY)
|
||||
|
||||
|
||||
def record_stream_proxy_event(event: dict[str, Any]) -> None:
|
||||
entry = {
|
||||
"ts": time.time(),
|
||||
**event,
|
||||
}
|
||||
with _STREAM_PROXY_HISTORY_LOCK:
|
||||
_STREAM_PROXY_HISTORY.append(entry)
|
||||
|
||||
|
||||
def get_stream_proxy_history() -> list[dict[str, Any]]:
|
||||
with _STREAM_PROXY_HISTORY_LOCK:
|
||||
return list(_STREAM_PROXY_HISTORY)
|
||||
|
||||
|
||||
def _read_text(path: Path, limit: int = 256_000) -> str:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8", errors="replace")[:limit]
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _tail_file(path: Path, lines: int) -> list[str]:
|
||||
text = _read_text(path)
|
||||
return [_trim_text(line) for line in text.splitlines()[-lines:]]
|
||||
|
||||
|
||||
def _run(args: list[str], timeout: float = 3.0) -> dict[str, Any]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
args,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
return {
|
||||
"argv": args,
|
||||
"returncode": proc.returncode,
|
||||
"stdout": _trim_text(proc.stdout, 96_000),
|
||||
"stderr": _trim_text(proc.stderr, 24_000),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"argv": args,
|
||||
"error": _trim_text(exc),
|
||||
}
|
||||
|
||||
|
||||
def _pid_cmdline(pid: int) -> str:
|
||||
try:
|
||||
return Path(f"/proc/{pid}/cmdline").read_bytes().decode(errors="replace")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _find_processes() -> dict[str, list[dict[str, Any]]]:
|
||||
result: dict[str, list[dict[str, Any]]] = {name: [] for name in _PROCESS_MATCHES}
|
||||
proc_root = Path("/proc")
|
||||
if not proc_root.exists():
|
||||
return result
|
||||
|
||||
for proc_path in proc_root.glob("[0-9]*"):
|
||||
try:
|
||||
pid = int(proc_path.name)
|
||||
except ValueError:
|
||||
continue
|
||||
raw_cmdline = _pid_cmdline(pid)
|
||||
if not raw_cmdline:
|
||||
continue
|
||||
for name, match in _PROCESS_MATCHES.items():
|
||||
if match in raw_cmdline:
|
||||
result[name].append(_process_snapshot(pid, raw_cmdline))
|
||||
return result
|
||||
|
||||
|
||||
def _process_snapshot(pid: int, raw_cmdline: str) -> dict[str, Any]:
|
||||
status: dict[str, str] = {}
|
||||
for line in _read_text(Path(f"/proc/{pid}/status"), limit=32_000).splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
if key in {"Name", "State", "Pid", "PPid", "Tgid", "Threads", "VmRSS", "VmSize"}:
|
||||
status[key] = value.strip()
|
||||
|
||||
stat = _read_text(Path(f"/proc/{pid}/stat"), limit=8_000).split()
|
||||
fd_count = None
|
||||
try:
|
||||
fd_count = sum(1 for _ in Path(f"/proc/{pid}/fd").iterdir())
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"pid": pid,
|
||||
"cmdline": _trim_text(raw_cmdline.replace("\x00", " ").strip()),
|
||||
"status": status,
|
||||
"cpu_user_ticks": stat[13] if len(stat) > 14 else None,
|
||||
"cpu_system_ticks": stat[14] if len(stat) > 15 else None,
|
||||
"fd_count": fd_count,
|
||||
}
|
||||
|
||||
|
||||
def _params_snapshot() -> dict[str, Any]:
|
||||
if not HAS_PARAMS:
|
||||
return {"available": False}
|
||||
params = Params()
|
||||
result: dict[str, Any] = {"available": True}
|
||||
for name in ("IsOffroad", "IsTakingSnapshot", "CarParamsPersistent"):
|
||||
try:
|
||||
if name == "CarParamsPersistent":
|
||||
raw = params.get(name)
|
||||
result[name] = {"present": bool(raw), "bytes": len(raw or b"")}
|
||||
else:
|
||||
result[name] = params.get_bool(name)
|
||||
except Exception as exc:
|
||||
result[name] = {"error": _trim_text(exc)}
|
||||
return result
|
||||
|
||||
|
||||
def _vipc_snapshot() -> dict[str, Any]:
|
||||
try:
|
||||
from msgq.visionipc import VisionIpcClient
|
||||
streams = sorted(int(stream) for stream in VisionIpcClient.available_streams("camerad", block=False))
|
||||
return {"available": True, "camerad_streams": streams}
|
||||
except Exception as exc:
|
||||
return {"available": False, "error": _trim_text(exc)}
|
||||
|
||||
|
||||
def _port_open(port: int) -> bool:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.25):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _proc_net_snapshot() -> dict[str, list[str]]:
|
||||
result: dict[str, list[str]] = {}
|
||||
for name in ("tcp", "tcp6", "udp", "udp6"):
|
||||
lines = _read_text(Path(f"/proc/net/{name}"), limit=256_000).splitlines()
|
||||
result[name] = [_trim_text(line) for line in lines[:PROC_NET_LINE_LIMIT]]
|
||||
return result
|
||||
|
||||
|
||||
def _journal_snapshot() -> dict[str, Any]:
|
||||
raw = _run(["journalctl", "-b", "--no-pager", "-n", "900", "-o", "short-iso"], timeout=4.0)
|
||||
lines = str(raw.get("stdout") or "").splitlines()
|
||||
filtered = [
|
||||
_trim_text(line)
|
||||
for line in lines
|
||||
if any(term in line.lower() for term in _JOURNAL_TERMS)
|
||||
]
|
||||
return {
|
||||
"command": raw.get("argv"),
|
||||
"returncode": raw.get("returncode"),
|
||||
"error": raw.get("error", ""),
|
||||
"lines": filtered[-JOURNAL_LINE_LIMIT:],
|
||||
}
|
||||
|
||||
|
||||
def _upload_filename(filename: str | None = None) -> str:
|
||||
name = str(filename or "").strip().replace("\\", "_").replace("/", "_")
|
||||
if name and name.endswith(".txt"):
|
||||
return name[:180]
|
||||
stamp = time.strftime("%Y-%m-%dT%H-%M-%SZ", time.gmtime())
|
||||
return f"carrot_vision_diag_{stamp}.txt"
|
||||
|
||||
|
||||
def _limit_upload_text(text: str) -> str:
|
||||
raw = text.encode("utf-8", errors="replace")
|
||||
if len(raw) <= DISCORD_FILE_MAX_BYTES:
|
||||
return text
|
||||
keep_each = max(256_000, (DISCORD_FILE_MAX_BYTES // 2) - 128_000)
|
||||
head = raw[:keep_each].decode("utf-8", errors="replace")
|
||||
tail = raw[-keep_each:].decode("utf-8", errors="replace")
|
||||
return "\n".join([
|
||||
head,
|
||||
"",
|
||||
f"# ===== DISCORD UPLOAD TRUNCATED original_bytes={len(raw)} limit={DISCORD_FILE_MAX_BYTES} =====",
|
||||
"",
|
||||
tail,
|
||||
])
|
||||
|
||||
|
||||
def _console_upload_filename(filename: str | None, diag_filename: str) -> str:
|
||||
name = str(filename or "").strip().replace("\\", "_").replace("/", "_")
|
||||
if name and name.endswith(".txt"):
|
||||
return name[:180]
|
||||
if diag_filename.endswith(".txt"):
|
||||
return f"{diag_filename[:-4]}_console.txt"
|
||||
return f"{diag_filename}_console.txt"
|
||||
|
||||
|
||||
def _discord_upload_content(
|
||||
snapshot: dict[str, Any],
|
||||
meta: dict[str, str],
|
||||
filename: str,
|
||||
text_bytes: int,
|
||||
console_filename: str = "",
|
||||
console_bytes: int = 0,
|
||||
) -> str:
|
||||
status = snapshot.get("vision_test", {}).get("status", {}) if isinstance(snapshot, dict) else {}
|
||||
ports = snapshot.get("ports", {}) if isinstance(snapshot, dict) else {}
|
||||
vipc = snapshot.get("vipc", {}) if isinstance(snapshot, dict) else {}
|
||||
stream_history = snapshot.get("stream_proxy_history", []) if isinstance(snapshot, dict) else []
|
||||
commit = str(meta.get("commit") or "").strip()
|
||||
commit_date = meta.get("commitDate") or "unknown"
|
||||
commit_text = (
|
||||
f"[{commit}](https://github.com/ajouatom/openpilot/commit/{commit})"
|
||||
if commit and commit != "unknown"
|
||||
else "unknown"
|
||||
)
|
||||
uploaded_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
car_name = meta.get("carName") or "none"
|
||||
dongle_id = meta.get("dongleId") or "unknown"
|
||||
upload_path = f"vision_diag/{car_name} {dongle_id}/".strip()
|
||||
lines = [
|
||||
"# Carrot Vision Diagnostic",
|
||||
"### Upload",
|
||||
f"- Time: {uploaded_at}",
|
||||
f"- Path: {upload_path}",
|
||||
"### Device",
|
||||
f"- Car name: {car_name}",
|
||||
f"- DongleId: {dongle_id}",
|
||||
f"- Serial: {meta.get('serial') or 'unknown'}",
|
||||
f"- Branch: {meta.get('branch') or 'unknown'}",
|
||||
f"- Commit: {commit_text} ({commit_date})",
|
||||
"### Result",
|
||||
f"- Vision test: {status.get('status') or 'unknown'}",
|
||||
f"- VIPC streams: {','.join(map(str, vipc.get('camerad_streams') or [])) or 'none'}",
|
||||
f"- WebRTC port 5001: {'open' if ports.get('webrtcd_5001_open') else 'closed'}",
|
||||
f"- Stream requests: {len(stream_history) if isinstance(stream_history, list) else 0}",
|
||||
f"- File: {filename} ({text_bytes} bytes)",
|
||||
]
|
||||
if console_filename:
|
||||
lines.append(f"- Console: {console_filename} ({console_bytes} bytes)")
|
||||
return "\n".join(lines)[:1900]
|
||||
|
||||
|
||||
async def upload_diagnostic_bundle_to_discord(
|
||||
*,
|
||||
bundle_text: str,
|
||||
filename: str | None = None,
|
||||
console_text: str = "",
|
||||
console_filename: str | None = None,
|
||||
source: str = "web",
|
||||
) -> dict[str, Any]:
|
||||
params = Params() if HAS_PARAMS else None
|
||||
url = vision_diag_discord_webhook_url(params)
|
||||
if not url:
|
||||
return {"configured": False, "ok": False, "skipped": True}
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return {"configured": True, "ok": False, "error": "invalid webhook url"}
|
||||
|
||||
snapshot = await asyncio.to_thread(get_server_diagnostic_snapshot)
|
||||
meta = _diagnostic_metadata(params)
|
||||
upload_snapshot_title = "COMMA SERVER SNAPSHOT AT DISCORD UPLOAD"
|
||||
upload_text = _limit_upload_text("\n".join([
|
||||
str(bundle_text or ""),
|
||||
"",
|
||||
f"# ===== {upload_snapshot_title} =====",
|
||||
json.dumps(snapshot, ensure_ascii=False, indent=2),
|
||||
f"# ===== END {upload_snapshot_title} =====",
|
||||
]))
|
||||
upload_name = _upload_filename(filename)
|
||||
upload_bytes = upload_text.encode("utf-8", errors="replace")
|
||||
console_upload_name = ""
|
||||
console_upload_bytes = b""
|
||||
if str(console_text or "").strip():
|
||||
console_upload_name = _console_upload_filename(console_filename, upload_name)
|
||||
console_upload_text = _limit_upload_text(str(console_text or ""))
|
||||
console_upload_bytes = console_upload_text.encode("utf-8", errors="replace")
|
||||
payload = {
|
||||
"username": "Carrot Vision",
|
||||
"content": _discord_upload_content(
|
||||
snapshot,
|
||||
meta,
|
||||
upload_name,
|
||||
len(upload_bytes),
|
||||
console_upload_name,
|
||||
len(console_upload_bytes),
|
||||
),
|
||||
"allowed_mentions": {"parse": []},
|
||||
"flags": 4,
|
||||
}
|
||||
|
||||
form = FormData()
|
||||
form.add_field("payload_json", json.dumps(payload, ensure_ascii=False), content_type="application/json")
|
||||
form.add_field("files[0]", upload_bytes, filename=upload_name, content_type="text/plain; charset=utf-8")
|
||||
if console_upload_bytes:
|
||||
form.add_field("files[1]", console_upload_bytes, filename=console_upload_name, content_type="text/plain; charset=utf-8")
|
||||
|
||||
try:
|
||||
timeout = ClientTimeout(total=20)
|
||||
async with ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, data=form) as resp:
|
||||
text = await resp.text()
|
||||
if 200 <= resp.status < 300:
|
||||
return {
|
||||
"configured": True,
|
||||
"ok": True,
|
||||
"status": resp.status,
|
||||
"filename": upload_name,
|
||||
"bytes": len(upload_bytes),
|
||||
"console_filename": console_upload_name or None,
|
||||
"console_bytes": len(console_upload_bytes) if console_upload_bytes else 0,
|
||||
"source": source,
|
||||
}
|
||||
return {
|
||||
"configured": True,
|
||||
"ok": False,
|
||||
"status": resp.status,
|
||||
"filename": upload_name,
|
||||
"bytes": len(upload_bytes),
|
||||
"console_filename": console_upload_name or None,
|
||||
"console_bytes": len(console_upload_bytes) if console_upload_bytes else 0,
|
||||
"error": text[:1000],
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"configured": True,
|
||||
"ok": False,
|
||||
"filename": upload_name,
|
||||
"bytes": len(upload_bytes),
|
||||
"console_filename": console_upload_name or None,
|
||||
"console_bytes": len(console_upload_bytes) if console_upload_bytes else 0,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def _socket_snapshot(processes: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
|
||||
relevant_pids = {
|
||||
str(proc["pid"])
|
||||
for entries in processes.values()
|
||||
for proc in entries
|
||||
if proc.get("pid")
|
||||
}
|
||||
raw = _run(["ss", "-tunap"], timeout=3.0)
|
||||
lines = str(raw.get("stdout") or "").splitlines()
|
||||
filtered = [
|
||||
_trim_text(line)
|
||||
for line in lines
|
||||
if ":5001" in line or any(f"pid={pid}," in line for pid in relevant_pids)
|
||||
]
|
||||
return {
|
||||
"command": raw.get("argv"),
|
||||
"returncode": raw.get("returncode"),
|
||||
"error": raw.get("error", ""),
|
||||
"lines": filtered[-PROC_NET_LINE_LIMIT:],
|
||||
}
|
||||
|
||||
|
||||
def get_server_diagnostic_snapshot() -> dict[str, Any]:
|
||||
processes = _find_processes()
|
||||
try:
|
||||
vision_test_status: dict[str, Any] = get_vision_test_status()
|
||||
except Exception as exc:
|
||||
vision_test_status = {"error": _trim_text(exc)}
|
||||
|
||||
return {
|
||||
"captured_at": time.time(),
|
||||
"captured_at_iso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"system": {
|
||||
"hostname": platform.node(),
|
||||
"platform": platform.platform(),
|
||||
"python": platform.python_version(),
|
||||
"uptime_seconds": _trim_text(_read_text(Path("/proc/uptime"), limit=128)),
|
||||
"loadavg": _trim_text(_read_text(Path("/proc/loadavg"), limit=128)),
|
||||
},
|
||||
"params": _params_snapshot(),
|
||||
"vipc": _vipc_snapshot(),
|
||||
"ports": {
|
||||
"webrtcd_5001_open": _port_open(5001),
|
||||
},
|
||||
"processes": processes,
|
||||
"stream_proxy_history": get_stream_proxy_history(),
|
||||
"vision_test": {
|
||||
"status": vision_test_status,
|
||||
"log_path": str(VISION_TEST_LOG_PATH),
|
||||
"log_tail": _tail_file(VISION_TEST_LOG_PATH, VISION_TEST_LOG_LINE_LIMIT),
|
||||
},
|
||||
"sockets": _socket_snapshot(processes),
|
||||
"proc_net": _proc_net_snapshot(),
|
||||
"journal_tail": _journal_snapshot(),
|
||||
}
|
||||
412
selfdrive/carrot/server/services/vision_test.py
Normal file
412
selfdrive/carrot/server/services/vision_test.py
Normal file
@@ -0,0 +1,412 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
REPO_ROOT = Path("/data/openpilot")
|
||||
STATE_PATH = Path("/tmp/carrot-vision-test-state.json")
|
||||
LOG_PATH = Path("/tmp/carrot-vision-test.log")
|
||||
TIMEOUT_SECONDS = 10 * 60
|
||||
RUNNER_MODULE = "selfdrive.carrot.server.services.vision_test"
|
||||
|
||||
_PROCESS_SPECS = {
|
||||
"camerad": {
|
||||
"cmd": [str(REPO_ROOT / "system/camerad/camerad")],
|
||||
"cwd": str(REPO_ROOT),
|
||||
"match": "system/camerad/camerad",
|
||||
},
|
||||
"stream_encoderd": {
|
||||
"cmd": [str(REPO_ROOT / "system/loggerd/encoderd"), "--stream"],
|
||||
"cwd": str(REPO_ROOT),
|
||||
"match": "system/loggerd/encoderd\x00--stream",
|
||||
},
|
||||
"webrtcd": {
|
||||
"cmd": [sys.executable, "-m", "system.webrtc.webrtcd"],
|
||||
"cwd": str(REPO_ROOT),
|
||||
"match": "system.webrtc.webrtcd",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _params():
|
||||
from openpilot.common.params import Params
|
||||
return Params()
|
||||
|
||||
|
||||
def _set_snapshot_active(active: bool) -> None:
|
||||
params = _params()
|
||||
params.put_bool("IsTakingSnapshot", active)
|
||||
try:
|
||||
from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
|
||||
set_offroad_alert("Offroad_IsTakingSnapshot", active)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _write_state(state: dict[str, Any]) -> None:
|
||||
state["updated_at"] = time.time()
|
||||
temp_path = STATE_PATH.with_suffix(".tmp")
|
||||
temp_path.write_text(json.dumps(state, sort_keys=True), encoding="utf-8")
|
||||
temp_path.replace(STATE_PATH)
|
||||
|
||||
|
||||
def _read_state() -> dict[str, Any]:
|
||||
try:
|
||||
raw = json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _pid_cmdline(pid: int) -> str:
|
||||
try:
|
||||
return Path(f"/proc/{int(pid)}/cmdline").read_bytes().decode(errors="replace")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _pid_alive(pid: int, match: str = "") -> bool:
|
||||
if pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
return not match or match in _pid_cmdline(pid)
|
||||
|
||||
|
||||
def _find_matching_pids(match: str) -> list[int]:
|
||||
matches = []
|
||||
for proc_path in Path("/proc").glob("[0-9]*"):
|
||||
try:
|
||||
pid = int(proc_path.name)
|
||||
except ValueError:
|
||||
continue
|
||||
if match in _pid_cmdline(pid):
|
||||
matches.append(pid)
|
||||
return sorted(matches)
|
||||
|
||||
|
||||
def _tail_log(lines: int) -> list[str]:
|
||||
try:
|
||||
return LOG_PATH.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _vipc_streams() -> list[int]:
|
||||
try:
|
||||
from msgq.visionipc import VisionIpcClient
|
||||
return sorted(int(stream) for stream in VisionIpcClient.available_streams("camerad", block=False))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _port_open(port: int) -> bool:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _children_status(state: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
children = state.get("children") if isinstance(state.get("children"), dict) else {}
|
||||
result = {}
|
||||
for name, spec in _PROCESS_SPECS.items():
|
||||
pid = int(children.get(name) or 0)
|
||||
result[name] = {"pid": pid, "alive": _pid_alive(pid, str(spec["match"]))}
|
||||
return result
|
||||
|
||||
|
||||
def get_status() -> dict[str, Any]:
|
||||
state = _read_state()
|
||||
runner_pid = int(state.get("runner_pid") or 0)
|
||||
status = str(state.get("status") or "stopped")
|
||||
runner_alive = _pid_alive(runner_pid, RUNNER_MODULE)
|
||||
return {
|
||||
**state,
|
||||
"status": status if runner_alive or status == "error" else "stopped",
|
||||
"runner_pid": runner_pid,
|
||||
"runner_alive": runner_alive,
|
||||
"children": _children_status(state),
|
||||
"vipc_streams": _vipc_streams(),
|
||||
"webrtcd_port_open": _port_open(5001),
|
||||
"log_path": str(LOG_PATH),
|
||||
}
|
||||
|
||||
|
||||
def _format_duration(seconds: float) -> str:
|
||||
seconds = max(0, int(seconds))
|
||||
return f"{seconds // 3600:02d}:{(seconds % 3600) // 60:02d}:{seconds % 60:02d}"
|
||||
|
||||
|
||||
def print_status() -> int:
|
||||
status = get_status()
|
||||
started_at = float(status.get("started_at") or 0)
|
||||
elapsed = _format_duration(time.time() - started_at) if started_at else "00:00:00"
|
||||
print(f"[vision_test] {status['status']} elapsed={elapsed}")
|
||||
print(f" runner pid={status['runner_pid'] or '-'} alive={str(status['runner_alive']).lower()}")
|
||||
for name, child in status["children"].items():
|
||||
print(f" {name:<16} pid={child['pid'] or '-'} alive={str(child['alive']).lower()}")
|
||||
streams = ", ".join(str(stream) for stream in status["vipc_streams"]) or "-"
|
||||
print(f" VIPC streams {streams}")
|
||||
print(f" webrtcd port 5001 open={str(status['webrtcd_port_open']).lower()}")
|
||||
print(f" log {status['log_path']}")
|
||||
error = str(status.get("error") or "").strip()
|
||||
if error:
|
||||
print(f" error {error}")
|
||||
return 0 if status["runner_alive"] else 1
|
||||
|
||||
|
||||
def _terminate_pid(pid: int, match: str, timeout: float = 3.0) -> None:
|
||||
if not _pid_alive(pid, match):
|
||||
return
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not _pid_alive(pid, match):
|
||||
return
|
||||
time.sleep(0.1)
|
||||
|
||||
if _pid_alive(pid, match):
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _cleanup_owned_processes(state: dict[str, Any]) -> None:
|
||||
children = state.get("children") if isinstance(state.get("children"), dict) else {}
|
||||
for name in reversed(tuple(_PROCESS_SPECS)):
|
||||
spec = _PROCESS_SPECS[name]
|
||||
_terminate_pid(int(children.get(name) or 0), str(spec["match"]))
|
||||
|
||||
|
||||
def start_test() -> int:
|
||||
status = get_status()
|
||||
if status["runner_alive"]:
|
||||
print("[vision_test] already running")
|
||||
return print_status()
|
||||
|
||||
if not _params().get_bool("IsOffroad"):
|
||||
print("[vision_test] refused: device is not offroad", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
for name, spec in _PROCESS_SPECS.items():
|
||||
pids = _find_matching_pids(str(spec["match"]))
|
||||
if pids:
|
||||
print(f"[vision_test] refused: {name} already running pid={','.join(map(str, pids))}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
LOG_PATH.write_text("", encoding="utf-8")
|
||||
with LOG_PATH.open("a", encoding="utf-8") as log:
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", RUNNER_MODULE, "_run"],
|
||||
cwd=str(REPO_ROOT),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
print(f"[vision_test] starting runner pid={proc.pid}")
|
||||
deadline = time.monotonic() + 12.0
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(0.25)
|
||||
status = get_status()
|
||||
if status.get("status") == "running":
|
||||
print("[vision_test] ready")
|
||||
return print_status()
|
||||
if status.get("status") == "error":
|
||||
print(f"[vision_test] failed: {status.get('error') or 'unknown error'}", file=sys.stderr)
|
||||
return 1
|
||||
if proc.poll() is not None:
|
||||
print("[vision_test] runner exited during startup", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("[vision_test] startup is still in progress; run :vision_test status", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def stop_test() -> int:
|
||||
state = _read_state()
|
||||
runner_pid = int(state.get("runner_pid") or 0)
|
||||
if _pid_alive(runner_pid, RUNNER_MODULE):
|
||||
print(f"[vision_test] stopping runner pid={runner_pid}")
|
||||
_terminate_pid(runner_pid, RUNNER_MODULE, timeout=15.0)
|
||||
elif state:
|
||||
print("[vision_test] runner is not active; cleaning stale state")
|
||||
_cleanup_owned_processes(state)
|
||||
_set_snapshot_active(False)
|
||||
else:
|
||||
print("[vision_test] runner is not active")
|
||||
|
||||
state = _read_state()
|
||||
if state:
|
||||
state.update({"status": "stopped", "children": {}, "error": ""})
|
||||
_write_state(state)
|
||||
print("[vision_test] stopped")
|
||||
return 0
|
||||
|
||||
|
||||
def print_logs(lines: int) -> int:
|
||||
print(f"[vision_test] log={LOG_PATH}")
|
||||
for line in _tail_log(lines):
|
||||
print(line)
|
||||
return 0
|
||||
|
||||
|
||||
def _wait_for_vipc(timeout: float) -> list[int]:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
streams = _vipc_streams()
|
||||
if streams:
|
||||
return streams
|
||||
time.sleep(0.25)
|
||||
return []
|
||||
|
||||
|
||||
def _wait_for_port(port: int, timeout: float) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if _port_open(port):
|
||||
return True
|
||||
time.sleep(0.25)
|
||||
return False
|
||||
|
||||
|
||||
def _run_test() -> int:
|
||||
stopped = False
|
||||
children: dict[str, subprocess.Popen] = {}
|
||||
state: dict[str, Any] = {
|
||||
"status": "starting",
|
||||
"runner_pid": os.getpid(),
|
||||
"started_at": time.time(),
|
||||
"children": {},
|
||||
"error": "",
|
||||
}
|
||||
|
||||
def request_stop(_signum=None, _frame=None) -> None:
|
||||
nonlocal stopped
|
||||
stopped = True
|
||||
|
||||
signal.signal(signal.SIGTERM, request_stop)
|
||||
signal.signal(signal.SIGINT, request_stop)
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(f"[vision_test] {message}", flush=True)
|
||||
|
||||
def start_child(name: str) -> None:
|
||||
spec = _PROCESS_SPECS[name]
|
||||
proc = subprocess.Popen(
|
||||
list(spec["cmd"]),
|
||||
cwd=str(spec["cwd"]),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=sys.stdout,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
children[name] = proc
|
||||
state["children"][name] = proc.pid
|
||||
_write_state(state)
|
||||
log(f"{name} started pid={proc.pid}")
|
||||
|
||||
_write_state(state)
|
||||
try:
|
||||
if not _params().get_bool("IsOffroad"):
|
||||
raise RuntimeError("device is not offroad")
|
||||
|
||||
_set_snapshot_active(True)
|
||||
log("IsTakingSnapshot enabled")
|
||||
time.sleep(2.0)
|
||||
|
||||
start_child("camerad")
|
||||
streams = _wait_for_vipc(8.0)
|
||||
if not streams:
|
||||
raise RuntimeError("camerad did not publish VisionIPC streams")
|
||||
log(f"VIPC streams ready: {','.join(map(str, streams))}")
|
||||
|
||||
start_child("stream_encoderd")
|
||||
start_child("webrtcd")
|
||||
if not _wait_for_port(5001, 8.0):
|
||||
raise RuntimeError("webrtcd did not open port 5001")
|
||||
log("webrtcd port ready: 5001")
|
||||
|
||||
state["status"] = "running"
|
||||
_write_state(state)
|
||||
log(f"running timeout={TIMEOUT_SECONDS}s")
|
||||
|
||||
deadline = time.monotonic() + TIMEOUT_SECONDS
|
||||
while not stopped and time.monotonic() < deadline:
|
||||
if not _params().get_bool("IsOffroad"):
|
||||
log("offroad ended; stopping")
|
||||
break
|
||||
for name, proc in children.items():
|
||||
if proc.poll() is not None:
|
||||
raise RuntimeError(f"{name} exited code={proc.returncode}")
|
||||
time.sleep(0.5)
|
||||
if time.monotonic() >= deadline:
|
||||
log("timeout reached; stopping")
|
||||
except Exception as exc:
|
||||
state["status"] = "error"
|
||||
state["error"] = str(exc)
|
||||
_write_state(state)
|
||||
log(f"error: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
for name in reversed(tuple(children)):
|
||||
spec = _PROCESS_SPECS[name]
|
||||
_terminate_pid(children[name].pid, str(spec["match"]))
|
||||
log(f"{name} stopped")
|
||||
_set_snapshot_active(False)
|
||||
state["children"] = {}
|
||||
if state["status"] != "error":
|
||||
state["status"] = "stopped"
|
||||
_write_state(state)
|
||||
log("IsTakingSnapshot cleared")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def run_command(args: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog=":vision_test", add_help=False)
|
||||
parser.add_argument("action", nargs="?", default="start", choices=("start", "status", "logs", "stop"))
|
||||
parser.add_argument("--lines", type=int, default=80)
|
||||
try:
|
||||
options = parser.parse_args(args)
|
||||
except SystemExit:
|
||||
return 2
|
||||
|
||||
if options.action == "start":
|
||||
return start_test()
|
||||
if options.action == "status":
|
||||
return print_status()
|
||||
if options.action == "logs":
|
||||
return print_logs(max(1, min(500, options.lines)))
|
||||
return stop_test()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("action", choices=("_run",))
|
||||
options = parser.parse_args(argv)
|
||||
if options.action == "_run":
|
||||
return _run_test()
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
58
selfdrive/carrot/server/terminal_commands/README.md
Normal file
58
selfdrive/carrot/server/terminal_commands/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Web Terminal Meta Commands
|
||||
|
||||
The Carrot web terminal sends normal input to its tmux shell unchanged. Input
|
||||
starting with `:` is reserved for small web-terminal-only helpers.
|
||||
|
||||
```text
|
||||
:help
|
||||
:help vision_test
|
||||
:vision_test status
|
||||
```
|
||||
|
||||
## Adding A Command
|
||||
|
||||
1. Add `custom_commands/example.py`.
|
||||
2. Register one handler with `register_command`.
|
||||
3. Import the new module in `custom_commands/__init__.py`.
|
||||
4. Print normal progress and result text from the handler. Output appears in
|
||||
the existing web terminal because the command runs through the tmux shell.
|
||||
|
||||
```python
|
||||
from ..registry import register_command
|
||||
|
||||
|
||||
@register_command(
|
||||
name="example",
|
||||
summary="Describe the helper in one line.",
|
||||
usage=":example [status]",
|
||||
)
|
||||
def run(args: list[str]) -> int:
|
||||
print("[example] ready")
|
||||
return 0
|
||||
```
|
||||
|
||||
Use Python argument lists for subprocess calls. Do not pass user input to a
|
||||
shell. Keep reusable process or state management in `server/services/`; the
|
||||
command handler should remain a small text interface.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
terminal_commands/
|
||||
bridge.py # Converts : input into a fixed CLI call.
|
||||
cli.py # Parses one meta-command line.
|
||||
registry.py # Stores command metadata and handlers.
|
||||
custom_commands/ # Actual user-editable custom command files.
|
||||
help.py
|
||||
vision_test.py
|
||||
```
|
||||
|
||||
## Bridge Flow
|
||||
|
||||
1. `features/terminal.py` receives terminal input over the existing websocket.
|
||||
2. `bridge.py` converts `:` input into a fixed Python CLI invocation.
|
||||
3. `cli.py` parses the command and invokes a registered handler.
|
||||
4. stdout and stderr are rendered by the existing tmux screen capture loop.
|
||||
|
||||
This keeps ordinary shell behavior intact and makes each helper independently
|
||||
editable.
|
||||
3
selfdrive/carrot/server/terminal_commands/__init__.py
Normal file
3
selfdrive/carrot/server/terminal_commands/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .bridge import META_COMMAND_PREFIX, translate_meta_command
|
||||
|
||||
__all__ = ["META_COMMAND_PREFIX", "translate_meta_command"]
|
||||
17
selfdrive/carrot/server/terminal_commands/bridge.py
Normal file
17
selfdrive/carrot/server/terminal_commands/bridge.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
|
||||
|
||||
META_COMMAND_PREFIX = ":"
|
||||
_CLI_MODULE = "selfdrive.carrot.server.terminal_commands.cli"
|
||||
|
||||
|
||||
def translate_meta_command(line: str) -> str | None:
|
||||
"""Translate a web-terminal-only meta command into the fixed CLI bridge."""
|
||||
stripped = str(line or "").strip()
|
||||
if not stripped.startswith(META_COMMAND_PREFIX):
|
||||
return None
|
||||
|
||||
command_line = stripped[len(META_COMMAND_PREFIX):].strip() or "help"
|
||||
return shlex.join(["python3", "-m", _CLI_MODULE, "--line", command_line])
|
||||
46
selfdrive/carrot/server/terminal_commands/cli.py
Normal file
46
selfdrive/carrot/server/terminal_commands/cli.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
from .custom_commands import load_commands
|
||||
from .registry import get_command
|
||||
|
||||
|
||||
def _parse_command_line(command_line: str) -> list[str] | None:
|
||||
try:
|
||||
return shlex.split(command_line)
|
||||
except ValueError as exc:
|
||||
print(f"[terminal] parse error: {exc}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument("--line", default="help")
|
||||
options = parser.parse_args(argv)
|
||||
|
||||
load_commands()
|
||||
parts = _parse_command_line(options.line)
|
||||
if parts is None:
|
||||
return 2
|
||||
if not parts:
|
||||
parts = ["help"]
|
||||
|
||||
command = get_command(parts[0])
|
||||
if command is None:
|
||||
print(f"[terminal] unknown command: {parts[0]}", file=sys.stderr)
|
||||
print("[terminal] run :help to list available commands", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
result = command.handler(parts[1:])
|
||||
except Exception as exc:
|
||||
print(f"[terminal] {command.name} failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return int(result or 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
_loaded = False
|
||||
|
||||
|
||||
def load_commands() -> None:
|
||||
global _loaded
|
||||
if _loaded:
|
||||
return
|
||||
|
||||
from . import help as _help
|
||||
from . import vision_test as _vision_test
|
||||
|
||||
del _help, _vision_test
|
||||
_loaded = True
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..bridge import META_COMMAND_PREFIX
|
||||
from ..registry import get_command, iter_commands, register_command
|
||||
|
||||
|
||||
@register_command(
|
||||
name="help",
|
||||
summary="List web terminal meta commands or show command usage.",
|
||||
usage=":help [command]",
|
||||
)
|
||||
def run(args: list[str]) -> int:
|
||||
if args:
|
||||
command = get_command(args[0])
|
||||
if command is None:
|
||||
print(f"[terminal] unknown command: {args[0]}")
|
||||
return 2
|
||||
print(f"{command.usage}\n {command.summary}")
|
||||
return 0
|
||||
|
||||
print("Web terminal meta commands")
|
||||
for command in iter_commands():
|
||||
print(f" {META_COMMAND_PREFIX}{command.name:<18} {command.summary}")
|
||||
print("\nRun :help <command> for usage. Other input is sent to the shell unchanged.")
|
||||
return 0
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...services import vision_test
|
||||
from ..registry import register_command
|
||||
|
||||
|
||||
@register_command(
|
||||
name="vision_test",
|
||||
summary="Run an offroad Carrot Vision camera and WebRTC test.",
|
||||
usage=":vision_test [start|status|logs|stop] [--lines N]",
|
||||
)
|
||||
def run(args: list[str]) -> int:
|
||||
return vision_test.run_command(args)
|
||||
47
selfdrive/carrot/server/terminal_commands/registry.py
Normal file
47
selfdrive/carrot/server/terminal_commands/registry.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Iterable
|
||||
|
||||
|
||||
CommandHandler = Callable[[list[str]], int | None]
|
||||
_COMMAND_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TerminalCommand:
|
||||
name: str
|
||||
summary: str
|
||||
usage: str
|
||||
handler: CommandHandler
|
||||
|
||||
|
||||
_commands: dict[str, TerminalCommand] = {}
|
||||
|
||||
|
||||
def register_command(*, name: str, summary: str, usage: str) -> Callable[[CommandHandler], CommandHandler]:
|
||||
normalized = str(name or "").strip().lower()
|
||||
if not _COMMAND_NAME_RE.fullmatch(normalized):
|
||||
raise ValueError(f"invalid terminal command name: {name!r}")
|
||||
|
||||
def decorator(handler: CommandHandler) -> CommandHandler:
|
||||
if normalized in _commands:
|
||||
raise ValueError(f"duplicate terminal command: {normalized}")
|
||||
_commands[normalized] = TerminalCommand(
|
||||
name=normalized,
|
||||
summary=str(summary or "").strip(),
|
||||
usage=str(usage or "").strip(),
|
||||
handler=handler,
|
||||
)
|
||||
return handler
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_command(name: str) -> TerminalCommand | None:
|
||||
return _commands.get(str(name or "").strip().lower())
|
||||
|
||||
|
||||
def iter_commands() -> Iterable[TerminalCommand]:
|
||||
return sorted(_commands.values(), key=lambda command: command.name)
|
||||
@@ -11,6 +11,21 @@
|
||||
--nav-side-width: 188px;
|
||||
--nav-gap: 12px;
|
||||
--nav-card-height: 88px;
|
||||
--nav-scale: 1;
|
||||
--nav-padding-y: 11px;
|
||||
--nav-main-padding-x: 18px;
|
||||
--nav-side-padding-x: 14px;
|
||||
--nav-side-gap: 14px;
|
||||
--nav-body-gap: 4px;
|
||||
--nav-side-sign-size: 56px;
|
||||
--nav-side-sign-font: 25px;
|
||||
--nav-side-dist-font: 23px;
|
||||
--nav-side-label-font: 18px;
|
||||
--nav-icon-size: 62px;
|
||||
--nav-icon-font: 38px;
|
||||
--nav-dist-font: 33px;
|
||||
--nav-road-font: 21px;
|
||||
--nav-meta-font: 17px;
|
||||
--nav-top: 56px;
|
||||
--nav-main-left: 50%;
|
||||
--nav-side-left: 14px;
|
||||
@@ -72,7 +87,7 @@
|
||||
min-height: var(--nav-card-height);
|
||||
margin: 0;
|
||||
margin-left: var(--nav-main-left);
|
||||
padding: 11px 18px 11px 14px;
|
||||
padding: var(--nav-padding-y) var(--nav-main-padding-x) var(--nav-padding-y) calc(var(--nav-main-padding-x) * 0.78);
|
||||
border-radius: var(--nav-radius);
|
||||
}
|
||||
|
||||
@@ -89,10 +104,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: clamp(11px, 1.0vw, 15px);
|
||||
gap: var(--nav-side-gap);
|
||||
width: var(--nav-side-width);
|
||||
height: var(--nav-card-height);
|
||||
min-height: var(--nav-card-height);
|
||||
padding: 11px 14px;
|
||||
padding: var(--nav-padding-y) var(--nav-side-padding-x);
|
||||
border-radius: var(--nav-radius);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
@@ -112,14 +128,14 @@
|
||||
|
||||
.carrot-nav-hud__side-sign {
|
||||
flex: 0 0 auto;
|
||||
width: clamp(50px, 4.4vw, 60px);
|
||||
height: clamp(50px, 4.4vw, 60px);
|
||||
width: var(--nav-side-sign-size);
|
||||
height: var(--nav-side-sign-size);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: #c00024;
|
||||
font-size: clamp(22px, 1.72vw, 27px);
|
||||
font-size: var(--nav-side-sign-font);
|
||||
font-family: "Arial Black", Impact, var(--font-sans, sans-serif);
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
@@ -139,12 +155,12 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: var(--nav-body-gap);
|
||||
}
|
||||
|
||||
.carrot-nav-hud__side-dist {
|
||||
color: #fff;
|
||||
font-size: clamp(20px, 1.55vw, 25px);
|
||||
font-size: var(--nav-side-dist-font);
|
||||
font-weight: 1000;
|
||||
line-height: 1.05;
|
||||
white-space: nowrap;
|
||||
@@ -159,35 +175,35 @@
|
||||
|
||||
.carrot-nav-hud__side-label {
|
||||
color: rgba(255, 255, 255, 0.98);
|
||||
font-size: clamp(16px, 1.18vw, 20px);
|
||||
font-size: var(--nav-side-label-font);
|
||||
font-weight: 920;
|
||||
line-height: 1.08;
|
||||
line-height: 1.12;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: normal;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 2px 3px rgba(0, 0, 0, 0.82);
|
||||
}
|
||||
|
||||
.carrot-nav-hud[data-layout="compact"] .carrot-nav-hud__side {
|
||||
gap: 9px;
|
||||
padding-inline: 11px;
|
||||
gap: calc(var(--nav-side-gap) * 0.82);
|
||||
padding-inline: calc(var(--nav-side-padding-x) * 0.82);
|
||||
}
|
||||
|
||||
.carrot-nav-hud[data-layout="compact"] .carrot-nav-hud__side-sign {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 21px;
|
||||
width: calc(var(--nav-side-sign-size) * 0.88);
|
||||
height: calc(var(--nav-side-sign-size) * 0.88);
|
||||
font-size: calc(var(--nav-side-sign-font) * 0.88);
|
||||
}
|
||||
|
||||
.carrot-nav-hud[data-layout="compact"] .carrot-nav-hud__side-dist {
|
||||
font-size: 19px;
|
||||
font-size: calc(var(--nav-side-dist-font) * 0.88);
|
||||
}
|
||||
|
||||
.carrot-nav-hud[data-layout="compact"] .carrot-nav-hud__side-label {
|
||||
font-size: 16px;
|
||||
font-size: calc(var(--nav-side-label-font) * 0.90);
|
||||
}
|
||||
|
||||
.carrot-nav-hud__side-countdown {
|
||||
@@ -232,8 +248,8 @@
|
||||
|
||||
.carrot-nav-hud__icon {
|
||||
flex: 0 0 auto;
|
||||
width: clamp(54px, 4.7vw, 66px);
|
||||
height: clamp(54px, 4.7vw, 66px);
|
||||
width: var(--nav-icon-size);
|
||||
height: var(--nav-icon-size);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: clamp(13px, 1.2vw, 17px);
|
||||
@@ -241,7 +257,7 @@
|
||||
linear-gradient(160deg, rgba(255,255,255,.22), rgba(255,255,255,0) 38%),
|
||||
linear-gradient(180deg, #ffb55e 0%, #f0791f 100%);
|
||||
color: #fff;
|
||||
font-size: clamp(32px, 2.7vw, 41px);
|
||||
font-size: var(--nav-icon-font);
|
||||
font-weight: 950;
|
||||
line-height: 1;
|
||||
box-shadow:
|
||||
@@ -281,7 +297,7 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: var(--nav-body-gap);
|
||||
}
|
||||
|
||||
.carrot-nav-hud__dist-row {
|
||||
@@ -293,7 +309,7 @@
|
||||
|
||||
.carrot-nav-hud__dist {
|
||||
color: var(--nav-amber);
|
||||
font-size: clamp(27px, 2.35vw, 36px);
|
||||
font-size: var(--nav-dist-font);
|
||||
font-weight: 950;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.02em;
|
||||
@@ -350,7 +366,7 @@
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: clamp(18px, 1.45vw, 23px);
|
||||
font-size: var(--nav-road-font);
|
||||
font-weight: 900;
|
||||
line-height: 1.18;
|
||||
overflow: hidden;
|
||||
@@ -383,7 +399,7 @@
|
||||
|
||||
.carrot-nav-hud__meta {
|
||||
color: rgba(255, 255, 255, 0.90);
|
||||
font-size: clamp(15px, 1.14vw, 18px);
|
||||
font-size: var(--nav-meta-font);
|
||||
font-weight: 900;
|
||||
line-height: 1.15;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -36,6 +36,238 @@ body[data-page="carrot"] #driveHudCard {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 6px);
|
||||
z-index: 8;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf[hidden],
|
||||
.carrot-rtc-perf-summary[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-glance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
border-radius: 8px;
|
||||
background: rgba(6, 19, 14, 0.88);
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.30);
|
||||
color: rgba(242, 255, 247, 0.96);
|
||||
font: 800 11px/1.1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: -0.18px;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-glance__text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf__dot {
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #48d786;
|
||||
box-shadow: 0 0 9px rgba(72, 215, 134, 0.66);
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-glance[data-tone="degraded"],
|
||||
.carrot-rtc-perf-summary[data-tone="degraded"] {
|
||||
border-color: rgba(255, 207, 80, 0.48);
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-glance[data-tone="degraded"] .carrot-rtc-perf__dot,
|
||||
.carrot-rtc-perf-summary[data-tone="degraded"] .carrot-rtc-perf__dot {
|
||||
background: #ffcf50;
|
||||
box-shadow: 0 0 9px rgba(255, 207, 80, 0.66);
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-glance[data-tone="reconnecting"],
|
||||
.carrot-rtc-perf-summary[data-tone="reconnecting"] {
|
||||
border-color: rgba(255, 157, 66, 0.56);
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-glance[data-tone="reconnecting"] .carrot-rtc-perf__dot,
|
||||
.carrot-rtc-perf-summary[data-tone="reconnecting"] .carrot-rtc-perf__dot {
|
||||
background: #ff9d42;
|
||||
box-shadow: 0 0 9px rgba(255, 157, 66, 0.70);
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-glance[data-tone="stall"],
|
||||
.carrot-rtc-perf-summary[data-tone="stall"] {
|
||||
border-color: rgba(255, 92, 92, 0.62);
|
||||
background: rgba(47, 10, 14, 0.92);
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-glance[data-tone="stall"] .carrot-rtc-perf__dot,
|
||||
.carrot-rtc-perf-summary[data-tone="stall"] .carrot-rtc-perf__dot {
|
||||
background: #ff5c5c;
|
||||
box-shadow: 0 0 10px rgba(255, 92, 92, 0.78);
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-glance[data-tone="offline"],
|
||||
.carrot-rtc-perf-summary[data-tone="offline"] {
|
||||
border-color: rgba(191, 202, 197, 0.26);
|
||||
background: rgba(19, 24, 23, 0.90);
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-glance[data-tone="offline"] .carrot-rtc-perf__dot,
|
||||
.carrot-rtc-perf-summary[data-tone="offline"] .carrot-rtc-perf__dot {
|
||||
background: #aeb9b4;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-summary {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 36px;
|
||||
width: min(520px, calc(100vw - 24px));
|
||||
max-width: calc(100vw - 24px);
|
||||
padding: 11px 12px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 11px;
|
||||
background: rgba(6, 28, 20, 0.94);
|
||||
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.42);
|
||||
color: rgba(245, 255, 249, 0.96);
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(9px);
|
||||
-webkit-backdrop-filter: blur(9px);
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-summary__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-summary__title {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
color: rgba(250, 255, 252, 0.98);
|
||||
font: 900 13px/1.1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-summary__action,
|
||||
.carrot-rtc-perf-summary__close {
|
||||
flex: 0 0 auto;
|
||||
min-width: 32px;
|
||||
height: 24px;
|
||||
padding: 0 7px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.20);
|
||||
border-radius: 7px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(242, 255, 247, 0.92);
|
||||
font: 900 10px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-summary__close {
|
||||
min-width: 24px;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-summary__sub {
|
||||
margin-top: 4px;
|
||||
padding-bottom: 7px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: rgba(193, 225, 207, 0.72);
|
||||
font: 800 9px/1.1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.28px;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-summary__grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(92px, 1fr) auto minmax(92px, 1fr);
|
||||
gap: 7px 10px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
font: 700 11px/1.15 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-summary__grid span {
|
||||
color: rgba(193, 225, 207, 0.70);
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-summary__grid strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: rgba(247, 255, 250, 0.96);
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-summary__grid .carrot-rtc-perf-summary__path-label {
|
||||
align-self: start;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-summary__grid .carrot-rtc-perf-summary__path-value {
|
||||
grid-column: 2 / -1;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.24;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-hold-target {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 7;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--hud-radius, 18px);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-hold-target.is-holding {
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-hold-target::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px solid #48d786;
|
||||
border-radius: 18px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.carrot-rtc-perf-hold-target.is-holding::after {
|
||||
opacity: 0.82;
|
||||
animation: carrot-rtc-perf-hold-progress 0.6s linear;
|
||||
}
|
||||
|
||||
@keyframes carrot-rtc-perf-hold-progress {
|
||||
from { clip-path: inset(0 100% 0 0); }
|
||||
to { clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
|
||||
.carrot-hud-dock {
|
||||
display: none;
|
||||
}
|
||||
@@ -621,6 +853,10 @@ body[data-page="carrot"] #driveHudCard.driveHudCard--loading {
|
||||
}
|
||||
|
||||
@media (orientation: portrait) {
|
||||
.carrot-rtc-perf-summary {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.page--drive {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
@@ -83,8 +83,8 @@
|
||||
<link rel="stylesheet" href="/css/pages/tools/base.css?v=2605-43" />
|
||||
<link rel="stylesheet" href="/css/pages/tools/qr.css?v=2605-40" />
|
||||
<link rel="stylesheet" href="/css/pages/tools/main.css?v=2605-50" />
|
||||
<link rel="stylesheet" href="/css/pages/drive.css?v=2605-18" />
|
||||
<link rel="stylesheet" href="/css/components/nav_hud.css?v=2605-24" />
|
||||
<link rel="stylesheet" href="/css/pages/drive.css?v=2606-04" />
|
||||
<link rel="stylesheet" href="/css/components/nav_hud.css?v=2605-26" />
|
||||
<link rel="stylesheet" href="/css/responsive.css?v=2605-10" />
|
||||
<link rel="stylesheet" href="/css/vendor/plyr.css?v=3.7.8" />
|
||||
</head>
|
||||
@@ -484,6 +484,32 @@
|
||||
</div>
|
||||
|
||||
<div id="driveHudCard">
|
||||
<div id="carrotRtcPerfHud" class="carrot-rtc-perf" hidden>
|
||||
<section id="carrotRtcPerfSummary" class="carrot-rtc-perf-summary" data-tone="offline" hidden aria-label="Carrot Vision live diagnostic summary">
|
||||
<div class="carrot-rtc-perf-summary__head">
|
||||
<span class="carrot-rtc-perf__dot" aria-hidden="true"></span>
|
||||
<strong id="carrotRtcPerfTitle" class="carrot-rtc-perf-summary__title">VISION OFFLINE</strong>
|
||||
<button id="carrotRtcPerfLogBtn" class="carrot-rtc-perf-summary__action" type="button">LOG</button>
|
||||
<button id="carrotRtcPerfCloseBtn" class="carrot-rtc-perf-summary__close" type="button" aria-label="Close Carrot Vision diagnostic summary">x</button>
|
||||
</div>
|
||||
<div class="carrot-rtc-perf-summary__sub">RTC ROAD CAMERA / LIVE DIAGNOSTIC</div>
|
||||
<div class="carrot-rtc-perf-summary__grid">
|
||||
<span>Video</span><strong id="carrotRtcPerfVideo">-</strong>
|
||||
<span>Codec</span><strong id="carrotRtcPerfCodec">-</strong>
|
||||
<span>Bitrate</span><strong id="carrotRtcPerfBitrate">-</strong>
|
||||
<span>RTT</span><strong id="carrotRtcPerfRtt">-</strong>
|
||||
<span>Jitter</span><strong id="carrotRtcPerfJitter">-</strong>
|
||||
<span>Loss</span><strong id="carrotRtcPerfLoss">-</strong>
|
||||
<span>Freeze</span><strong id="carrotRtcPerfFreeze">-</strong>
|
||||
<span class="carrot-rtc-perf-summary__path-label">Path</span><strong id="carrotRtcPerfPath" class="carrot-rtc-perf-summary__path-value">-</strong>
|
||||
</div>
|
||||
</section>
|
||||
<div id="carrotRtcPerfGlance" class="carrot-rtc-perf-glance" data-tone="offline" aria-live="polite">
|
||||
<span class="carrot-rtc-perf__dot" aria-hidden="true"></span>
|
||||
<span id="carrotRtcPerfGlanceText" class="carrot-rtc-perf-glance__text">VISION OFFLINE</span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="carrotRtcPerfHoldTarget" class="carrot-rtc-perf-hold-target" type="button" aria-label="Hold HUD to toggle Carrot Vision diagnostic summary"></button>
|
||||
<div class="hudWrap" id="hudRoot">
|
||||
<div class="hudMetricBar" role="presentation">
|
||||
<div class="hudMetricCell">
|
||||
@@ -686,14 +712,14 @@
|
||||
<script src="/js/pages/terminal.js?v=2604-72"></script>
|
||||
<script src="/js/realtime/raw_capnp.js?v=2604-05"></script>
|
||||
<script src="/js/realtime/vision_state.js?v=2605-01"></script>
|
||||
<script src="/js/realtime/vision_rtc.js?v=2605-01"></script>
|
||||
<script src="/js/realtime/vision_rtc.js?v=2606-06"></script>
|
||||
<script src="/js/realtime/vision_raw.js?v=2605-01"></script>
|
||||
<script src="/js/realtime/app_realtime.js?v=2605-01"></script>
|
||||
<script src="/js/realtime/vision_diag.js?v=2605-01"></script>
|
||||
<script src="/js/realtime/carrot_map.js?v=2605-53"></script>
|
||||
<script src="/js/realtime/nav_hud.js?v=2605-14"></script>
|
||||
<script src="/js/realtime/app_realtime.js?v=2606-04"></script>
|
||||
<script src="/js/realtime/vision_diag.js?v=2606-07"></script>
|
||||
<script src="/js/realtime/carrot_map.js?v=2606-02"></script>
|
||||
<script src="/js/realtime/nav_hud.js?v=2605-17"></script>
|
||||
<script src="/js/pages/vision_background.js?v=2605-01"></script>
|
||||
<script src="/js/realtime/home_drive.js?v=2605-07"></script>
|
||||
<script src="/js/realtime/home_drive.js?v=2606-05"></script>
|
||||
<script src="/js/app.js?v=2604-76"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -47,7 +47,16 @@ window.CarrotLiveRuntimeState = CARROT_LIVE_RUNTIME_STATE;
|
||||
let LIVE_RUNTIME_FETCH_T = null;
|
||||
let LIVE_RUNTIME_FETCH_IN_FLIGHT = null;
|
||||
let LIVE_RUNTIME_POLL_ACTIVE = false;
|
||||
let CARROT_VISION_TEST_FETCH_T = null;
|
||||
let CARROT_VISION_TEST_FETCH_IN_FLIGHT = null;
|
||||
const CARROT_VISION_REQUIRED_LIVE_SERVICES = Object.freeze(["roadCameraState", "modelV2"]);
|
||||
const CARROT_VISION_TEST_REQUIRED_LIVE_SERVICES = Object.freeze(["roadCameraState"]);
|
||||
const CARROT_VISION_TEST_STATE = {
|
||||
active: false,
|
||||
status: "stopped",
|
||||
fetchedAtMs: 0,
|
||||
};
|
||||
window.CarrotVisionTestState = CARROT_VISION_TEST_STATE;
|
||||
var CARROT_VISION_PHASE = window.CarrotVisionPhase;
|
||||
var CARROT_VISION_CONTROL = window.CarrotVisionControl;
|
||||
var CARROT_VISION_STATE = window.CarrotVisionState;
|
||||
@@ -69,6 +78,11 @@ function shouldRunCarrotHudRealtime() {
|
||||
return isCarrotPageActive();
|
||||
}
|
||||
|
||||
function isCarrotVisionTestActive() {
|
||||
return Boolean(window.CarrotVisionTestState?.active);
|
||||
}
|
||||
window.isCarrotVisionTestActive = isCarrotVisionTestActive;
|
||||
|
||||
function getCarrotVisionRealtimeBlockReason() {
|
||||
if (!isCarrotPageActive() || !isCarrotVisionActive()) return "";
|
||||
|
||||
@@ -79,7 +93,10 @@ function getCarrotVisionRealtimeBlockReason() {
|
||||
const serviceAlive = runtime.serviceAlive && typeof runtime.serviceAlive === "object" ? runtime.serviceAlive : null;
|
||||
if (!serviceAlive) return "runtime-unavailable";
|
||||
|
||||
const missing = CARROT_VISION_REQUIRED_LIVE_SERVICES.filter((service) => serviceAlive[service] !== true);
|
||||
const requiredServices = isCarrotVisionTestActive()
|
||||
? CARROT_VISION_TEST_REQUIRED_LIVE_SERVICES
|
||||
: CARROT_VISION_REQUIRED_LIVE_SERVICES;
|
||||
const missing = requiredServices.filter((service) => serviceAlive[service] !== true);
|
||||
return missing.length ? "services-missing" : "";
|
||||
}
|
||||
|
||||
@@ -349,6 +366,54 @@ function startLiveRuntimeStateFetch(force = false, ms = getLiveRuntimePollMs())
|
||||
scheduleLiveRuntimeStateFetch(ms);
|
||||
}
|
||||
|
||||
async function fetchCarrotVisionTestState() {
|
||||
if (CARROT_VISION_TEST_FETCH_IN_FLIGHT) return CARROT_VISION_TEST_FETCH_IN_FLIGHT;
|
||||
|
||||
CARROT_VISION_TEST_FETCH_IN_FLIGHT = (async () => {
|
||||
const wasActive = CARROT_VISION_TEST_STATE.active;
|
||||
try {
|
||||
const response = await fetch("/api/vision_test/status", { cache: "no-store" });
|
||||
const payload = await response.json();
|
||||
if (!payload?.ok) throw new Error(payload?.error || "vision_test status failed");
|
||||
CARROT_VISION_TEST_STATE.active = payload.status === "running" && payload.runner_alive === true;
|
||||
CARROT_VISION_TEST_STATE.status = String(payload.status || "stopped");
|
||||
CARROT_VISION_TEST_STATE.fetchedAtMs = Date.now();
|
||||
window.CarrotVisionTestState = CARROT_VISION_TEST_STATE;
|
||||
} catch {
|
||||
CARROT_VISION_TEST_STATE.active = false;
|
||||
CARROT_VISION_TEST_STATE.status = "unavailable";
|
||||
CARROT_VISION_TEST_STATE.fetchedAtMs = Date.now();
|
||||
} finally {
|
||||
CARROT_VISION_TEST_FETCH_IN_FLIGHT = null;
|
||||
}
|
||||
|
||||
if (wasActive !== CARROT_VISION_TEST_STATE.active) {
|
||||
window.dispatchEvent(new CustomEvent("carrot:visiontestchange", {
|
||||
detail: { ...CARROT_VISION_TEST_STATE },
|
||||
}));
|
||||
syncCarrotVisionAvailability().catch(() => {});
|
||||
syncCarrotRealtimeLifecycle(true);
|
||||
}
|
||||
return CARROT_VISION_TEST_STATE;
|
||||
})();
|
||||
|
||||
return CARROT_VISION_TEST_FETCH_IN_FLIGHT;
|
||||
}
|
||||
|
||||
function scheduleCarrotVisionTestStateFetch(ms = 1500) {
|
||||
if (CARROT_VISION_TEST_FETCH_T) clearTimeout(CARROT_VISION_TEST_FETCH_T);
|
||||
CARROT_VISION_TEST_FETCH_T = setTimeout(async () => {
|
||||
CARROT_VISION_TEST_FETCH_T = null;
|
||||
await fetchCarrotVisionTestState().catch(() => {});
|
||||
scheduleCarrotVisionTestStateFetch(document.hidden ? 4000 : 1500);
|
||||
}, ms);
|
||||
}
|
||||
|
||||
function startCarrotVisionTestStateFetch() {
|
||||
fetchCarrotVisionTestState().catch(() => {});
|
||||
scheduleCarrotVisionTestStateFetch(1500);
|
||||
}
|
||||
|
||||
setCarrotVisionAvailable(false, {
|
||||
disabledMessage: getUIText("vision_unavailable_hint", "Available when DisableDM is 2."),
|
||||
reason: "init",
|
||||
@@ -448,10 +513,14 @@ function updateCarrotVisionAvailabilityUi(available, message = window.CARROT_VIS
|
||||
async function syncCarrotVisionAvailability() {
|
||||
try {
|
||||
const disableDm = await fetchDisableDmValue();
|
||||
const available = disableDm === 2;
|
||||
const available = disableDm === 2 || isCarrotVisionTestActive();
|
||||
updateCarrotVisionAvailabilityUi(available);
|
||||
return available;
|
||||
} catch (e) {
|
||||
if (isCarrotVisionTestActive()) {
|
||||
updateCarrotVisionAvailabilityUi(true);
|
||||
return true;
|
||||
}
|
||||
updateCarrotVisionAvailabilityUi(false, getUIText("disable_dm_check_failed", "Could not check DisableDM status."));
|
||||
return false;
|
||||
}
|
||||
@@ -552,6 +621,7 @@ async function startAll() {
|
||||
console.log("[time_sync] syncing server time on page load");
|
||||
syncServerTimeOnConnect().catch(() => {});
|
||||
rtcInitAuto();
|
||||
startCarrotVisionTestStateFetch();
|
||||
ensureRawDecodeWorker();
|
||||
|
||||
if (window.DrivingHud) {
|
||||
@@ -564,6 +634,70 @@ async function startAll() {
|
||||
let _carrotHudRealtimeActive = false;
|
||||
let _carrotVisionRealtimeActive = false;
|
||||
let _carrotVisionRealtimeBlockReason = "";
|
||||
let _carrotVisionPageReturnConnectT = null;
|
||||
|
||||
function cancelCarrotVisionPageReturnConnect() {
|
||||
if (_carrotVisionPageReturnConnectT != null) {
|
||||
window.clearTimeout(_carrotVisionPageReturnConnectT);
|
||||
_carrotVisionPageReturnConnectT = null;
|
||||
}
|
||||
}
|
||||
|
||||
function recordCarrotVisionLifecycleEvent(event, detail = {}) {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("carrot:visionlifecycle", { detail: { event, ...detail, ts: Date.now() } }));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function canReuseCarrotVisionConnection() {
|
||||
try {
|
||||
return Boolean(window.CarrotVisionRtc?.canResumeWithoutReconnect?.());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleCarrotVisionPageReturnConnect(reason = "page return") {
|
||||
cancelCarrotVisionPageReturnConnect();
|
||||
if (!shouldRunCarrotVisionRealtime()) return;
|
||||
|
||||
if (canReuseCarrotVisionConnection()) {
|
||||
recordCarrotVisionLifecycleEvent("page_return_reconnect_skipped", { reason, reused: true });
|
||||
rtcScheduleResumeIfConnected(reason);
|
||||
startCarrotVisionHealthWatch();
|
||||
startRtcPerfPolling(true);
|
||||
requestCarrotVisionRender();
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldConnect = typeof window.CarrotVisionRtc?.shouldConnect === "function"
|
||||
? window.CarrotVisionRtc.shouldConnect()
|
||||
: rtcShouldConnect();
|
||||
if (!shouldConnect) {
|
||||
recordCarrotVisionLifecycleEvent("page_return_reconnect_skipped", { reason, reused: false, busy: true });
|
||||
rtcScheduleResumeIfConnected(reason);
|
||||
startCarrotVisionHealthWatch();
|
||||
startRtcPerfPolling(true);
|
||||
requestCarrotVisionRender();
|
||||
return;
|
||||
}
|
||||
|
||||
recordCarrotVisionLifecycleEvent("page_return_reconnect_scheduled", { reason });
|
||||
_carrotVisionPageReturnConnectT = window.setTimeout(async () => {
|
||||
_carrotVisionPageReturnConnectT = null;
|
||||
if (!shouldRunCarrotVisionRealtime()) return;
|
||||
recordCarrotVisionLifecycleEvent("page_return_reconnect_start", { reason });
|
||||
rtcCancelRetry();
|
||||
rtcCancelRecovery();
|
||||
rtcDisarmTrackTimeout();
|
||||
rtcDisarmFirstFrameTimeout();
|
||||
stopCarrotVisionHealthWatch();
|
||||
await rawOverlayDisconnectAll().catch(() => {});
|
||||
await rtcDisconnect({ keepVideo: true }).catch(() => {});
|
||||
startCarrotVisionHealthWatch();
|
||||
await rtcConnectOnce({ force: true }).catch(() => {});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function syncCarrotRealtimeLifecycle(forceFetch = false) {
|
||||
const nextHudActive = shouldRunCarrotHudRealtime();
|
||||
@@ -606,6 +740,10 @@ function syncCarrotRealtimeLifecycle(forceFetch = false) {
|
||||
|
||||
if (nextVisionActive) {
|
||||
console.log("[perf] carrot vision realtime -> active");
|
||||
recordCarrotVisionLifecycleEvent("vision_realtime_active", {
|
||||
forceFetch: Boolean(forceFetch),
|
||||
page: document.body?.dataset?.page || "",
|
||||
});
|
||||
startCarrotVisionHealthWatch();
|
||||
setCarrotVisionPhase(CARROT_VISION_PHASE.STARTING, {
|
||||
reason: "vision lifecycle active",
|
||||
@@ -624,9 +762,11 @@ function syncCarrotRealtimeLifecycle(forceFetch = false) {
|
||||
if (rtcShouldConnect()) {
|
||||
rtcCancelRetry();
|
||||
rtcResetFailCount();
|
||||
rtcConnectOnce().catch(() => {});
|
||||
if (forceFetch) scheduleCarrotVisionPageReturnConnect("vision lifecycle active");
|
||||
else rtcConnectOnce().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
cancelCarrotVisionPageReturnConnect();
|
||||
if (nextVisionWanted && nextVisionBlockReason) {
|
||||
console.log("[perf] carrot vision realtime -> waiting", nextVisionBlockReason);
|
||||
setCarrotVisionPhase(CARROT_VISION_PHASE.STARTING, {
|
||||
@@ -641,6 +781,11 @@ function syncCarrotRealtimeLifecycle(forceFetch = false) {
|
||||
rtcCancelResumeCheck();
|
||||
} else {
|
||||
console.log("[perf] carrot vision realtime -> idle");
|
||||
recordCarrotVisionLifecycleEvent("vision_realtime_idle", {
|
||||
wanted: Boolean(nextVisionWanted),
|
||||
blockReason: nextVisionBlockReason || "",
|
||||
page: document.body?.dataset?.page || "",
|
||||
});
|
||||
setCarrotVisionPhase(CARROT_VISION_STATE.available ? CARROT_VISION_PHASE.INACTIVE : CARROT_VISION_PHASE.UNAVAILABLE, {
|
||||
reason: "vision lifecycle idle",
|
||||
updateRtcStatus: false,
|
||||
@@ -650,29 +795,106 @@ function syncCarrotRealtimeLifecycle(forceFetch = false) {
|
||||
stopCarrotVisionHealthWatch();
|
||||
stopRtcPerfPolling();
|
||||
rawOverlayDisconnectAll();
|
||||
rtcDisconnect().catch(() => {});
|
||||
rtcDisconnect({ keepVideo: true }).catch(() => {});
|
||||
}
|
||||
|
||||
emitCarrotRenderRequest({ force: true, overlayDirty: true, hudDirty: true });
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
recordCarrotVisionLifecycleEvent("visibility_change", {
|
||||
state: document.visibilityState,
|
||||
page: document.body?.dataset?.page || "",
|
||||
visionActive: Boolean(isCarrotVisionActive()),
|
||||
});
|
||||
rtcHandleVisibilityChange();
|
||||
syncCarrotRealtimeLifecycle(false);
|
||||
});
|
||||
|
||||
window.addEventListener("offline", () => {
|
||||
recordCarrotVisionLifecycleEvent("network_offline", {
|
||||
page: document.body?.dataset?.page || "",
|
||||
visionActive: Boolean(isCarrotVisionActive()),
|
||||
});
|
||||
rtcStatusSet("offline");
|
||||
});
|
||||
|
||||
window.addEventListener("online", () => {
|
||||
recordCarrotVisionLifecycleEvent("network_online", {
|
||||
page: document.body?.dataset?.page || "",
|
||||
visionActive: Boolean(isCarrotVisionActive()),
|
||||
});
|
||||
syncCarrotRealtimeLifecycle(false);
|
||||
rtcScheduleResumeIfConnected("network resumed");
|
||||
});
|
||||
window.addEventListener("pagehide", rtcExitPictureInPicture);
|
||||
|
||||
function handleCarrotVisionPageSuspend(eventName, detail = {}) {
|
||||
recordCarrotVisionLifecycleEvent(eventName, {
|
||||
page: document.body?.dataset?.page || "",
|
||||
visibility: document.visibilityState,
|
||||
visionActive: Boolean(isCarrotVisionActive()),
|
||||
...detail,
|
||||
});
|
||||
cancelCarrotVisionPageReturnConnect();
|
||||
rtcExitPictureInPicture();
|
||||
if (!isCarrotVisionActive()) return;
|
||||
rtcCaptureVideoHoldFrame();
|
||||
stopCarrotVisionHealthWatch();
|
||||
rawOverlayDisconnectAll().catch(() => {});
|
||||
if (isCarrotPageActive()) {
|
||||
rtcDisconnect({ keepVideo: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function handleCarrotVisionPageResume(eventName, detail = {}) {
|
||||
recordCarrotVisionLifecycleEvent(eventName, {
|
||||
page: document.body?.dataset?.page || "",
|
||||
visibility: document.visibilityState,
|
||||
visionActive: Boolean(isCarrotVisionActive()),
|
||||
...detail,
|
||||
});
|
||||
if (!isCarrotPageActive() || !isCarrotVisionActive()) return;
|
||||
syncCarrotVisionAvailability().catch(() => {});
|
||||
fetchLiveRuntimeState(true).catch(() => {});
|
||||
syncCarrotRealtimeLifecycle(true);
|
||||
scheduleCarrotVisionPageReturnConnect(eventName);
|
||||
}
|
||||
|
||||
window.addEventListener("pagehide", (event) => {
|
||||
handleCarrotVisionPageSuspend("pagehide", { persisted: Boolean(event?.persisted) });
|
||||
});
|
||||
window.addEventListener("pageshow", (event) => {
|
||||
handleCarrotVisionPageResume("pageshow", { persisted: Boolean(event?.persisted) });
|
||||
});
|
||||
window.addEventListener("focus", () => {
|
||||
handleCarrotVisionPageResume("window_focus");
|
||||
});
|
||||
window.addEventListener("blur", () => {
|
||||
recordCarrotVisionLifecycleEvent("window_blur", {
|
||||
page: document.body?.dataset?.page || "",
|
||||
visionActive: Boolean(isCarrotVisionActive()),
|
||||
});
|
||||
});
|
||||
document.addEventListener("freeze", () => {
|
||||
handleCarrotVisionPageSuspend("page_freeze", { persisted: true });
|
||||
});
|
||||
document.addEventListener("resume", () => {
|
||||
handleCarrotVisionPageResume("page_resume", { persisted: true });
|
||||
});
|
||||
|
||||
window.addEventListener("carrot:pagechange", (event) => {
|
||||
maybeRequestCarrotFullscreenOnPageChange(event?.detail || {});
|
||||
syncCarrotRealtimeLifecycle(false);
|
||||
const page = event?.detail?.page || "";
|
||||
recordCarrotVisionLifecycleEvent("page_change", {
|
||||
page,
|
||||
visionActive: Boolean(isCarrotVisionActive()),
|
||||
});
|
||||
if (page === "carrot" && isCarrotVisionActive()) {
|
||||
scheduleCarrotVisionPageReturnConnect("page changed to carrot");
|
||||
} else {
|
||||
cancelCarrotVisionPageReturnConnect();
|
||||
}
|
||||
syncCarrotRealtimeLifecycle(true);
|
||||
});
|
||||
|
||||
// Staged overlay gate, driven by vision phase:
|
||||
@@ -695,7 +917,7 @@ window.addEventListener("carrot:visionstatechange", (event) => {
|
||||
_overlayStaged = false;
|
||||
return;
|
||||
}
|
||||
const isReady = state.controlState === CARROT_VISION_CONTROL.LIVE;
|
||||
const isReady = state.controlState === CARROT_VISION_CONTROL.LIVE && !isCarrotVisionTestActive();
|
||||
if (isReady && !_overlayStaged) {
|
||||
_overlayStaged = true;
|
||||
window.CarrotVisionRaw?.connectOverlay?.();
|
||||
@@ -705,6 +927,12 @@ window.addEventListener("carrot:visionstatechange", (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("carrot:visiontestchange", () => {
|
||||
if (!isCarrotVisionTestActive()) return;
|
||||
_overlayStaged = false;
|
||||
window.CarrotVisionRaw?.disconnectOverlay?.();
|
||||
});
|
||||
|
||||
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
@@ -306,6 +306,7 @@
|
||||
window.addEventListener("offline", this.sync);
|
||||
window.addEventListener("carrot:pagechange", this.sync);
|
||||
window.addEventListener("carrot:visionchange", this.sync);
|
||||
window.addEventListener("carrot:visiontestchange", this.sync);
|
||||
window.addEventListener("carrot:websettingschange", this.sync);
|
||||
window.addEventListener("carrot:render-request", this.tick);
|
||||
document.addEventListener("visibilitychange", this.handleVisibility);
|
||||
@@ -378,6 +379,7 @@
|
||||
if (!settings.enabled) return false;
|
||||
if (!isCarrotPageActive()) return false;
|
||||
if (!isVisionActive()) return false;
|
||||
if (window.CarrotVisionTestState?.active) return false;
|
||||
// Staged startup: hold the map until the first camera frame renders so
|
||||
// it does not compete with the WebRTC first-frame acquisition.
|
||||
if (!hasVisionReachedReady()) return false;
|
||||
|
||||
@@ -17,6 +17,22 @@ window.HomeDrive = (() => {
|
||||
const metaEl = document.getElementById("carrotStageMeta");
|
||||
const debugEl = document.getElementById("carrotStageDebug");
|
||||
const driveHudCardEl = document.getElementById("driveHudCard");
|
||||
const rtcPerfHudEl = document.getElementById("carrotRtcPerfHud");
|
||||
const rtcPerfGlanceEl = document.getElementById("carrotRtcPerfGlance");
|
||||
const rtcPerfGlanceTextEl = document.getElementById("carrotRtcPerfGlanceText");
|
||||
const rtcPerfSummaryEl = document.getElementById("carrotRtcPerfSummary");
|
||||
const rtcPerfTitleEl = document.getElementById("carrotRtcPerfTitle");
|
||||
const rtcPerfVideoEl = document.getElementById("carrotRtcPerfVideo");
|
||||
const rtcPerfCodecEl = document.getElementById("carrotRtcPerfCodec");
|
||||
const rtcPerfBitrateEl = document.getElementById("carrotRtcPerfBitrate");
|
||||
const rtcPerfRttEl = document.getElementById("carrotRtcPerfRtt");
|
||||
const rtcPerfJitterEl = document.getElementById("carrotRtcPerfJitter");
|
||||
const rtcPerfLossEl = document.getElementById("carrotRtcPerfLoss");
|
||||
const rtcPerfFreezeEl = document.getElementById("carrotRtcPerfFreeze");
|
||||
const rtcPerfPathEl = document.getElementById("carrotRtcPerfPath");
|
||||
const rtcPerfHoldTargetEl = document.getElementById("carrotRtcPerfHoldTarget");
|
||||
const rtcPerfCloseBtnEl = document.getElementById("carrotRtcPerfCloseBtn");
|
||||
const rtcPerfLogBtnEl = document.getElementById("carrotRtcPerfLogBtn");
|
||||
const sourceVideoEl = videoEl;
|
||||
const displayModeButton = document.getElementById("btnDisplayModeCycle");
|
||||
|
||||
@@ -59,6 +75,9 @@ window.HomeDrive = (() => {
|
||||
const DESKTOP_DPR_CAP = 1.5;
|
||||
const RENDER_INTERVAL_MS = 33; // ~30fps for denser plot data (C3: 20Hz/50ms)
|
||||
const CAMERA_FRAME_RECHECK_MS = 250;
|
||||
const RTC_PERF_HOLD_MS = 600;
|
||||
const RTC_PERF_HOLD_MOVE_PX = 12;
|
||||
const RTC_PERF_SUMMARY_AUTO_CLOSE_MS = 8000;
|
||||
const MIN_ROAD_VIDEO_WIDTH = 320;
|
||||
const MIN_ROAD_VIDEO_HEIGHT = 180;
|
||||
const PATH_PALETTE = [
|
||||
@@ -2272,15 +2291,22 @@ window.HomeDrive = (() => {
|
||||
}
|
||||
|
||||
function getLeadBadgeOffsets(videoWidth, videoHeight) {
|
||||
const uiScale = getLeadUiScale(videoWidth, videoHeight);
|
||||
return {
|
||||
dx: 80 * uiScale,
|
||||
rectTopOffset: 25 * uiScale,
|
||||
textBaselineOffset: 60 * uiScale,
|
||||
badgeHeight: Math.max(42 * uiScale, 20),
|
||||
fontSize: Math.max(40 * uiScale, 18),
|
||||
radius: Math.max(12 * uiScale, 7),
|
||||
strokeWidth: Math.max(4 * uiScale, 2.2),
|
||||
};
|
||||
}
|
||||
|
||||
function getLeadUiScale(videoWidth, videoHeight) {
|
||||
const scaleX = videoWidth / BASE_CAMERA.width;
|
||||
const scaleY = videoHeight / BASE_CAMERA.height;
|
||||
return {
|
||||
dx: 80 * scaleX,
|
||||
rectTopOffset: 25 * scaleY,
|
||||
textBaselineOffset: 60 * scaleY,
|
||||
badgeHeight: Math.max(42 * scaleY, 26),
|
||||
fontSize: Math.max(40 * scaleY, 20),
|
||||
};
|
||||
return clamp(Math.min(scaleX, scaleY), 0.45, 1.0);
|
||||
}
|
||||
|
||||
function hasNearbyAssistLead(lead, speedMps) {
|
||||
@@ -2325,28 +2351,33 @@ window.HomeDrive = (() => {
|
||||
|
||||
function getLeadBoxClampMargins(videoWidth, videoHeight, stageWidth = videoWidth, stageHeight = videoHeight, transform = null, options = {}) {
|
||||
const visibleRect = getVisibleSourceRect(videoWidth, videoHeight, stageWidth, stageHeight, transform);
|
||||
// C3 fixed margins: top=200, bottom=80, marginX=350
|
||||
const topMargin = Math.max(200.0, visibleRect.top + 6);
|
||||
const uiScale = getLeadUiScale(videoWidth, videoHeight);
|
||||
// C3 fixed margins scaled to the encoded source resolution. Without this,
|
||||
// 964x604 streams keep 1928x1208 margins and force lead UI into the center.
|
||||
const topReserve = Math.max(200.0 * uiScale, 96.0);
|
||||
const bottomReserve = Math.max(80.0 * uiScale, 42.0);
|
||||
const sideReserve = Math.max(350.0 * uiScale, 120.0);
|
||||
const topMargin = Math.max(topReserve, visibleRect.top + 6 * uiScale);
|
||||
|
||||
// C3 base: maxCenterY = fb_h - 80
|
||||
let maxCenterY = videoHeight - 80.0;
|
||||
let maxCenterY = videoHeight - bottomReserve;
|
||||
|
||||
// In crop/fit modes, also keep badges inside visible area
|
||||
const offsets = getLeadBadgeOffsets(videoWidth, videoHeight);
|
||||
let badgeReserve = 0;
|
||||
if (options.includeDistanceBadge !== false) {
|
||||
badgeReserve = Math.max(badgeReserve, offsets.rectTopOffset + offsets.badgeHeight + 8);
|
||||
badgeReserve = Math.max(badgeReserve, offsets.rectTopOffset + offsets.badgeHeight + 8 * uiScale);
|
||||
}
|
||||
if (options.includeStateText) {
|
||||
badgeReserve = Math.max(badgeReserve, offsets.textBaselineOffset + Math.max(offsets.fontSize * 0.28, 8));
|
||||
badgeReserve = Math.max(badgeReserve, offsets.textBaselineOffset + Math.max(offsets.fontSize * 0.28, 8 * uiScale));
|
||||
}
|
||||
maxCenterY = Math.min(maxCenterY, visibleRect.bottom - Math.max(badgeReserve, 80.0));
|
||||
maxCenterY = Math.min(maxCenterY, visibleRect.bottom - Math.max(badgeReserve, bottomReserve));
|
||||
maxCenterY = Math.max(topMargin, maxCenterY);
|
||||
|
||||
return {
|
||||
marginX: 350.0,
|
||||
marginX: sideReserve,
|
||||
topMargin,
|
||||
bottomMargin: Math.max(80.0, videoHeight - maxCenterY),
|
||||
bottomMargin: Math.max(bottomReserve, videoHeight - maxCenterY),
|
||||
maxCenterY,
|
||||
visibleRect,
|
||||
bottomReserve: badgeReserve,
|
||||
@@ -2381,7 +2412,8 @@ window.HomeDrive = (() => {
|
||||
// Match CarrotLink's adaptive bottom margin while keeping carrot.cc clamp policy.
|
||||
const _path_x = clamp(rawCenterX, marginX, Math.max(marginX, videoWidth - marginX));
|
||||
const _path_y = clamp(rawCenterY, topMargin, maxCenterY);
|
||||
const _path_width = clamp(rawWidth, 120, 800);
|
||||
const uiScale = getLeadUiScale(videoWidth, videoHeight);
|
||||
const _path_width = clamp(rawWidth, 120 * uiScale, 800 * uiScale);
|
||||
|
||||
// ── Step 2: Time-based EMA on clamped values ──
|
||||
// C3 uses alpha=0.85 at stable 20Hz. Web frame rate varies, so
|
||||
@@ -2401,8 +2433,8 @@ window.HomeDrive = (() => {
|
||||
const path_x = Math.trunc(path_fx);
|
||||
const path_y = Math.trunc(path_fy);
|
||||
const width = Math.max(Math.trunc(path_fw), 1);
|
||||
const sidePad = 10;
|
||||
const height = Math.max(Math.trunc(width * 0.8), 12);
|
||||
const sidePad = Math.max(10 * uiScale, 5);
|
||||
const height = Math.max(Math.trunc(width * 0.8), Math.round(12 * uiScale));
|
||||
// capnp default is -1; Number(null)=0 would falsely trigger radar-detected, so guard null
|
||||
const radarTrackId = (lead.radarTrackId != null) ? finiteNumber(lead.radarTrackId, -1) : -1;
|
||||
const radarDetected = radarTrackId >= 0;
|
||||
@@ -2422,6 +2454,8 @@ window.HomeDrive = (() => {
|
||||
dRel: distance,
|
||||
modelProb: finiteNumber(lead.modelProb, 0),
|
||||
width,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2457,18 +2491,22 @@ window.HomeDrive = (() => {
|
||||
);
|
||||
const clampedCenterX = clamp((xl + xr) * 0.5, marginX, Math.max(marginX, videoWidth - marginX));
|
||||
const rawWidth = Math.max(xr - xl, 1);
|
||||
const width = Math.max(Math.trunc(clamp(rawWidth, 120, 800)), 1);
|
||||
const uiScale = getLeadUiScale(videoWidth, videoHeight);
|
||||
const sidePad = Math.max(10 * uiScale, 5);
|
||||
const width = Math.max(Math.trunc(clamp(rawWidth, 120 * uiScale, 800 * uiScale)), 1);
|
||||
const yInt = Math.trunc(clamp(y, topMargin, maxCenterY));
|
||||
const xlInt = Math.trunc(clampedCenterX - width * 0.5);
|
||||
const height = Math.max(Math.trunc(width * 0.8), 1);
|
||||
const height = Math.max(Math.trunc(width * 0.8), Math.round(12 * uiScale));
|
||||
return {
|
||||
rect: {
|
||||
x: xlInt - 10,
|
||||
x: xlInt - sidePad,
|
||||
y: yInt - height,
|
||||
width: width + 20,
|
||||
width: width + sidePad * 2,
|
||||
height,
|
||||
},
|
||||
dRel: distance,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2482,8 +2520,9 @@ window.HomeDrive = (() => {
|
||||
function drawLeadBoxCard(box, strokeColor, fillColor, primary = true) {
|
||||
if (!box?.rect) return;
|
||||
const { x, y, width, height } = box.rect;
|
||||
const r = primary ? 15 : 12;
|
||||
const sw = primary ? 3.0 : 2.2;
|
||||
const uiScale = getLeadUiScale(box.videoWidth || BASE_CAMERA.width, box.videoHeight || BASE_CAMERA.height);
|
||||
const r = Math.max((primary ? 15 : 12) * uiScale, primary ? 7 : 6);
|
||||
const sw = Math.max((primary ? 3.0 : 2.2) * uiScale, primary ? 1.7 : 1.3);
|
||||
// C3 style: fill + stroke (carrot.cc ui_fill_rect with stroke color)
|
||||
fillRoundedRect(ctx, x, y, width, height, r, fillColor);
|
||||
strokeRoundedRect(ctx, x, y, width, height, r, strokeColor, sw);
|
||||
@@ -2492,9 +2531,10 @@ window.HomeDrive = (() => {
|
||||
function eraseLeadBoxOcclusion(box, primary = true) {
|
||||
if (!box?.rect) return;
|
||||
const { x, y, width, height } = box.rect;
|
||||
const uiScale = getLeadUiScale(box.videoWidth || BASE_CAMERA.width, box.videoHeight || BASE_CAMERA.height);
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
fillRoundedRect(ctx, x, y, width, height, primary ? 15 : 12, "rgba(0,0,0,1)");
|
||||
fillRoundedRect(ctx, x, y, width, height, Math.max((primary ? 15 : 12) * uiScale, primary ? 7 : 6), "rgba(0,0,0,1)");
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
@@ -2510,9 +2550,9 @@ window.HomeDrive = (() => {
|
||||
|
||||
const drawBadge = (text, centerX, bgColor) => {
|
||||
const charW = fontSize * 0.62;
|
||||
const w = Math.max(52, 16 + text.length * charW);
|
||||
const w = Math.max(52 * getLeadUiScale(videoWidth, videoHeight), 16 * getLeadUiScale(videoWidth, videoHeight) + text.length * charW);
|
||||
const bx = centerX - w * 0.5;
|
||||
fillRoundedRect(ctx, bx, baseY, w, badgeH, 12, bgColor);
|
||||
fillRoundedRect(ctx, bx, baseY, w, badgeH, offsets.radius, bgColor);
|
||||
ctx.save();
|
||||
ctx.font = `800 ${fontSize}px ${HUD_TEXT_FONT}`;
|
||||
ctx.textAlign = "center";
|
||||
@@ -2520,7 +2560,7 @@ window.HomeDrive = (() => {
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.82)";
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineWidth = offsets.strokeWidth;
|
||||
ctx.strokeText(text, centerX, baseY + badgeH * 0.54);
|
||||
ctx.fillText(text, centerX, baseY + badgeH * 0.54);
|
||||
ctx.restore();
|
||||
@@ -2619,15 +2659,16 @@ window.HomeDrive = (() => {
|
||||
|
||||
function drawLeadStateBadge(box, text, _xState, videoWidth = BASE_CAMERA.width, videoHeight = BASE_CAMERA.height, stageWidth = videoWidth, stageHeight = videoHeight, transform = null) {
|
||||
if (!box?.rect || !text) return;
|
||||
const uiScale = getLeadUiScale(videoWidth, videoHeight);
|
||||
const offsets = getLeadBadgeOffsets(videoWidth, videoHeight);
|
||||
const visibleRect = getVisibleSourceRect(videoWidth, videoHeight, stageWidth, stageHeight, transform);
|
||||
const textY = Math.min(box.centerY + offsets.textBaselineOffset, Math.max(visibleRect.top + 6, visibleRect.bottom - 6));
|
||||
const textY = Math.min(box.centerY + offsets.textBaselineOffset, Math.max(visibleRect.top + 6 * uiScale, visibleRect.bottom - 6 * uiScale));
|
||||
drawCanvasOutlinedText(text, box.centerX, textY, {
|
||||
fontSize: Math.max(50 * (videoHeight / BASE_CAMERA.height), 24),
|
||||
fontSize: Math.max(50 * uiScale, 22),
|
||||
fontWeight: 900,
|
||||
fillStyle: "#ffffff",
|
||||
strokeStyle: "rgba(0,0,0,0.88)",
|
||||
strokeWidth: Math.max(4.0 * (videoHeight / BASE_CAMERA.height), 3.4),
|
||||
strokeWidth: Math.max(4.0 * uiScale, 2.2),
|
||||
align: "center",
|
||||
baseline: "bottom",
|
||||
});
|
||||
@@ -2667,17 +2708,18 @@ window.HomeDrive = (() => {
|
||||
return anchor;
|
||||
}
|
||||
|
||||
function drawRadarSpeedBadge(center, text, accentColor) {
|
||||
function drawRadarSpeedBadge(center, text, accentColor, videoWidth = BASE_CAMERA.width, videoHeight = BASE_CAMERA.height) {
|
||||
if (!center || !text) return;
|
||||
const badgeWidth = Math.max(40, 35 * String(text).length);
|
||||
const badgeHeight = 42;
|
||||
const uiScale = getLeadUiScale(videoWidth, videoHeight);
|
||||
const badgeWidth = Math.max(40 * uiScale, 35 * uiScale * String(text).length);
|
||||
const badgeHeight = Math.max(42 * uiScale, 20);
|
||||
const badgeX = center.x - badgeWidth * 0.5;
|
||||
const badgeY = center.y - 35;
|
||||
fillRoundedRect(ctx, badgeX, badgeY, badgeWidth, badgeHeight, 15, accentColor);
|
||||
const badgeY = center.y - 35 * uiScale;
|
||||
fillRoundedRect(ctx, badgeX, badgeY, badgeWidth, badgeHeight, Math.max(15 * uiScale, 7), accentColor);
|
||||
drawCanvasOutlinedText(String(text), center.x, center.y, {
|
||||
fontSize: 40,
|
||||
fontSize: Math.max(40 * uiScale, 18),
|
||||
fontWeight: 900,
|
||||
strokeWidth: 4.2,
|
||||
strokeWidth: Math.max(4.2 * uiScale, 2.2),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2700,19 +2742,22 @@ window.HomeDrive = (() => {
|
||||
const right = projectPoint(calibTransform, tfDistance, lineY + 1.0, lineZ + PATH_Z_OFFSET);
|
||||
if (!left || !right) return;
|
||||
|
||||
drawPolyline([left, right], "rgba(255,255,255,0.92)", 3.0);
|
||||
const uiScale = getLeadUiScale(videoWidth, videoHeight);
|
||||
drawPolyline([left, right], "rgba(255,255,255,0.92)", Math.max(3.0 * uiScale, 1.6));
|
||||
const labelText = `${displayDistanceMeters(tfDistance).toFixed(1)}(${finiteNumber(longitudinalPlan?.tFollow, 0).toFixed(2)})`;
|
||||
const labelFontSize = Math.max(20 * uiScale, 12);
|
||||
const labelAnchor = clampTextAnchor(
|
||||
{ x: right.x + 10, y: right.y - 4 },
|
||||
labelText,
|
||||
20,
|
||||
labelFontSize,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
);
|
||||
drawCanvasOutlinedText(labelText, labelAnchor.x, labelAnchor.y, {
|
||||
fontSize: 20,
|
||||
fontSize: labelFontSize,
|
||||
fontWeight: 800,
|
||||
align: "left",
|
||||
strokeWidth: Math.max(3.4 * uiScale, 1.8),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2747,7 +2792,8 @@ window.HomeDrive = (() => {
|
||||
const rawCenterX = (left.x + right.x) * 0.5;
|
||||
const rawCenterY = (left.y + right.y) * 0.5;
|
||||
const { marginX, topMargin, bottomMargin } = getLeadBoxClampMargins(videoWidth, videoHeight);
|
||||
const width = clamp(rawWidth, 120, 800);
|
||||
const uiScale = getLeadUiScale(videoWidth, videoHeight);
|
||||
const width = clamp(rawWidth, 120 * uiScale, 800 * uiScale);
|
||||
const centerX = clamp(rawCenterX, marginX, Math.max(marginX, videoWidth - marginX));
|
||||
const centerY = clamp(rawCenterY, topMargin, Math.max(topMargin, videoHeight - bottomMargin));
|
||||
return {
|
||||
@@ -2764,15 +2810,15 @@ window.HomeDrive = (() => {
|
||||
const anchor = anchorBox || projectPathEndAnchorBox(modelPath, calibTransform, videoWidth, videoHeight);
|
||||
if (!anchor) return;
|
||||
|
||||
const scale = videoHeight / BASE_CAMERA.height;
|
||||
const fontSize = Math.max(50 * scale, 24);
|
||||
const baselineY = clamp(anchor.centerY + 60 * scale, fontSize + 8, videoHeight - 10);
|
||||
const scale = getLeadUiScale(videoWidth, videoHeight);
|
||||
const fontSize = Math.max(50 * scale, 22);
|
||||
const baselineY = clamp(anchor.centerY + 60 * scale, fontSize + 8 * scale, videoHeight - 10 * scale);
|
||||
drawCanvasOutlinedText(text, anchor.centerX, baselineY, {
|
||||
fontSize,
|
||||
fontWeight: 900,
|
||||
fillStyle: "#ffffff",
|
||||
strokeStyle: "rgba(0,0,0,0.88)",
|
||||
strokeWidth: Math.max(4.0 * scale, 3.4),
|
||||
strokeWidth: Math.max(4.0 * scale, 2.2),
|
||||
align: "center",
|
||||
baseline: "bottom",
|
||||
});
|
||||
@@ -2802,7 +2848,7 @@ window.HomeDrive = (() => {
|
||||
return model?.position || null;
|
||||
}
|
||||
|
||||
function drawRadarTargets(radarState, model, calibTransform) {
|
||||
function drawRadarTargets(radarState, model, calibTransform, videoWidth = BASE_CAMERA.width, videoHeight = BASE_CAMERA.height) {
|
||||
const showRadarInfo = finiteNumber(paramsState.ShowRadarInfo, 0);
|
||||
if (showRadarInfo <= 0) return;
|
||||
const projectionLine = getRadarProjectionLine(model);
|
||||
@@ -2834,8 +2880,9 @@ window.HomeDrive = (() => {
|
||||
: null;
|
||||
if (future) {
|
||||
const vectorColor = vSigned >= 0 ? "rgba(35,213,93,0.94)" : "rgba(255,59,48,0.94)";
|
||||
drawPolyline([center, future], vectorColor, 3.0);
|
||||
drawPolygon(circlePolygon(future.x, future.y, 10, 12), vectorColor);
|
||||
const uiScale = getLeadUiScale(videoWidth, videoHeight);
|
||||
drawPolyline([center, future], vectorColor, Math.max(3.0 * uiScale, 1.6));
|
||||
drawPolygon(circlePolygon(future.x, future.y, Math.max(10 * uiScale, 5), 12), vectorColor);
|
||||
}
|
||||
|
||||
let badgeColor = "rgba(255,59,48,0.96)";
|
||||
@@ -2844,26 +2891,28 @@ window.HomeDrive = (() => {
|
||||
else if (vSigned > 0) badgeColor = "rgba(255,167,38,0.96)";
|
||||
|
||||
const speedValue = vSigned * (isMetric ? 3.6 : 2.2369363);
|
||||
drawRadarSpeedBadge({ x: center.x, y: center.y }, speedValue.toFixed(0), badgeColor);
|
||||
drawRadarSpeedBadge({ x: center.x, y: center.y }, speedValue.toFixed(0), badgeColor, videoWidth, videoHeight);
|
||||
|
||||
if (showRadarInfo >= 2) {
|
||||
drawCanvasOutlinedText(displayDistanceMeters(finiteNumber(radar?.yRel, 0)).toFixed(1), center.x, center.y - 40, {
|
||||
fontSize: 30,
|
||||
const uiScale = getLeadUiScale(videoWidth, videoHeight);
|
||||
drawCanvasOutlinedText(displayDistanceMeters(finiteNumber(radar?.yRel, 0)).toFixed(1), center.x, center.y - 40 * uiScale, {
|
||||
fontSize: Math.max(30 * uiScale, 15),
|
||||
fontWeight: 900,
|
||||
strokeWidth: 3.8,
|
||||
strokeWidth: Math.max(3.8 * uiScale, 2.0),
|
||||
});
|
||||
const distanceValue = displayDistanceMeters(dRel);
|
||||
drawCanvasOutlinedText(distanceValue.toFixed(1), center.x, center.y + 30, {
|
||||
fontSize: 30,
|
||||
drawCanvasOutlinedText(distanceValue.toFixed(1), center.x, center.y + 30 * uiScale, {
|
||||
fontSize: Math.max(30 * uiScale, 15),
|
||||
fontWeight: 900,
|
||||
strokeWidth: 3.8,
|
||||
strokeWidth: Math.max(3.8 * uiScale, 2.0),
|
||||
});
|
||||
}
|
||||
} else if (showRadarInfo >= 3) {
|
||||
const uiScale = getLeadUiScale(videoWidth, videoHeight);
|
||||
drawCanvasOutlinedText("*", center.x, center.y, {
|
||||
fontSize: 40,
|
||||
fontSize: Math.max(40 * uiScale, 18),
|
||||
fontWeight: 900,
|
||||
strokeWidth: 4.2,
|
||||
strokeWidth: Math.max(4.2 * uiScale, 2.2),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2966,7 +3015,7 @@ window.HomeDrive = (() => {
|
||||
resetLeadTwoEma();
|
||||
}
|
||||
drawPathStatusText(modelPath, hudState, calibTransform, videoWidth, videoHeight, primaryStatusAnchorBox);
|
||||
drawRadarTargets(radarState, model, calibTransform);
|
||||
drawRadarTargets(radarState, model, calibTransform, videoWidth, videoHeight);
|
||||
}
|
||||
|
||||
function roundedRectPath(context, x, y, width, height, radius) {
|
||||
@@ -3064,79 +3113,180 @@ window.HomeDrive = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
function formatRtcPerfLabel() {
|
||||
function optionalNumber(value) {
|
||||
if (value == null || value === "") return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function formatRtcNumber(value, suffix = "", digits = 0) {
|
||||
return value == null ? `-${suffix}` : `${value.toFixed(digits)}${suffix}`;
|
||||
}
|
||||
|
||||
function getRtcCodecLabel(codec, codecParams) {
|
||||
const rawCodec = String(codec || "").split("/").pop().trim().toUpperCase();
|
||||
if (!rawCodec) return "-";
|
||||
if (rawCodec !== "H264") return rawCodec;
|
||||
const match = /profile-level-id=([0-9a-fA-F]{6})/.exec(String(codecParams || ""));
|
||||
const profilePrefix = match?.[1]?.slice(0, 2)?.toLowerCase() || "";
|
||||
const profile = profilePrefix === "42"
|
||||
? "Baseline"
|
||||
: profilePrefix === "4d"
|
||||
? "Main"
|
||||
: profilePrefix === "64"
|
||||
? "High"
|
||||
: "";
|
||||
return profile ? `${rawCodec} ${profile}` : rawCodec;
|
||||
}
|
||||
|
||||
function buildRtcPerfHudModel() {
|
||||
const perf = window.CarrotRtcPerf || null;
|
||||
if (!perf?.active) return "";
|
||||
if (perf?.connectionState === "connecting" || perf?.iceConnectionState === "checking") return "RECONN";
|
||||
if (perf?.error) return "";
|
||||
if (!perf?.active) {
|
||||
return { visible: false, tone: "offline", title: "VISION OFFLINE", glance: "" };
|
||||
}
|
||||
|
||||
const network = perf.network || {};
|
||||
const inbound = perf.inbound || {};
|
||||
const video = perf.video || {};
|
||||
const resolutionLabel = String(network.resolutionLabel || "").trim();
|
||||
const bitrateMbps = Number.isFinite(Number(network.bitrateMbps)) ? Number(network.bitrateMbps) : null;
|
||||
const fps = Number.isFinite(Number(inbound.framesPerSecond)) ? Number(inbound.framesPerSecond) : null;
|
||||
const rttMs = Number.isFinite(Number(network.rttMs)) ? Number(network.rttMs) : null;
|
||||
const lossPct = Number.isFinite(Number(network.lossPct)) ? Number(network.lossPct) : null;
|
||||
const jitterMs = Number.isFinite(Number(network.jitterMs)) ? Number(network.jitterMs) : null;
|
||||
const keyFramesDecoded = Number.isFinite(Number(inbound.keyFramesDecoded)) ? Number(inbound.keyFramesDecoded) : null;
|
||||
const packetsLost = Number.isFinite(Number(inbound.packetsLost)) ? Number(inbound.packetsLost) : null;
|
||||
const hasFreeze = Number.isFinite(Number(inbound.freezeCount)) && Number(inbound.freezeCount) > 0 &&
|
||||
Number.isFinite(Number(video.readyState)) && Number(video.readyState) < 3;
|
||||
const resolution = String(network.resolutionLabel || "").trim() || "-";
|
||||
const bitrateMbps = optionalNumber(network.bitrateMbps);
|
||||
const fps = optionalNumber(inbound.framesPerSecond);
|
||||
const rttMs = optionalNumber(network.rttMs);
|
||||
const lossPct = optionalNumber(network.lossPct);
|
||||
const jitterMs = optionalNumber(network.jitterMs);
|
||||
const freezeCount = optionalNumber(inbound.freezeCount);
|
||||
const framesDecoded = optionalNumber(inbound.framesDecoded);
|
||||
const framesReceived = optionalNumber(inbound.framesReceived);
|
||||
const readyState = optionalNumber(video.readyState);
|
||||
const reconnecting = (
|
||||
perf.connectionState === "connecting" ||
|
||||
perf.iceConnectionState === "checking" ||
|
||||
perf.iceConnectionState === "disconnected"
|
||||
);
|
||||
const stalled = (
|
||||
(framesReceived != null && framesReceived > 0 && framesDecoded != null && framesDecoded <= 0) ||
|
||||
(freezeCount != null && freezeCount > 0 && readyState != null && readyState < 3)
|
||||
);
|
||||
const degraded = (
|
||||
(lossPct != null && lossPct >= 0.5) ||
|
||||
(jitterMs != null && jitterMs >= 30) ||
|
||||
(rttMs != null && rttMs >= 150)
|
||||
);
|
||||
const bitrateLabel = formatRtcNumber(bitrateMbps, "Mbps", bitrateMbps != null && bitrateMbps < 10 ? 2 : 1);
|
||||
const fpsLabel = fps == null ? "-fps" : `${Math.round(fps)}fps`;
|
||||
const rttLabel = rttMs == null ? "-ms" : `${Math.round(rttMs)}ms`;
|
||||
const jitterLabel = jitterMs == null ? "-ms" : `${Math.round(jitterMs)}ms`;
|
||||
const lossLabel = lossPct == null ? "-%" : `${lossPct.toFixed(lossPct >= 10 ? 0 : 1)}%`;
|
||||
const codecLabel = getRtcCodecLabel(perf.codec, perf.codecParams);
|
||||
const protocol = String(network.protocol || "-").toUpperCase();
|
||||
const localType = String(network.localCandidateType || "-");
|
||||
const remoteType = String(network.remoteCandidateType || "-");
|
||||
let tone = "normal";
|
||||
let title = "VISION OK";
|
||||
let glance = `${resolution} | ${fpsLabel} | ${bitrateLabel} | ${rttLabel}`;
|
||||
|
||||
if (!resolutionLabel && bitrateMbps == null && fps == null && rttMs == null && lossPct == null && jitterMs == null) {
|
||||
return hasFreeze ? "STALL" : "";
|
||||
if (perf.error) {
|
||||
tone = "offline";
|
||||
title = "VISION OFFLINE";
|
||||
glance = "OFFLINE | stats unavailable";
|
||||
} else if (reconnecting) {
|
||||
tone = "reconnecting";
|
||||
title = "VISION RECONNECTING";
|
||||
glance = "RECONNECTING | waiting stream";
|
||||
} else if (stalled) {
|
||||
tone = "stall";
|
||||
title = "VISION STALL";
|
||||
glance = `STALL | decode ${framesDecoded ?? "-"} | recv ${framesReceived ?? "-"}`;
|
||||
} else if (degraded) {
|
||||
tone = "degraded";
|
||||
title = "VISION DEGRADED";
|
||||
const warnings = [];
|
||||
if (lossPct != null && lossPct >= 0.5) warnings.push(`loss ${lossLabel}`);
|
||||
if (rttMs != null && rttMs >= 150) warnings.push(`RTT ${rttLabel}`);
|
||||
if (jitterMs != null && jitterMs >= 30) warnings.push(`jitter ${jitterLabel}`);
|
||||
glance = `DEGRADED | ${warnings.slice(0, 2).join(" | ")}`;
|
||||
}
|
||||
|
||||
const bitrateLabel = bitrateMbps != null
|
||||
? `${bitrateMbps >= 10 ? bitrateMbps.toFixed(0) : bitrateMbps.toFixed(1)}m`
|
||||
: "-m";
|
||||
const fpsLabel = fps != null ? `${Math.round(fps)}fps` : "-fps";
|
||||
const rttLabel = rttMs != null ? `${Math.round(rttMs)}ms` : "-ms";
|
||||
const warningLabels = [];
|
||||
if (lossPct != null && lossPct >= 0.5) {
|
||||
warningLabels.push(`loss${lossPct >= 10 ? Math.round(lossPct) : lossPct.toFixed(1)}%`);
|
||||
} else if (packetsLost != null && packetsLost > 0 && Number(inbound.framesDecoded || 0) <= 0) {
|
||||
warningLabels.push(`lost${packetsLost}`);
|
||||
}
|
||||
if (jitterMs != null && jitterMs >= 30) warningLabels.push(`jit${Math.round(jitterMs)}`);
|
||||
if (keyFramesDecoded === 0 && Number(inbound.framesDecoded || 0) <= 0) warningLabels.push("key0");
|
||||
|
||||
return [resolutionLabel || "-p", bitrateLabel, fpsLabel, rttLabel].concat(warningLabels).join(" ");
|
||||
return {
|
||||
visible: true,
|
||||
tone,
|
||||
title,
|
||||
glance,
|
||||
video: `${resolution} | ${fpsLabel}`,
|
||||
codec: codecLabel,
|
||||
bitrate: bitrateLabel,
|
||||
rtt: rttLabel,
|
||||
jitter: jitterLabel,
|
||||
loss: lossLabel,
|
||||
freeze: freezeCount == null ? "-" : String(Math.round(freezeCount)),
|
||||
path: `${protocol} ${localType} -> ${remoteType}`,
|
||||
};
|
||||
}
|
||||
|
||||
function drawHudRightCenterPerfText(stageWidth, stageHeight, viewportRect, pathMode) {
|
||||
const label = shortText(formatRtcPerfLabel(), 48);
|
||||
if (!label) return;
|
||||
function formatRtcPerfLabel() {
|
||||
return buildRtcPerfHudModel().glance || "";
|
||||
}
|
||||
|
||||
const exactC3Mode = stageWidth >= 1280 && stageHeight >= 720;
|
||||
const baseScale = Math.min(stageWidth / 1920, stageHeight / 1080);
|
||||
const scale = clamp(baseScale, 0.48, 1.0);
|
||||
const edgeInsetX = exactC3Mode ? 1.5 : clamp(2.0 * scale, 1.0, 2.5);
|
||||
const edgeInsetTop = exactC3Mode ? 28.0 : clamp(30.0 * scale, 12.0, 30.0);
|
||||
const maxWidth = Math.max(180.0, viewportRect.width * 0.42);
|
||||
const fontSize = fitSingleLineHudFontSize(
|
||||
label,
|
||||
exactC3Mode ? 18.0 : clamp(18.0 * scale, 8.0, 18.0),
|
||||
maxWidth,
|
||||
6.0,
|
||||
900,
|
||||
);
|
||||
const alpha = getHudLabelAlpha(pathMode, 0.0);
|
||||
let _rtcPerfSummaryOpen = false;
|
||||
let _rtcPerfSummaryCloseTimer = null;
|
||||
|
||||
drawOutlinedHudText({
|
||||
text: label,
|
||||
x: viewportRect.right - edgeInsetX,
|
||||
y: viewportRect.top + edgeInsetTop,
|
||||
color: `rgba(244, 244, 244, ${alpha.toFixed(3)})`,
|
||||
strokeColor: `rgba(0, 0, 0, ${clamp(alpha + 0.08, 0.0, 1.0).toFixed(3)})`,
|
||||
strokeWidth: clamp(4.2 * scale, 2.8, 5.4),
|
||||
fontSize,
|
||||
fontWeight: 900,
|
||||
alignX: "right",
|
||||
alignY: "top",
|
||||
maxWidth,
|
||||
});
|
||||
function isRtcPerfSummaryAvailable() {
|
||||
return window.matchMedia("(orientation: landscape)").matches;
|
||||
}
|
||||
|
||||
function clearRtcPerfSummaryCloseTimer() {
|
||||
if (_rtcPerfSummaryCloseTimer == null) return;
|
||||
window.clearTimeout(_rtcPerfSummaryCloseTimer);
|
||||
_rtcPerfSummaryCloseTimer = null;
|
||||
}
|
||||
|
||||
function syncRtcPerfSummaryAutoClose(tone) {
|
||||
clearRtcPerfSummaryCloseTimer();
|
||||
if (!_rtcPerfSummaryOpen || tone !== "normal") return;
|
||||
_rtcPerfSummaryCloseTimer = window.setTimeout(() => {
|
||||
setRtcPerfSummaryOpen(false);
|
||||
}, RTC_PERF_SUMMARY_AUTO_CLOSE_MS);
|
||||
}
|
||||
|
||||
function setRtcPerfSummaryOpen(open) {
|
||||
const model = buildRtcPerfHudModel();
|
||||
const nextOpen = Boolean(open && model.visible && isRtcPerfSummaryAvailable());
|
||||
_rtcPerfSummaryOpen = nextOpen;
|
||||
if (rtcPerfSummaryEl) rtcPerfSummaryEl.hidden = !nextOpen;
|
||||
if (!nextOpen) {
|
||||
clearRtcPerfSummaryCloseTimer();
|
||||
return;
|
||||
}
|
||||
syncRtcPerfSummaryAutoClose(model.tone);
|
||||
}
|
||||
|
||||
function syncRtcPerfHud() {
|
||||
if (!rtcPerfHudEl || !rtcPerfGlanceEl || !rtcPerfGlanceTextEl) return;
|
||||
const model = buildRtcPerfHudModel();
|
||||
rtcPerfHudEl.hidden = !model.visible;
|
||||
rtcPerfGlanceEl.dataset.tone = model.tone;
|
||||
rtcPerfGlanceTextEl.textContent = model.glance || model.title;
|
||||
|
||||
if (!model.visible || !isRtcPerfSummaryAvailable()) {
|
||||
setRtcPerfSummaryOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rtcPerfSummaryEl) rtcPerfSummaryEl.dataset.tone = model.tone;
|
||||
if (rtcPerfTitleEl) rtcPerfTitleEl.textContent = model.title;
|
||||
if (rtcPerfVideoEl) rtcPerfVideoEl.textContent = model.video;
|
||||
if (rtcPerfCodecEl) rtcPerfCodecEl.textContent = model.codec;
|
||||
if (rtcPerfBitrateEl) rtcPerfBitrateEl.textContent = model.bitrate;
|
||||
if (rtcPerfRttEl) rtcPerfRttEl.textContent = model.rtt;
|
||||
if (rtcPerfJitterEl) rtcPerfJitterEl.textContent = model.jitter;
|
||||
if (rtcPerfLossEl) rtcPerfLossEl.textContent = model.loss;
|
||||
if (rtcPerfFreezeEl) rtcPerfFreezeEl.textContent = model.freeze;
|
||||
if (rtcPerfPathEl) rtcPerfPathEl.textContent = model.path;
|
||||
if (_rtcPerfSummaryOpen && _rtcPerfSummaryCloseTimer == null) {
|
||||
syncRtcPerfSummaryAutoClose(model.tone);
|
||||
} else if (_rtcPerfSummaryOpen && model.tone !== "normal") {
|
||||
clearRtcPerfSummaryCloseTimer();
|
||||
}
|
||||
}
|
||||
|
||||
function drawStageEdgeFades(stageWidth, stageHeight) {
|
||||
@@ -3679,6 +3829,7 @@ window.HomeDrive = (() => {
|
||||
: getUIText("start_vision_hint", "Tap the start button to enable drive vision."));
|
||||
setMeta("");
|
||||
setDebug("");
|
||||
syncRtcPerfHud();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3707,6 +3858,7 @@ window.HomeDrive = (() => {
|
||||
width: stageWidth,
|
||||
height: stageHeight,
|
||||
});
|
||||
syncRtcPerfHud();
|
||||
scheduleCameraFrameRecheck();
|
||||
return;
|
||||
}
|
||||
@@ -3793,7 +3945,7 @@ window.HomeDrive = (() => {
|
||||
setDebug(debugText);
|
||||
drawHudTopLeftText(stageWidth, stageHeight, viewportRect, overlayInfoState.carLabel, pathStyle.mode);
|
||||
drawHudTopRightText(stageWidth, stageHeight, viewportRect, lastDebug, pathStyle.mode);
|
||||
drawHudRightCenterPerfText(stageWidth, stageHeight, viewportRect, pathStyle.mode);
|
||||
syncRtcPerfHud();
|
||||
drawHudBottomLeftText(stageWidth, stageHeight, viewportRect, overlayInfoState.branchLabel, pathStyle.mode);
|
||||
drawHudBottomText(stageWidth, stageHeight, viewportRect, selectedPath.latDebugText, hudState, pathStyle.mode);
|
||||
}
|
||||
@@ -3942,6 +4094,85 @@ window.HomeDrive = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
let _rtcPerfHoldTimer = null;
|
||||
let _rtcPerfHoldPointerId = null;
|
||||
let _rtcPerfHoldStartX = 0;
|
||||
let _rtcPerfHoldStartY = 0;
|
||||
|
||||
function clearRtcPerfHold() {
|
||||
if (_rtcPerfHoldTimer != null) {
|
||||
window.clearTimeout(_rtcPerfHoldTimer);
|
||||
_rtcPerfHoldTimer = null;
|
||||
}
|
||||
_rtcPerfHoldPointerId = null;
|
||||
if (rtcPerfHoldTargetEl) rtcPerfHoldTargetEl.classList.remove("is-holding");
|
||||
}
|
||||
|
||||
function bindRtcPerfHudInteractions() {
|
||||
if (!rtcPerfHoldTargetEl || !rtcPerfSummaryEl) return;
|
||||
|
||||
rtcPerfHoldTargetEl.addEventListener("pointerdown", (event) => {
|
||||
event.stopPropagation();
|
||||
clearRtcPerfHold();
|
||||
if (!isActive() || !isRtcPerfSummaryAvailable()) return;
|
||||
_rtcPerfHoldPointerId = event.pointerId;
|
||||
_rtcPerfHoldStartX = event.clientX;
|
||||
_rtcPerfHoldStartY = event.clientY;
|
||||
rtcPerfHoldTargetEl.classList.add("is-holding");
|
||||
rtcPerfHoldTargetEl.setPointerCapture?.(event.pointerId);
|
||||
_rtcPerfHoldTimer = window.setTimeout(() => {
|
||||
_rtcPerfHoldTimer = null;
|
||||
rtcPerfHoldTargetEl.classList.remove("is-holding");
|
||||
setRtcPerfSummaryOpen(!_rtcPerfSummaryOpen);
|
||||
}, RTC_PERF_HOLD_MS);
|
||||
});
|
||||
rtcPerfHoldTargetEl.addEventListener("pointermove", (event) => {
|
||||
if (_rtcPerfHoldPointerId !== event.pointerId) return;
|
||||
if (Math.hypot(event.clientX - _rtcPerfHoldStartX, event.clientY - _rtcPerfHoldStartY) > RTC_PERF_HOLD_MOVE_PX) {
|
||||
clearRtcPerfHold();
|
||||
}
|
||||
});
|
||||
["pointerup", "pointercancel", "lostpointercapture"].forEach((eventName) => {
|
||||
rtcPerfHoldTargetEl.addEventListener(eventName, clearRtcPerfHold);
|
||||
});
|
||||
rtcPerfHoldTargetEl.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
rtcPerfCloseBtnEl?.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
setRtcPerfSummaryOpen(false);
|
||||
});
|
||||
rtcPerfLogBtnEl?.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const previousText = rtcPerfLogBtnEl.textContent;
|
||||
rtcPerfLogBtnEl.disabled = true;
|
||||
rtcPerfLogBtnEl.textContent = "SEND";
|
||||
(async () => {
|
||||
try {
|
||||
if (!window.CarrotVisionDiag?.uploadDiscord) throw new Error("diagnostic upload unavailable");
|
||||
await window.CarrotVisionDiag.uploadDiscord();
|
||||
rtcPerfLogBtnEl.textContent = "SENT";
|
||||
if (typeof showAppToast === "function") showAppToast("Carrot Vision log sent to Discord", { duration: 2600 });
|
||||
} catch (error) {
|
||||
rtcPerfLogBtnEl.textContent = "FAIL";
|
||||
if (typeof showAppToast === "function") showAppToast(`Discord upload failed: ${error?.message || error}`, { tone: "error", duration: 4200 });
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
rtcPerfLogBtnEl.disabled = false;
|
||||
rtcPerfLogBtnEl.textContent = previousText;
|
||||
}, 1400);
|
||||
}
|
||||
})();
|
||||
});
|
||||
document.addEventListener("pointerdown", (event) => {
|
||||
if (!_rtcPerfSummaryOpen) return;
|
||||
if (rtcPerfSummaryEl.contains(event.target) || rtcPerfHoldTargetEl.contains(event.target)) return;
|
||||
setRtcPerfSummaryOpen(false);
|
||||
}, true);
|
||||
}
|
||||
|
||||
function shouldIgnoreStageFullscreenToggle(target) {
|
||||
if (!(target instanceof Element)) return false;
|
||||
if (target.closest("button, a, input, textarea, select, label")) return true;
|
||||
@@ -3958,11 +4189,13 @@ window.HomeDrive = (() => {
|
||||
}
|
||||
|
||||
function requestFullRender() {
|
||||
if (!isRtcPerfSummaryAvailable()) setRtcPerfSummaryOpen(false);
|
||||
refresh();
|
||||
requestRender({ force: true, overlayDirty: true, hudDirty: true });
|
||||
}
|
||||
|
||||
function handleLifecycleChange() {
|
||||
if (!isActive()) setRtcPerfSummaryOpen(false);
|
||||
if (isStageVisible()) {
|
||||
if (isCarrotVisionActive()) {
|
||||
const live = (getCarrotVisionState().controlState || "") === "live";
|
||||
@@ -4008,6 +4241,8 @@ window.HomeDrive = (() => {
|
||||
} catch {}
|
||||
|
||||
syncDisplayModeButtons();
|
||||
bindRtcPerfHudInteractions();
|
||||
syncRtcPerfHud();
|
||||
refreshParams(true);
|
||||
refreshOverlayInfo(true).catch(() => {});
|
||||
requestRender({ force: true, overlayDirty: true, hudDirty: true });
|
||||
|
||||
@@ -146,6 +146,26 @@
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function cleanGuidanceText(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return "";
|
||||
if (/^route\s*=\s*[-+]?\d+(?:\.\d+)?$/i.test(text)) return "";
|
||||
return text;
|
||||
}
|
||||
|
||||
function escapeRegex(text) {
|
||||
return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function compactSideLabel(side) {
|
||||
let label = String(side?.label || "").replace(/\s+/g, " ").trim();
|
||||
const sign = String(side?.sign || "").trim();
|
||||
if (side?.kind === "camera" && sign) {
|
||||
label = label.replace(new RegExp(`^${escapeRegex(sign)}\\s*`), "").trim();
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
function navIconSvg(kind) {
|
||||
const attrs = 'class="carrot-nav-hud__icon-svg" viewBox="0 0 48 48" aria-hidden="true" focusable="false"';
|
||||
const pathAttrs = 'fill="none" stroke="currentColor" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"';
|
||||
@@ -288,7 +308,10 @@
|
||||
}
|
||||
|
||||
const compact = sideVisible && (sideLeft <= edge + 1 || groupWidth() > stageWidth * 0.68);
|
||||
const cardHeight = compact ? 78 : clamp(stageHeight * 0.12, 82, 92);
|
||||
const navScale = clamp(stageHeight / 604, 0.86, 1.10);
|
||||
const compactScale = compact ? clamp(navScale * 0.92, 0.82, 0.98) : navScale;
|
||||
const cardHeight = Math.round((compact ? 88 : 96) * compactScale);
|
||||
const setPx = (name, value) => this.root.style.setProperty(name, `${Math.round(value)}px`);
|
||||
if (compact) this.root.dataset.layout = "compact";
|
||||
else delete this.root.dataset.layout;
|
||||
this.root.dataset.sideVisible = sideVisible ? "1" : "0";
|
||||
@@ -300,6 +323,21 @@
|
||||
this.root.style.setProperty("--nav-card-height", `${Math.round(cardHeight)}px`);
|
||||
this.root.style.setProperty("--nav-main-left", `${mainLeft}px`);
|
||||
this.root.style.setProperty("--nav-side-left", `${Math.max(edge, sideLeft)}px`);
|
||||
this.root.style.setProperty("--nav-scale", String(Number(compactScale.toFixed(3))));
|
||||
setPx("--nav-padding-y", 12 * compactScale);
|
||||
setPx("--nav-main-padding-x", 18 * compactScale);
|
||||
setPx("--nav-side-padding-x", 15 * compactScale);
|
||||
setPx("--nav-side-gap", 14 * compactScale);
|
||||
setPx("--nav-body-gap", 5 * compactScale);
|
||||
setPx("--nav-side-sign-size", 58 * compactScale);
|
||||
setPx("--nav-side-sign-font", 25 * compactScale);
|
||||
setPx("--nav-side-dist-font", 23 * compactScale);
|
||||
setPx("--nav-side-label-font", 18 * compactScale);
|
||||
setPx("--nav-icon-size", 62 * compactScale);
|
||||
setPx("--nav-icon-font", 38 * compactScale);
|
||||
setPx("--nav-dist-font", 33 * compactScale);
|
||||
setPx("--nav-road-font", 21 * compactScale);
|
||||
setPx("--nav-meta-font", 17 * compactScale);
|
||||
}
|
||||
|
||||
readCarrotMan() {
|
||||
@@ -327,8 +365,8 @@
|
||||
const turnDist = finiteNumber(cm.xDistToTurn) ?? 0;
|
||||
const turnCountdown = finiteNumber(cm.xTurnCountDown) ?? 0;
|
||||
const roadLimit = finiteNumber(cm.nRoadLimitSpeed) ?? 0;
|
||||
const roadName = String(cm.szPosRoadName || "").trim();
|
||||
const tbtRoad = String(cm.szTBTMainText || "").trim();
|
||||
const roadName = cleanGuidanceText(cm.szPosRoadName);
|
||||
const tbtRoad = cleanGuidanceText(cm.szTBTMainText);
|
||||
const atcType = String(cm.atcType || "").trim().toLowerCase();
|
||||
const trafficState = finiteNumber(cm.trafficState) ?? 0;
|
||||
const meta = buildMeta(cm);
|
||||
@@ -513,7 +551,7 @@
|
||||
this.sideDistEl.textContent = side.dist || "";
|
||||
this.sideDistEl.hidden = !side.dist;
|
||||
}
|
||||
if (this.sideLabelEl) this.sideLabelEl.textContent = side.label || "";
|
||||
if (this.sideLabelEl) this.sideLabelEl.textContent = compactSideLabel(side);
|
||||
if (this.sideCountdownEl) {
|
||||
this.sideCountdownEl.textContent = side.countdown || "";
|
||||
this.sideCountdownEl.hidden = !side.countdown;
|
||||
|
||||
@@ -1,25 +1,97 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Carrot Vision diagnostic recorder.
|
||||
// Carrot Vision diagnostic recorder (enriched).
|
||||
//
|
||||
// Silently records RTC phase transitions + periodic WebRTC stats into a ring
|
||||
// buffer (persisted to localStorage so it survives reloads / reconnects), and
|
||||
// exposes a phone-friendly export (download .txt + copy to clipboard) via a
|
||||
// small "LOG" button on the drive page. Built so a tester can capture
|
||||
// "why did it drop" data during a once-a-day drive WITHOUT DevTools.
|
||||
// Silently records, into a ring buffer persisted to localStorage:
|
||||
// PHASE - RTC phase / recovery transitions (deduped)
|
||||
// STAT - periodic WebRTC + <video> numbers, with framesReceived/Decoded
|
||||
// deltas so a decode stall ("data in, 0 frames out") is obvious
|
||||
// CODEC - negotiated codec / H264 profile / decoder implementation (on change)
|
||||
// PATH - transport protocol + ICE candidate types + negotiated resolution (on change)
|
||||
// VINFO - <video> readyState / networkState / MediaError (on change)
|
||||
// VEVENT - real <video> element events (loadedmetadata/playing/waiting/error/...)
|
||||
// VIS - tab visibility changes (a hidden tab can freeze the decoder)
|
||||
// NETCHG - navigator.connection changes (effectiveType/downlink/rtt)
|
||||
// PERFERR - getStats() errors
|
||||
// RTCTRACE- WebRTC control-plane trace events emitted by vision_rtc.js
|
||||
// LIFECYCLE - page leave/return and forced reconnect lifecycle events
|
||||
//
|
||||
// Fully self-contained: only reads window.CarrotRtcPerf / CarrotVisionState
|
||||
// and listens to carrot:visionstatechange — no edits to the RTC logic.
|
||||
// dump() prepends an AUTO SUMMARY (verdict) so a tester can read the conclusion
|
||||
// without scrolling 200 lines. Phone-friendly export via a small "LOG" button
|
||||
// (download .txt + copy to clipboard) — no DevTools needed.
|
||||
//
|
||||
// Self-contained: reads window.CarrotRtcPerf / CarrotVisionState, listens to
|
||||
// carrot:visionstatechange, and binds to the live <video> element. No edits to
|
||||
// the RTC control logic.
|
||||
|
||||
const STORAGE_KEY = "carrot_vision_diag_log_v1";
|
||||
const MAX_ENTRIES = 1500; // ~50 min at the 2s stats cadence
|
||||
const STATS_INTERVAL_MS = 2000;
|
||||
const STORAGE_KEY = "carrot_vision_diag_log_v2";
|
||||
const MAX_ENTRIES = 3000;
|
||||
const MAX_CONSOLE_ENTRIES = 1200;
|
||||
const MAX_CONSOLE_ARG_CHARS = 1600;
|
||||
const PERSIST_THROTTLE_MS = 4000;
|
||||
const STAT_FAST_MS = 1000; // while connecting / reconnecting / pre-first-frame
|
||||
const STAT_SLOW_MS = 2500; // once decoding steadily
|
||||
|
||||
let entries = [];
|
||||
let lastPersist = 0;
|
||||
let lastPhaseSig = "";
|
||||
let prevFrm = null;
|
||||
let prevRecv = null;
|
||||
let prevCt = null;
|
||||
let prevDvf = null;
|
||||
let everDecoded = false;
|
||||
let lastCodecSig = "";
|
||||
let lastPathSig = "";
|
||||
let lastVinfoSig = "";
|
||||
let lastErr = "";
|
||||
let statsTimer = null;
|
||||
let consoleEntries = [];
|
||||
|
||||
function stringifyConsoleArg(value) {
|
||||
try {
|
||||
if (value instanceof Error) {
|
||||
return value.stack || value.message || String(value);
|
||||
}
|
||||
if (typeof value === "string") return value;
|
||||
if (value == null || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
} catch (_) {
|
||||
try { return String(value); } catch (__) { return "[unprintable]"; }
|
||||
}
|
||||
}
|
||||
|
||||
function recordConsole(level, args) {
|
||||
try {
|
||||
const parts = Array.prototype.slice.call(args || []).map(function (arg) {
|
||||
const text = stringifyConsoleArg(arg);
|
||||
return text.length > MAX_CONSOLE_ARG_CHARS ? text.slice(0, MAX_CONSOLE_ARG_CHARS) + "..." : text;
|
||||
});
|
||||
consoleEntries.push({
|
||||
t: Date.now(),
|
||||
level: level,
|
||||
message: parts.join(" "),
|
||||
});
|
||||
if (consoleEntries.length > MAX_CONSOLE_ENTRIES) {
|
||||
consoleEntries.splice(0, consoleEntries.length - MAX_CONSOLE_ENTRIES);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function hookConsole() {
|
||||
if (!window.console || window.console.__carrotVisionDiagHooked) return;
|
||||
["log", "info", "warn", "error", "debug"].forEach(function (level) {
|
||||
const original = window.console[level];
|
||||
if (typeof original !== "function") return;
|
||||
window.console[level] = function () {
|
||||
recordConsole(level, arguments);
|
||||
return original.apply(this, arguments);
|
||||
};
|
||||
});
|
||||
window.console.__carrotVisionDiagHooked = true;
|
||||
}
|
||||
|
||||
hookConsole();
|
||||
|
||||
// Restore a prior buffer (survives the reconnect/reload that often follows a drop).
|
||||
try {
|
||||
@@ -54,32 +126,118 @@
|
||||
return (typeof v === "number" && isFinite(v)) ? Number(v.toFixed(d == null ? 1 : d)) : null;
|
||||
}
|
||||
|
||||
function n(v) { return (typeof v === "number" && isFinite(v)) ? v : null; }
|
||||
|
||||
// 0x42=Baseline 0x4D=Main 0x58=Extended 0x64=High ... + constrained-baseline flag.
|
||||
function h264Profile(fmtp) {
|
||||
const m = /profile-level-id=([0-9a-fA-F]{6})/.exec(fmtp || "");
|
||||
if (!m) return "";
|
||||
const pli = m[1].toUpperCase();
|
||||
const prof = pli.slice(0, 2);
|
||||
const constraints = parseInt(pli.slice(2, 4), 16) || 0;
|
||||
const level = (parseInt(pli.slice(4, 6), 16) || 0) / 10;
|
||||
const map = { "42": "Baseline", "4D": "Main", "58": "Extended", "64": "High", "6E": "High10", "7A": "High422", "F4": "High444" };
|
||||
let name = map[prof] || ("0x" + prof);
|
||||
if (prof === "42" && (constraints & 0x40)) name = "ConstrainedBaseline";
|
||||
return name + " L" + level.toFixed(1) + " (" + pli + ")";
|
||||
}
|
||||
|
||||
// --- video element binding (re-binds when the element is recreated on reconnect) ---
|
||||
function getVideo() {
|
||||
return document.getElementById("carrotRoadVideo") || document.getElementById("rtcVideo");
|
||||
}
|
||||
|
||||
const VEVENTS = [
|
||||
"loadedmetadata", "loadeddata", "canplay", "canplaythrough", "play", "playing",
|
||||
"pause", "waiting", "stalled", "suspend", "emptied", "ended", "ratechange", "resize", "error",
|
||||
];
|
||||
|
||||
function ensureVideoBound() {
|
||||
const v = getVideo();
|
||||
if (!v || v.__carrotDiagBound) return;
|
||||
v.__carrotDiagBound = true;
|
||||
VEVENTS.forEach(function (name) {
|
||||
v.addEventListener(name, function () {
|
||||
const e = { name: name, w: v.videoWidth || 0, h: v.videoHeight || 0, rs: v.readyState, ct: num(v.currentTime, 1) };
|
||||
if (name === "error" && v.error) { e.code = v.error.code; e.msg = String(v.error.message || ""); }
|
||||
record("vevent", e);
|
||||
}, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
function emitChangeLines(st, p, inb, net, vid) {
|
||||
const codecSig = (p.codec || "") + "|" + (inb.decoderImplementation || "") + "|" + (p.codecParams || "");
|
||||
if (codecSig !== lastCodecSig && (p.codec || inb.decoderImplementation || p.codecParams)) {
|
||||
lastCodecSig = codecSig;
|
||||
record("codec", { mime: p.codec || "", decoder: inb.decoderImplementation || "", fmtp: p.codecParams || "", profile: h264Profile(p.codecParams) });
|
||||
}
|
||||
const pathSig = (net.protocol || "") + "|" + (net.localCandidateType || "") + "|" + (net.remoteCandidateType || "") + "|" + (net.resolutionLabel || "");
|
||||
if (pathSig !== lastPathSig && (net.protocol || net.resolutionLabel)) {
|
||||
lastPathSig = pathSig;
|
||||
record("path", { proto: net.protocol || "", local: net.localCandidateType || "", remote: net.remoteCandidateType || "", res: net.resolutionLabel || "", avail: num(net.availableIncomingMbps, 2) });
|
||||
}
|
||||
const vinfoSig = vid.readyState + "|" + vid.networkState + "|" + (vid.errorCode || 0) + "|" + vid.paused;
|
||||
if (vinfoSig !== lastVinfoSig) {
|
||||
lastVinfoSig = vinfoSig;
|
||||
record("vinfo", { rs: n(vid.readyState), ns: n(vid.networkState), code: vid.errorCode || 0, msg: vid.errorMessage || "", paused: typeof vid.paused === "boolean" ? vid.paused : null });
|
||||
}
|
||||
const err = p.error || "";
|
||||
if (err !== lastErr) { lastErr = err; if (err) record("perferr", { msg: err }); }
|
||||
}
|
||||
|
||||
function snapshotStats() {
|
||||
if (!document.body || document.body.dataset.page !== "carrot") return;
|
||||
ensureVideoBound();
|
||||
const st = window.CarrotVisionState || {};
|
||||
if (!st.active) return;
|
||||
const p = window.CarrotRtcPerf || {};
|
||||
const inb = p.inbound || {};
|
||||
const net = p.network || {};
|
||||
const vid = p.video || {};
|
||||
|
||||
emitChangeLines(st, p, inb, net, vid);
|
||||
|
||||
const frm = n(inb.framesDecoded);
|
||||
const recv = n(inb.framesReceived);
|
||||
const frmD = (frm != null && prevFrm != null) ? Math.max(0, frm - prevFrm) : null;
|
||||
const recvD = (recv != null && prevRecv != null) ? Math.max(0, recv - prevRecv) : null;
|
||||
const ct = num(vid.currentTime, 1);
|
||||
const dvf = n(vid.droppedVideoFrames);
|
||||
const ctD = (ct != null && prevCt != null) ? num(Math.max(0, ct - prevCt), 1) : null;
|
||||
const dvfD = (dvf != null && prevDvf != null) ? Math.max(0, dvf - prevDvf) : null;
|
||||
prevFrm = frm; prevRecv = recv;
|
||||
prevCt = ct; prevDvf = dvf;
|
||||
if (frm != null && frm > 0) everDecoded = true;
|
||||
|
||||
push({
|
||||
type: "stat",
|
||||
ph: st.phase,
|
||||
cs: st.controlState,
|
||||
conn: p.connectionState,
|
||||
ice: p.iceConnectionState,
|
||||
loss: num(net.lossPct, 1),
|
||||
jit: num(net.jitterMs, 0),
|
||||
br: num(net.bitrateMbps, 2),
|
||||
frm: inb.framesDecoded != null ? inb.framesDecoded : null,
|
||||
key: inb.keyFramesDecoded != null ? inb.keyFramesDecoded : null,
|
||||
lost: inb.packetsLost != null ? inb.packetsLost : null,
|
||||
ct: num(vid.currentTime, 1),
|
||||
rs: vid.readyState != null ? vid.readyState : null,
|
||||
err: p.error || "",
|
||||
ph: st.phase, cs: st.controlState, conn: p.connectionState, ice: p.iceConnectionState,
|
||||
recv: recv, recvD: recvD, frm: frm, frmD: frmD, key: n(inb.keyFramesDecoded),
|
||||
drop: n(inb.framesDropped), fps: num(inb.framesPerSecond, 1),
|
||||
dw: n(inb.frameWidth), dh: n(inb.frameHeight),
|
||||
loss: num(net.lossPct, 1), lost: n(inb.packetsLost),
|
||||
jit: num(net.jitterMs, 0), rtt: num(net.rttMs, 0),
|
||||
br: num(net.bitrateMbps, 2), avail: num(net.availableIncomingMbps, 2),
|
||||
nack: n(inb.nackCount), pli: n(inb.pliCount), fir: n(inb.firCount),
|
||||
frz: n(inb.freezeCount), frzMs: num(inb.totalFreezesDuration, 1),
|
||||
ct: ct, ctD: ctD, rs: n(vid.readyState), ns: n(vid.networkState),
|
||||
paused: typeof vid.paused === "boolean" ? vid.paused : null,
|
||||
dvf: dvf, dvfD: dvfD, cvf: n(vid.corruptedVideoFrames),
|
||||
});
|
||||
}
|
||||
|
||||
function statInterval() {
|
||||
const st = window.CarrotVisionState || {};
|
||||
const cs = st.controlState;
|
||||
if (cs === "connecting" || cs === "reconnecting" || !everDecoded) return STAT_FAST_MS;
|
||||
return STAT_SLOW_MS;
|
||||
}
|
||||
|
||||
function statsTick() {
|
||||
try { snapshotStats(); } catch (_) {}
|
||||
statsTimer = setTimeout(statsTick, statInterval());
|
||||
}
|
||||
|
||||
function fmtTime(ms) {
|
||||
const base = entries.length ? entries[0].t : ms;
|
||||
const s = Math.max(0, (ms - base) / 1000);
|
||||
@@ -88,9 +246,164 @@
|
||||
return String(mm).padStart(2, "0") + ":" + (ss.length < 4 ? "0" + ss : ss);
|
||||
}
|
||||
|
||||
function summary() {
|
||||
const stats = entries.filter((e) => e.type === "stat");
|
||||
const codecs = entries.filter((e) => e.type === "codec");
|
||||
let maxFrm = 0, maxRecv = 0, maxKey = 0, peakLoss = 0, maxFrz = 0, brSum = 0, brN = 0;
|
||||
let reachedReady = false, everPlayed = false, decRes = "";
|
||||
for (const s of stats) {
|
||||
if (typeof s.frm === "number") maxFrm = Math.max(maxFrm, s.frm);
|
||||
if (typeof s.recv === "number") maxRecv = Math.max(maxRecv, s.recv);
|
||||
if (typeof s.key === "number") maxKey = Math.max(maxKey, s.key);
|
||||
if (typeof s.rs === "number" && s.rs >= 2) reachedReady = true;
|
||||
if (typeof s.ct === "number" && s.ct > 0) everPlayed = true;
|
||||
if (typeof s.loss === "number") peakLoss = Math.max(peakLoss, s.loss);
|
||||
if (typeof s.frz === "number") maxFrz = Math.max(maxFrz, s.frz);
|
||||
if (typeof s.br === "number" && s.br > 0) { brSum += s.br; brN++; }
|
||||
if (typeof s.dw === "number" && s.dw > 0 && typeof s.dh === "number" && s.dh > 0) decRes = s.dw + "x" + s.dh;
|
||||
}
|
||||
let videoError = false;
|
||||
for (const e of entries) {
|
||||
if (e.type === "vevent" && e.name === "error") videoError = true;
|
||||
if (e.type === "vinfo" && e.code) videoError = true;
|
||||
}
|
||||
const reconnects = entries.filter((e) => e.type === "phase" && e.cs === "reconnecting").length;
|
||||
const starts = entries.filter((e) => e.type === "phase" && /user start/.test(e.reason || "")).length;
|
||||
const decoders = [...new Set(codecs.map((c) => c.decoder).filter(Boolean))];
|
||||
const profiles = [...new Set(codecs.map((c) => c.profile).filter(Boolean))];
|
||||
const mimes = [...new Set(codecs.map((c) => c.mime).filter(Boolean))];
|
||||
const decodedEver = maxFrm > 0, receivedEver = maxRecv > 0;
|
||||
|
||||
let verdict;
|
||||
if (receivedEver && !decodedEver)
|
||||
verdict = "DECODE STALL — frames RECEIVED (" + maxRecv + ") but framesDecoded stayed 0. Viewer never decoded a renderable frame -> likely codec/profile/hardware-decoder incompatibility.";
|
||||
else if (decodedEver && !reachedReady)
|
||||
verdict = "DECODED frames but <video> never reached readyState>=2 -> render/attach problem, not decode.";
|
||||
else if (peakLoss >= 5)
|
||||
verdict = "PACKET LOSS elevated (peak " + peakLoss.toFixed(1) + "%) -> network path is the prime suspect.";
|
||||
else if (decodedEver && reachedReady)
|
||||
verdict = "Video DID play (frames decoded, readyState>=2). For any mid-stream drops, inspect freeze/loss spikes below.";
|
||||
else
|
||||
verdict = "Inconclusive — a media flow likely never established (no frames received).";
|
||||
|
||||
return [
|
||||
"# ===== AUTO SUMMARY =====",
|
||||
"# verdict: " + verdict,
|
||||
"# framesReceived(max)=" + maxRecv + " framesDecoded(max)=" + maxFrm + " keyFrames(max)=" + maxKey,
|
||||
"# reachedReadyState>=2=" + reachedReady + " everPlayed(ct>0)=" + everPlayed + " decodedResolution=" + (decRes || "never"),
|
||||
"# peakLoss=" + peakLoss.toFixed(1) + "% avgBitrate=" + (brN ? (brSum / brN).toFixed(2) : "?") + "Mbps maxFreezeCount=" + maxFrz,
|
||||
"# starts=" + starts + " reconnects=" + reconnects + " videoElementError=" + videoError,
|
||||
"# codec=" + (mimes.join(",") || "?") + " profile=" + (profiles.join(",") || "?") + " decoder=" + (decoders.join(",") || "?"),
|
||||
"# ========================",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
function koreanHealthSummary() {
|
||||
const stats = entries.filter((e) => e.type === "stat");
|
||||
const latest = stats.length ? stats[stats.length - 1] : null;
|
||||
const codec = entries.filter((e) => e.type === "codec").slice(-1)[0] || {};
|
||||
const path = entries.filter((e) => e.type === "path").slice(-1)[0] || {};
|
||||
let verdict = "아직 판단할 통계가 충분하지 않습니다.";
|
||||
if (latest) {
|
||||
const connected = /connected|completed/i.test(String(latest.conn || "")) || /connected|completed/i.test(String(latest.ice || ""));
|
||||
const recv = Number(latest.recv || 0);
|
||||
const frm = Number(latest.frm || 0);
|
||||
const loss = Number(latest.loss || 0);
|
||||
if (recv > 0 && frm > 0) {
|
||||
verdict = "영상 수신과 디코드가 정상 진행 중입니다.";
|
||||
} else if (recv > 0 && frm <= 0) {
|
||||
verdict = "영상 RTP는 수신되지만 브라우저 디코드/렌더가 진행되지 않습니다.";
|
||||
} else if (connected && recv <= 0) {
|
||||
verdict = "WebRTC 연결은 되었지만 영상 RTP/첫 프레임이 아직 들어오지 않았습니다.";
|
||||
} else if (loss >= 5) {
|
||||
verdict = "패킷 손실이 높아 네트워크 경로 문제가 의심됩니다.";
|
||||
}
|
||||
}
|
||||
return [
|
||||
"# ===== 네트워크 건강 요약 =====",
|
||||
"# 판정: " + verdict,
|
||||
"# 상태: " + (latest ? `${latest.ph}/${latest.cs}` : "?"),
|
||||
"# ICE/연결: " + (latest ? `${latest.conn}/${latest.ice}` : "?"),
|
||||
"# 경로: " + ((path.proto || "?") + " " + (path.local || "?") + " -> " + (path.remote || "?")),
|
||||
"# 해상도/FPS: " + (latest ? `${latest.dw || "?"}x${latest.dh || "?"} / ${latest.fps || "?"}fps` : "?"),
|
||||
"# 프레임: 수신 " + (latest?.recv ?? "?") + " / 디코드 " + (latest?.frm ?? "?") + " / 키프레임 " + (latest?.key ?? "?"),
|
||||
"# 네트워크: RTT " + (latest?.rtt ?? "?") + "ms / 지터 " + (latest?.jit ?? "?") + "ms / 손실 " + (latest?.loss ?? "?") + "% / 비트레이트 " + (latest?.br ?? "?") + "Mbps",
|
||||
"# 코덱: " + (codec.mime || "?") + " / " + (codec.profile || "?"),
|
||||
"# 프리즈: " + (latest?.frz ?? "?") + "회 / " + (latest?.frzMs ?? "?") + "ms",
|
||||
"# ============================",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
function fmtLine(e) {
|
||||
const ts = fmtTime(e.t);
|
||||
switch (e.type) {
|
||||
case "phase":
|
||||
return "[" + ts + "] PHASE " + e.ph + " (" + e.cs + ") reason=" + (e.reason || "");
|
||||
case "stat":
|
||||
return "[" + ts + "] STAT " + e.ph + "/" + e.cs + " " + e.conn + "/" + e.ice +
|
||||
" | recv=" + e.recv + "Δ" + e.recvD + " frm=" + e.frm + "Δ" + e.frmD + " key=" + e.key +
|
||||
" drop=" + e.drop + " fps=" + e.fps + " dec=" + e.dw + "x" + e.dh +
|
||||
" | loss=" + e.loss + "% lost=" + e.lost + " jit=" + e.jit + "ms rtt=" + e.rtt + "ms br=" + e.br + "M avail=" + e.avail + "M" +
|
||||
" nack=" + e.nack + " pli=" + e.pli + " fir=" + e.fir +
|
||||
" | ct=" + e.ct + "?" + e.ctD + " rs=" + e.rs + " ns=" + e.ns + " paused=" + e.paused + " dvf=" + e.dvf + "?" + e.dvfD + " cvf=" + e.cvf +
|
||||
" frz=" + e.frz + "/" + e.frzMs + "ms";
|
||||
case "codec":
|
||||
return "[" + ts + "] CODEC mime=" + e.mime + " profile=" + (e.profile || "?") + " decoder=" + (e.decoder || "?") + " fmtp=\"" + (e.fmtp || "") + "\"";
|
||||
case "path":
|
||||
return "[" + ts + "] PATH proto=" + e.proto + " " + e.local + "->" + e.remote + " res=" + (e.res || "?") + " avail=" + e.avail + "M";
|
||||
case "vinfo":
|
||||
return "[" + ts + "] VINFO rs=" + e.rs + " ns=" + e.ns + " paused=" + e.paused + (e.code ? " ERROR " + e.code + ":" + (e.msg || "") : "");
|
||||
case "vevent":
|
||||
return "[" + ts + "] VEVENT " + e.name + " " + e.w + "x" + e.h + " rs=" + e.rs + " ct=" + e.ct + (e.code ? " ERROR " + e.code + ":" + (e.msg || "") : "");
|
||||
case "vis":
|
||||
return "[" + ts + "] VIS " + e.state;
|
||||
case "netchg":
|
||||
return "[" + ts + "] NETCHG type=" + e.netType + " downlink=" + e.downlink + "Mbps rtt=" + e.rtt + "ms";
|
||||
case "perferr":
|
||||
return "[" + ts + "] PERFERR " + e.msg;
|
||||
case "rtctrace":
|
||||
return "[" + ts + "] RTCTRACE " + e.event + " pc=" + (e.pc || "?") +
|
||||
" conn=" + (e.conn || "?") + "/" + (e.ice || "?") +
|
||||
" frm=" + e.framesDecoded + " key=" + e.keyFramesDecoded +
|
||||
" br=" + e.bitrateMbps + "M rtt=" + e.rttMs + "ms" +
|
||||
" rs=" + e.readyState + " track=" + (e.trackState || "?") +
|
||||
(e.stallSamples != null ? " stallSamples=" + e.stallSamples : "") +
|
||||
(e.receivedProgress != null ? " rtpProgress=" + e.receivedProgress : "") +
|
||||
(e.reason ? " reason=" + e.reason : "") +
|
||||
(e.timeoutMs ? " timeoutMs=" + e.timeoutMs : "");
|
||||
case "lifecycle":
|
||||
return "[" + ts + "] LIFECYCLE " + e.event +
|
||||
(e.page ? " page=" + e.page : "") +
|
||||
(e.reason ? " reason=" + e.reason : "") +
|
||||
(e.visionActive != null ? " visionActive=" + e.visionActive : "") +
|
||||
(e.forceFetch != null ? " forceFetch=" + e.forceFetch : "");
|
||||
default:
|
||||
return "[" + ts + "] " + e.type + " " + JSON.stringify(e);
|
||||
}
|
||||
}
|
||||
|
||||
function safeJson(value) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error?.message || String(error), fallback: String(value) }, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonSection(title, value) {
|
||||
return [
|
||||
"",
|
||||
"# ===== " + title + " =====",
|
||||
safeJson(value),
|
||||
"# ===== END " + title + " =====",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function dump() {
|
||||
const conn = navigator.connection || {};
|
||||
const lines = [
|
||||
const head = [
|
||||
"# Carrot Vision diagnostic log",
|
||||
"# exported: " + new Date().toISOString(),
|
||||
"# ua: " + navigator.userAgent,
|
||||
@@ -99,20 +412,147 @@
|
||||
"# entries: " + entries.length,
|
||||
"",
|
||||
];
|
||||
for (const e of entries) {
|
||||
const ts = fmtTime(e.t);
|
||||
if (e.type === "phase") {
|
||||
lines.push("[" + ts + "] PHASE " + e.ph + " (" + e.cs + ") reason=" + (e.reason || ""));
|
||||
} else if (e.type === "stat") {
|
||||
lines.push("[" + ts + "] STAT ph=" + e.ph + " conn=" + e.conn + " ice=" + e.ice +
|
||||
" loss=" + e.loss + "% jit=" + e.jit + "ms br=" + e.br + "M" +
|
||||
" frm=" + e.frm + " key=" + e.key + " lost=" + e.lost + " ct=" + e.ct + " rs=" + e.rs +
|
||||
(e.err ? " err=" + e.err : ""));
|
||||
} else {
|
||||
lines.push("[" + ts + "] " + e.type + " " + JSON.stringify(e));
|
||||
const body = entries.map(fmtLine);
|
||||
return head.concat(summary(), koreanHealthSummary(), body).join("\n");
|
||||
}
|
||||
|
||||
async function collectBrowserRawSnapshot() {
|
||||
const snapshot = {
|
||||
capturedAtMs: Date.now(),
|
||||
capturedAtIso: new Date().toISOString(),
|
||||
document: {
|
||||
visibilityState: document.visibilityState,
|
||||
hidden: document.hidden,
|
||||
url: window.location.href,
|
||||
referrer: document.referrer || "",
|
||||
fullscreenElement: Boolean(document.fullscreenElement),
|
||||
pictureInPictureElement: Boolean(document.pictureInPictureElement),
|
||||
},
|
||||
navigator: {
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform || "",
|
||||
language: navigator.language || "",
|
||||
languages: navigator.languages || [],
|
||||
hardwareConcurrency: navigator.hardwareConcurrency ?? null,
|
||||
deviceMemory: navigator.deviceMemory ?? null,
|
||||
maxTouchPoints: navigator.maxTouchPoints ?? null,
|
||||
cookieEnabled: navigator.cookieEnabled ?? null,
|
||||
onLine: navigator.onLine ?? null,
|
||||
connection: navigator.connection ? {
|
||||
effectiveType: navigator.connection.effectiveType || "",
|
||||
downlink: navigator.connection.downlink ?? null,
|
||||
rtt: navigator.connection.rtt ?? null,
|
||||
saveData: navigator.connection.saveData ?? null,
|
||||
} : null,
|
||||
},
|
||||
screen: {
|
||||
width: window.screen?.width ?? null,
|
||||
height: window.screen?.height ?? null,
|
||||
availWidth: window.screen?.availWidth ?? null,
|
||||
availHeight: window.screen?.availHeight ?? null,
|
||||
orientation: window.screen?.orientation ? {
|
||||
type: window.screen.orientation.type || "",
|
||||
angle: window.screen.orientation.angle ?? null,
|
||||
} : null,
|
||||
innerWidth: window.innerWidth,
|
||||
innerHeight: window.innerHeight,
|
||||
outerWidth: window.outerWidth,
|
||||
outerHeight: window.outerHeight,
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
},
|
||||
performance: {
|
||||
timeOrigin: performance.timeOrigin ?? null,
|
||||
now: performance.now ? performance.now() : null,
|
||||
memory: performance.memory ? {
|
||||
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit ?? null,
|
||||
totalJSHeapSize: performance.memory.totalJSHeapSize ?? null,
|
||||
usedJSHeapSize: performance.memory.usedJSHeapSize ?? null,
|
||||
} : null,
|
||||
navigation: performance.getEntriesByType ? performance.getEntriesByType("navigation").map(function (entry) {
|
||||
return {
|
||||
type: entry.type || "",
|
||||
startTime: entry.startTime,
|
||||
domContentLoadedEventEnd: entry.domContentLoadedEventEnd,
|
||||
loadEventEnd: entry.loadEventEnd,
|
||||
transferSize: entry.transferSize,
|
||||
encodedBodySize: entry.encodedBodySize,
|
||||
decodedBodySize: entry.decodedBodySize,
|
||||
};
|
||||
}) : [],
|
||||
},
|
||||
state: {
|
||||
carrotVision: window.CarrotVisionState || null,
|
||||
rtcPerf: window.CarrotRtcPerf || null,
|
||||
visionTest: window.CarrotVisionTestState || null,
|
||||
},
|
||||
rtc: null,
|
||||
rawStatsHistory: null,
|
||||
errors: [],
|
||||
};
|
||||
try {
|
||||
if (typeof window.rtcDiagnosticSnapshot === "function") {
|
||||
snapshot.rtc = await window.rtcDiagnosticSnapshot();
|
||||
} else if (window.CarrotVisionRtc && typeof window.CarrotVisionRtc.diagnosticSnapshot === "function") {
|
||||
snapshot.rtc = await window.CarrotVisionRtc.diagnosticSnapshot();
|
||||
}
|
||||
} catch (error) {
|
||||
snapshot.errors.push({ source: "rtcDiagnosticSnapshot", message: error?.message || String(error) });
|
||||
}
|
||||
return lines.join("\n");
|
||||
try {
|
||||
if (window.CarrotVisionRtc && typeof window.CarrotVisionRtc.rawStatsHistory === "function") {
|
||||
snapshot.rawStatsHistory = window.CarrotVisionRtc.rawStatsHistory();
|
||||
} else if (typeof window.rtcRawStatsHistory === "function") {
|
||||
snapshot.rawStatsHistory = window.rtcRawStatsHistory();
|
||||
}
|
||||
} catch (error) {
|
||||
snapshot.errors.push({ source: "rtcRawStatsHistory", message: error?.message || String(error) });
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function fetchServerSnapshot() {
|
||||
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
||||
const timer = controller ? setTimeout(function () { controller.abort(); }, 8000) : null;
|
||||
try {
|
||||
const response = await fetch("/api/vision_diag/server_snapshot", {
|
||||
cache: "no-store",
|
||||
signal: controller ? controller.signal : undefined,
|
||||
});
|
||||
const body = await response.json().catch(function () { return null; });
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error?.message || String(error),
|
||||
};
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function dumpBundle() {
|
||||
persist(true);
|
||||
const browserRaw = await collectBrowserRawSnapshot();
|
||||
const serverSnapshot = await fetchServerSnapshot();
|
||||
return [
|
||||
dump(),
|
||||
jsonSection("BROWSER RAW SNAPSHOT", browserRaw),
|
||||
jsonSection("COMMA SERVER SNAPSHOT", serverSnapshot),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function dumpConsole() {
|
||||
return [
|
||||
"# Carrot Vision browser console",
|
||||
"# exported: " + new Date().toISOString(),
|
||||
"# ua: " + navigator.userAgent,
|
||||
"# entries: " + consoleEntries.length,
|
||||
jsonSection("BROWSER CONSOLE", consoleEntries.slice(-MAX_CONSOLE_ENTRIES)),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function copy(text) {
|
||||
@@ -135,9 +575,9 @@
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function download() {
|
||||
async function download() {
|
||||
persist(true);
|
||||
const text = dump();
|
||||
const text = await dumpBundle();
|
||||
try {
|
||||
const blob = new Blob([text], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -151,30 +591,99 @@
|
||||
copy(text); // also stash on the clipboard so they can paste directly
|
||||
}
|
||||
|
||||
async function uploadDiscord() {
|
||||
persist(true);
|
||||
const text = await dumpBundle();
|
||||
const consoleText = dumpConsole();
|
||||
const filename = "carrot_vision_diag_" + new Date().toISOString().replace(/[:.]/g, "-") + ".txt";
|
||||
const consoleFilename = filename.replace(/\.txt$/, "_console.txt");
|
||||
const response = await fetch("/api/vision_diag/upload_discord", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
source: "web",
|
||||
filename: filename,
|
||||
bundle: text,
|
||||
consoleFilename: consoleFilename,
|
||||
console: consoleText,
|
||||
}),
|
||||
});
|
||||
const body = await response.json().catch(function () { return null; });
|
||||
if (!response.ok || !body || !body.ok) {
|
||||
const detail = body?.discord?.error || body?.error || response.statusText || "upload failed";
|
||||
throw new Error(detail);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
entries = [];
|
||||
lastPhaseSig = "";
|
||||
prevFrm = null; prevRecv = null; prevCt = null; prevDvf = null; everDecoded = false;
|
||||
lastCodecSig = ""; lastPathSig = ""; lastVinfoSig = ""; lastErr = "";
|
||||
try { window.localStorage && window.localStorage.removeItem(STORAGE_KEY); } catch (_) {}
|
||||
}
|
||||
|
||||
// --- capture ---
|
||||
// Phase / recovery transitions (recovery sets phase=recovering with a reason).
|
||||
// De-dupe so the many same-phase state updates don't spam the log.
|
||||
window.addEventListener("carrot:visionstatechange", function (ev) {
|
||||
const st = (ev && ev.detail && ev.detail.state) || window.CarrotVisionState;
|
||||
if (!st) return;
|
||||
ensureVideoBound();
|
||||
const sig = (st.phase || "") + "|" + (st.reason || "");
|
||||
if (sig === lastPhaseSig) return;
|
||||
lastPhaseSig = sig;
|
||||
record("phase", { ph: st.phase, cs: st.controlState, reason: st.reason || "" });
|
||||
});
|
||||
|
||||
setInterval(snapshotStats, STATS_INTERVAL_MS);
|
||||
window.addEventListener("carrot:rtctrace", function (ev) {
|
||||
const detail = ev && ev.detail;
|
||||
if (!detail || typeof detail !== "object") return;
|
||||
record("rtctrace", detail);
|
||||
});
|
||||
|
||||
window.addEventListener("carrot:visionlifecycle", function (ev) {
|
||||
const detail = ev && ev.detail;
|
||||
if (!detail || typeof detail !== "object") return;
|
||||
record("lifecycle", detail);
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
record("vis", { state: document.visibilityState });
|
||||
});
|
||||
|
||||
window.addEventListener("error", function (event) {
|
||||
recordConsole("window.error", [
|
||||
event?.message || "error",
|
||||
event?.filename || "",
|
||||
event?.lineno || "",
|
||||
event?.colno || "",
|
||||
event?.error?.stack || event?.error || "",
|
||||
]);
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", function (event) {
|
||||
recordConsole("unhandledrejection", [
|
||||
event?.reason?.stack || event?.reason?.message || event?.reason || "",
|
||||
]);
|
||||
});
|
||||
|
||||
const netInfo = navigator.connection;
|
||||
if (netInfo && typeof netInfo.addEventListener === "function") {
|
||||
netInfo.addEventListener("change", function () {
|
||||
record("netchg", {
|
||||
netType: netInfo.effectiveType || "?",
|
||||
downlink: netInfo.downlink != null ? netInfo.downlink : null,
|
||||
rtt: netInfo.rtt != null ? netInfo.rtt : null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
statsTick();
|
||||
window.addEventListener("beforeunload", function () { persist(true); });
|
||||
|
||||
// --- phone-friendly export button on the drive page ---
|
||||
function ensureButton() {
|
||||
if (!document.body || document.getElementById("carrotDiagBtn")) return;
|
||||
if (!document.body || document.getElementById("carrotRtcPerfLogBtn") || document.getElementById("carrotDiagBtn")) return;
|
||||
const btn = document.createElement("button");
|
||||
btn.id = "carrotDiagBtn";
|
||||
btn.type = "button";
|
||||
@@ -224,7 +733,10 @@
|
||||
window.CarrotVisionDiag = {
|
||||
record: record,
|
||||
dump: dump,
|
||||
dumpBundle: dumpBundle,
|
||||
dumpConsole: dumpConsole,
|
||||
download: download,
|
||||
uploadDiscord: uploadDiscord,
|
||||
copy: copy,
|
||||
clear: clear,
|
||||
get entries() { return entries; },
|
||||
|
||||
@@ -7,6 +7,18 @@ var setCarrotVisionPhase = window.CarrotVisionSetPhase;
|
||||
var setCarrotVisionState = window.CarrotVisionSetState;
|
||||
|
||||
const RTC_STATS_POLL_MS = 1000;
|
||||
const RTC_RAW_STATS_HISTORY_MAX = 60;
|
||||
const RTC_RAW_STATS_KEEP_TYPES = new Set([
|
||||
"candidate-pair",
|
||||
"codec",
|
||||
"inbound-rtp",
|
||||
"local-candidate",
|
||||
"media-source",
|
||||
"remote-candidate",
|
||||
"remote-inbound-rtp",
|
||||
"track",
|
||||
"transport",
|
||||
]);
|
||||
// Tolerance philosophy: a frame stall while the PC/ICE is still connected and
|
||||
// the track has not ended is almost always TRANSIENT (a brief source-side
|
||||
// encoder/CPU hiccup on the comma device, or a momentary viewer main-thread
|
||||
@@ -21,8 +33,9 @@ const RTC_STATS_POLL_MS = 1000;
|
||||
// wait for the source to resume; only fall back to a full reconnect as a
|
||||
// last resort. Genuine permanent failures (ICE/connection failed|closed,
|
||||
// remote track ended) still reconnect immediately via their own handlers.
|
||||
const RTC_FREEZE_MAX_STALL_SAMPLES = 20; // ~20s live-connection stall before last-resort reconnect (was 8s)
|
||||
const RTC_INITIAL_FRAME_MAX_STALL_SAMPLES = 12; // ~12s for first frame before reconnect (was 5s)
|
||||
const RTC_FREEZE_MAX_STALL_SAMPLES = 8; // source/network progress also stopped: wait a little longer
|
||||
const RTC_DECODE_STALL_MAX_SAMPLES = 4; // RTP still arrives but decode/render is stuck: recreate quickly
|
||||
const RTC_INITIAL_FRAME_MAX_STALL_SAMPLES = 6; // ~6s for first frame before reconnect
|
||||
const RTC_FREEZE_CURRENT_TIME_EPSILON = 0.05;
|
||||
const RTC_FREEZE_RECOVERY_COOLDOWN_MS = 4000;
|
||||
const RTC_RESUME_PROGRESS_CHECK_MS = 900;
|
||||
@@ -33,10 +46,10 @@ const RTC_RESUME_PROGRESS_CHECK_MS = 900;
|
||||
// persistent failure.
|
||||
const RTC_RETRY_BASE_MS = 350; // first retry delay after a failed/closed peer (was 700)
|
||||
const RTC_ICE_GATHER_TIMEOUT_MS = 700; // host-only candidates gather near-instantly; tighter cap (was 1200)
|
||||
const RTC_INITIAL_TRACK_TIMEOUT_MS = 4000; // track should arrive fast; give a loaded device a little more slack (was 2800)
|
||||
const RTC_INITIAL_FRAME_TIMEOUT_MS = 12000; // align with the first-frame stall window above (was 6500)
|
||||
const RTC_STREAM_FETCH_TIMEOUT_MS = 6500;
|
||||
const RTC_PENDING_STALE_MS = 12000; // align pending-peer stale with the first-frame window (was 9000)
|
||||
const RTC_INITIAL_TRACK_TIMEOUT_MS = 3000; // track should arrive quickly on host-only WebRTC
|
||||
const RTC_INITIAL_FRAME_TIMEOUT_MS = 6000; // ICE/track connected but no first frame -> recreate /stream session
|
||||
const RTC_STREAM_FETCH_TIMEOUT_MS = 5000;
|
||||
const RTC_PENDING_STALE_MS = 7000;
|
||||
const CARROT_VISION_HEALTH_POLL_MS = 2000;
|
||||
const RTC_PERF_STATE = {
|
||||
active: false,
|
||||
@@ -44,6 +57,7 @@ const RTC_PERF_STATE = {
|
||||
connectionState: "idle",
|
||||
iceConnectionState: "new",
|
||||
codec: "",
|
||||
codecParams: "",
|
||||
inbound: null,
|
||||
video: null,
|
||||
network: null,
|
||||
@@ -60,12 +74,14 @@ const RTC_RATE_STATE = {
|
||||
const RTC_FREEZE_STATE = {
|
||||
stallSamples: 0,
|
||||
lastFramesDecoded: null,
|
||||
lastFramesReceived: null,
|
||||
lastTotalVideoFrames: null,
|
||||
lastCurrentTime: null,
|
||||
lastRecoveredAtMs: 0,
|
||||
consecutiveRecoveries: 0,
|
||||
everDecodedFrame: false,
|
||||
};
|
||||
const RTC_RAW_STATS_HISTORY = [];
|
||||
let RTC_RECOVERY_T = null;
|
||||
let RTC_VIDEO_EVENTS_BOUND = false;
|
||||
let RTC_WAIT_TRACK_PC = null;
|
||||
@@ -109,15 +125,172 @@ function rtcBuildTraceSnapshot(pc = RTC_PC) {
|
||||
}
|
||||
|
||||
function rtcTrace(event, extra = {}, pc = RTC_PC) {
|
||||
if (!RTC_TRACE_ENABLED) return;
|
||||
console.log("[RTC TRACE]", {
|
||||
const detail = {
|
||||
ts: Date.now(),
|
||||
iso: new Date().toISOString(),
|
||||
event,
|
||||
pc: rtcPcLabel(pc),
|
||||
...rtcBuildTraceSnapshot(pc),
|
||||
...extra,
|
||||
});
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent("carrot:rtctrace", { detail }));
|
||||
if (RTC_TRACE_ENABLED) console.log("[RTC TRACE]", detail);
|
||||
}
|
||||
|
||||
function rtcDescriptionSnapshot(description) {
|
||||
if (!description) return null;
|
||||
return {
|
||||
type: description.type || "",
|
||||
sdp: description.sdp || "",
|
||||
sdpBytes: String(description.sdp || "").length,
|
||||
};
|
||||
}
|
||||
|
||||
function rtcPlainStatsReport(report) {
|
||||
const snapshot = {};
|
||||
try {
|
||||
Object.entries(report || {}).forEach(([key, value]) => {
|
||||
snapshot[key] = value;
|
||||
});
|
||||
} catch {}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function rtcImportantStatsReports(stats) {
|
||||
if (!stats) return [];
|
||||
const reports = [];
|
||||
try {
|
||||
stats.forEach((report) => {
|
||||
if (!report || !RTC_RAW_STATS_KEEP_TYPES.has(report.type)) return;
|
||||
if (report.type === "inbound-rtp" && report.kind && report.kind !== "video") return;
|
||||
if (report.type === "media-source" && report.kind && report.kind !== "video") return;
|
||||
if (report.type === "track" && report.kind && report.kind !== "video") return;
|
||||
reports.push(rtcPlainStatsReport(report));
|
||||
});
|
||||
} catch {}
|
||||
return reports;
|
||||
}
|
||||
|
||||
function rtcTrackSnapshot(track) {
|
||||
if (!track) return null;
|
||||
let settings = null;
|
||||
let constraints = null;
|
||||
let capabilities = null;
|
||||
try { settings = track.getSettings?.() || null; } catch {}
|
||||
try { constraints = track.getConstraints?.() || null; } catch {}
|
||||
try { capabilities = track.getCapabilities?.() || null; } catch {}
|
||||
return {
|
||||
id: track.id || "",
|
||||
kind: track.kind || "",
|
||||
label: track.label || "",
|
||||
enabled: typeof track.enabled === "boolean" ? track.enabled : null,
|
||||
muted: typeof track.muted === "boolean" ? track.muted : null,
|
||||
readyState: track.readyState || "",
|
||||
contentHint: track.contentHint || "",
|
||||
settings,
|
||||
constraints,
|
||||
capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
function rtcStatsReportSnapshot(report) {
|
||||
return rtcPlainStatsReport(report);
|
||||
}
|
||||
|
||||
function rtcMediaSnapshot(video = getRtcVideoElement()) {
|
||||
const stream = video?.srcObject || null;
|
||||
const tracks = typeof stream?.getTracks === "function" ? stream.getTracks().map(rtcTrackSnapshot) : [];
|
||||
return {
|
||||
video: {
|
||||
id: video?.id || "",
|
||||
width: Number(video?.videoWidth || 0),
|
||||
height: Number(video?.videoHeight || 0),
|
||||
readyState: Number(video?.readyState || 0),
|
||||
networkState: Number(video?.networkState || 0),
|
||||
currentTime: Number.isFinite(Number(video?.currentTime)) ? Number(video.currentTime) : null,
|
||||
paused: typeof video?.paused === "boolean" ? video.paused : null,
|
||||
muted: typeof video?.muted === "boolean" ? video.muted : null,
|
||||
srcObjectActive: typeof stream?.active === "boolean" ? stream.active : null,
|
||||
tracks,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rtcPushRawStatsHistory(pc, stats, reason = "poll") {
|
||||
try {
|
||||
const video = getRtcVideoElement();
|
||||
RTC_RAW_STATS_HISTORY.push({
|
||||
capturedAtMs: Date.now(),
|
||||
capturedAtIso: new Date().toISOString(),
|
||||
reason,
|
||||
pcLabel: pc ? rtcPcLabel(pc) : null,
|
||||
pc: pc ? {
|
||||
connectionState: pc.connectionState || "",
|
||||
iceConnectionState: pc.iceConnectionState || "",
|
||||
iceGatheringState: pc.iceGatheringState || "",
|
||||
signalingState: pc.signalingState || "",
|
||||
} : null,
|
||||
trace: rtcBuildTraceSnapshot(pc),
|
||||
media: rtcMediaSnapshot(video),
|
||||
perf: {
|
||||
inbound: RTC_PERF_STATE.inbound,
|
||||
video: RTC_PERF_STATE.video,
|
||||
network: RTC_PERF_STATE.network,
|
||||
codec: RTC_PERF_STATE.codec,
|
||||
codecParams: RTC_PERF_STATE.codecParams,
|
||||
},
|
||||
stats: rtcImportantStatsReports(stats),
|
||||
});
|
||||
if (RTC_RAW_STATS_HISTORY.length > RTC_RAW_STATS_HISTORY_MAX) {
|
||||
RTC_RAW_STATS_HISTORY.splice(0, RTC_RAW_STATS_HISTORY.length - RTC_RAW_STATS_HISTORY_MAX);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function rtcDiagnosticSnapshot() {
|
||||
const pc = RTC_PC || RTC_PENDING_PC || null;
|
||||
const video = getRtcVideoElement();
|
||||
let stats = [];
|
||||
let statsError = "";
|
||||
if (pc) {
|
||||
try {
|
||||
const report = await pc.getStats(null);
|
||||
stats = Array.from(report.values()).map(rtcStatsReportSnapshot);
|
||||
} catch (error) {
|
||||
statsError = error?.message || String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
capturedAtMs: Date.now(),
|
||||
capturedAtIso: new Date().toISOString(),
|
||||
active: shouldRunCarrotVisionRealtime(),
|
||||
connecting: _rtcConnecting,
|
||||
failCount: RTC_FAIL_COUNT,
|
||||
retryArmed: Boolean(RTC_RETRY_T),
|
||||
trackTimeoutArmed: Boolean(RTC_WAIT_TRACK_T),
|
||||
firstFrameTimeoutArmed: Boolean(RTC_WAIT_FIRST_FRAME_T),
|
||||
pendingPc: RTC_PENDING_PC ? rtcPcLabel(RTC_PENDING_PC) : null,
|
||||
activePc: RTC_PC ? rtcPcLabel(RTC_PC) : null,
|
||||
trace: rtcBuildTraceSnapshot(pc),
|
||||
freezeState: { ...RTC_FREEZE_STATE },
|
||||
perfState: window.CarrotRtcPerf || null,
|
||||
visionState: window.CarrotVisionState || null,
|
||||
testState: window.CarrotVisionTestState || null,
|
||||
video: rtcMediaSnapshot(video).video,
|
||||
pc: pc ? {
|
||||
label: rtcPcLabel(pc),
|
||||
connectionState: pc.connectionState || "",
|
||||
iceConnectionState: pc.iceConnectionState || "",
|
||||
iceGatheringState: pc.iceGatheringState || "",
|
||||
signalingState: pc.signalingState || "",
|
||||
localDescription: rtcDescriptionSnapshot(pc.localDescription),
|
||||
remoteDescription: rtcDescriptionSnapshot(pc.remoteDescription),
|
||||
} : null,
|
||||
statsError,
|
||||
stats,
|
||||
rawStatsHistory: RTC_RAW_STATS_HISTORY.slice(-RTC_RAW_STATS_HISTORY_MAX),
|
||||
};
|
||||
}
|
||||
|
||||
function rtcPcSawTrack(pc) {
|
||||
@@ -216,6 +389,7 @@ function resetRtcPerfState() {
|
||||
function rtcResetFreezeWatchdog() {
|
||||
RTC_FREEZE_STATE.stallSamples = 0;
|
||||
RTC_FREEZE_STATE.lastFramesDecoded = null;
|
||||
RTC_FREEZE_STATE.lastFramesReceived = null;
|
||||
RTC_FREEZE_STATE.lastTotalVideoFrames = null;
|
||||
RTC_FREEZE_STATE.lastCurrentTime = null;
|
||||
RTC_FREEZE_STATE.everDecodedFrame = false;
|
||||
@@ -254,7 +428,11 @@ function readRtcVideoPlaybackQuality(video) {
|
||||
width: Number(video.videoWidth || 0),
|
||||
height: Number(video.videoHeight || 0),
|
||||
readyState: Number(video.readyState || 0),
|
||||
networkState: Number(video.networkState || 0),
|
||||
currentTime: Number(video.currentTime || 0),
|
||||
paused: Boolean(video.paused),
|
||||
errorCode: video.error ? Number(video.error.code || 0) : 0,
|
||||
errorMessage: video.error ? String(video.error.message || "") : "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,11 +440,14 @@ function extractRtcInboundVideoStats(statsReport, statsMap) {
|
||||
if (!statsReport) return { inbound: null, codec: "" };
|
||||
const codecReport = statsReport.codecId ? statsMap.get(statsReport.codecId) : null;
|
||||
const keyFramesDecoded = "keyFramesDecoded" in statsReport ? Number(statsReport.keyFramesDecoded ?? 0) : null;
|
||||
const framesReceived = "framesReceived" in statsReport ? Number(statsReport.framesReceived ?? 0) : null;
|
||||
return {
|
||||
codec: codecReport?.mimeType || codecReport?.id || "",
|
||||
codecFmtp: codecReport?.sdpFmtpLine || "",
|
||||
inbound: {
|
||||
framesDecoded: Number(statsReport.framesDecoded ?? 0),
|
||||
keyFramesDecoded: Number.isFinite(Number(keyFramesDecoded)) ? Number(keyFramesDecoded) : null,
|
||||
framesReceived: Number.isFinite(Number(framesReceived)) ? Number(framesReceived) : null,
|
||||
framesDropped: Number(statsReport.framesDropped ?? 0),
|
||||
framesPerSecond: Number(statsReport.framesPerSecond ?? 0),
|
||||
frameWidth: Number(statsReport.frameWidth ?? 0),
|
||||
@@ -428,11 +609,13 @@ async function collectRtcPerfStats() {
|
||||
RTC_PERF_STATE.connectionState = pc.connectionState || "unknown";
|
||||
RTC_PERF_STATE.iceConnectionState = pc.iceConnectionState || "unknown";
|
||||
RTC_PERF_STATE.codec = inbound.codec;
|
||||
RTC_PERF_STATE.codecParams = inbound.codecFmtp || "";
|
||||
RTC_PERF_STATE.inbound = inbound.inbound;
|
||||
RTC_PERF_STATE.video = readRtcVideoPlaybackQuality(video);
|
||||
RTC_PERF_STATE.network = buildRtcNetworkStats(inbound.inbound, RTC_PERF_STATE.video, stats, collectedAtMs);
|
||||
RTC_PERF_STATE.error = "";
|
||||
window.CarrotRtcPerf = RTC_PERF_STATE;
|
||||
rtcPushRawStatsHistory(pc, stats, "poll");
|
||||
rtcUpdateFreezeWatchdog(pc, video);
|
||||
_hudMarkDirty();
|
||||
emitCarrotRenderRequest({ force: false, overlayDirty: false, hudDirty: true });
|
||||
@@ -592,12 +775,25 @@ function rtcConnectionLooksLive(pc = RTC_PC) {
|
||||
return pc.connectionState === "connected" || pc.iceConnectionState === "connected" || pc.iceConnectionState === "completed";
|
||||
}
|
||||
|
||||
function rtcCanResumeWithoutReconnect() {
|
||||
return Boolean(
|
||||
shouldRunCarrotVisionRealtime() &&
|
||||
RTC_PC &&
|
||||
!RTC_PENDING_PC &&
|
||||
!_rtcConnecting &&
|
||||
rtcConnectionLooksLive(RTC_PC) &&
|
||||
rtcHasLiveTrack() &&
|
||||
(rtcVideoHasRenderableFrame() || RTC_FREEZE_STATE.everDecodedFrame)
|
||||
);
|
||||
}
|
||||
|
||||
function rtcIsWaitingForInitialTrack(pc = RTC_PC) {
|
||||
return Boolean(RTC_WAIT_TRACK_T && RTC_WAIT_TRACK_PC && RTC_WAIT_TRACK_PC === pc);
|
||||
}
|
||||
|
||||
function rtcUpdateFreezeSnapshot(snapshot) {
|
||||
RTC_FREEZE_STATE.lastFramesDecoded = snapshot.framesDecoded;
|
||||
RTC_FREEZE_STATE.lastFramesReceived = snapshot.framesReceived;
|
||||
RTC_FREEZE_STATE.lastTotalVideoFrames = snapshot.totalVideoFrames;
|
||||
RTC_FREEZE_STATE.lastCurrentTime = snapshot.currentTime;
|
||||
}
|
||||
@@ -753,6 +949,7 @@ function rtcUpdateFreezeWatchdog(pc, video) {
|
||||
|
||||
const snapshot = {
|
||||
framesDecoded: Number.isFinite(Number(RTC_PERF_STATE.inbound?.framesDecoded)) ? Number(RTC_PERF_STATE.inbound.framesDecoded) : null,
|
||||
framesReceived: Number.isFinite(Number(RTC_PERF_STATE.inbound?.framesReceived)) ? Number(RTC_PERF_STATE.inbound.framesReceived) : null,
|
||||
totalVideoFrames: Number.isFinite(Number(RTC_PERF_STATE.video?.totalVideoFrames)) ? Number(RTC_PERF_STATE.video.totalVideoFrames) : null,
|
||||
currentTime: Number.isFinite(Number(RTC_PERF_STATE.video?.currentTime)) ? Number(RTC_PERF_STATE.video.currentTime) : null,
|
||||
};
|
||||
@@ -777,6 +974,10 @@ function rtcUpdateFreezeWatchdog(pc, video) {
|
||||
(snapshot.framesDecoded != null && RTC_FREEZE_STATE.lastFramesDecoded != null && snapshot.framesDecoded > RTC_FREEZE_STATE.lastFramesDecoded) ||
|
||||
(snapshot.totalVideoFrames != null && RTC_FREEZE_STATE.lastTotalVideoFrames != null && snapshot.totalVideoFrames > RTC_FREEZE_STATE.lastTotalVideoFrames) ||
|
||||
(snapshot.currentTime != null && RTC_FREEZE_STATE.lastCurrentTime != null && snapshot.currentTime > RTC_FREEZE_STATE.lastCurrentTime + RTC_FREEZE_CURRENT_TIME_EPSILON);
|
||||
const receivedProgress =
|
||||
snapshot.framesReceived != null &&
|
||||
RTC_FREEZE_STATE.lastFramesReceived != null &&
|
||||
snapshot.framesReceived > RTC_FREEZE_STATE.lastFramesReceived;
|
||||
|
||||
if (hasProgress) {
|
||||
RTC_FREEZE_STATE.stallSamples = 0;
|
||||
@@ -786,10 +987,26 @@ function rtcUpdateFreezeWatchdog(pc, video) {
|
||||
}
|
||||
} else {
|
||||
RTC_FREEZE_STATE.stallSamples++;
|
||||
rtcTrace("decode_stall_sample", {
|
||||
stallSamples: RTC_FREEZE_STATE.stallSamples,
|
||||
receivedProgress,
|
||||
previous: {
|
||||
framesReceived: RTC_FREEZE_STATE.lastFramesReceived,
|
||||
framesDecoded: RTC_FREEZE_STATE.lastFramesDecoded,
|
||||
totalVideoFrames: RTC_FREEZE_STATE.lastTotalVideoFrames,
|
||||
currentTime: RTC_FREEZE_STATE.lastCurrentTime,
|
||||
},
|
||||
current: snapshot,
|
||||
inbound: RTC_PERF_STATE.inbound,
|
||||
video: RTC_PERF_STATE.video,
|
||||
network: RTC_PERF_STATE.network,
|
||||
}, pc);
|
||||
}
|
||||
rtcUpdateFreezeSnapshot(snapshot);
|
||||
|
||||
const stallLimit = RTC_FREEZE_STATE.everDecodedFrame ? RTC_FREEZE_MAX_STALL_SAMPLES : RTC_INITIAL_FRAME_MAX_STALL_SAMPLES;
|
||||
const stallLimit = RTC_FREEZE_STATE.everDecodedFrame
|
||||
? (receivedProgress ? RTC_DECODE_STALL_MAX_SAMPLES : RTC_FREEZE_MAX_STALL_SAMPLES)
|
||||
: RTC_INITIAL_FRAME_MAX_STALL_SAMPLES;
|
||||
if (RTC_FREEZE_STATE.stallSamples >= stallLimit) {
|
||||
rtcRecover(
|
||||
"stall",
|
||||
@@ -1059,7 +1276,6 @@ async function rtcConnectOnce(options = {}) {
|
||||
videoEl.srcObject = stream;
|
||||
RTC_PENDING_PC = null;
|
||||
RTC_PC = pc;
|
||||
try { await videoEl.play(); } catch (e) { console.log("[RTC] play() failed", e); }
|
||||
rtcStatusSet("track: " + ev.track.kind);
|
||||
rtcDisarmTrackTimeout(pc);
|
||||
rtcArmFirstFrameTimeout(RTC_INITIAL_FRAME_TIMEOUT_MS, pc);
|
||||
@@ -1069,6 +1285,7 @@ async function rtcConnectOnce(options = {}) {
|
||||
startRtcPerfPolling(true);
|
||||
collectRtcPerfStats().catch(() => {});
|
||||
requestCarrotVisionRender();
|
||||
videoEl.play().catch((e) => console.log("[RTC] play() failed", e));
|
||||
|
||||
ev.track.addEventListener("unmute", () => {
|
||||
videoEl.play().catch(() => {});
|
||||
@@ -1259,12 +1476,14 @@ function rtcHandleVisibilityChange() {
|
||||
|
||||
window.CarrotVisionRtc = {
|
||||
bindVideoEvents: rtcBindVideoEvents,
|
||||
canResumeWithoutReconnect: rtcCanResumeWithoutReconnect,
|
||||
cancelRecovery: rtcCancelRecovery,
|
||||
cancelResumeCheck: rtcCancelResumeCheck,
|
||||
cancelRetry: rtcCancelRetry,
|
||||
captureVideoHoldFrame: rtcCaptureVideoHoldFrame,
|
||||
collectPerfStats: collectRtcPerfStats,
|
||||
connectOnce: rtcConnectOnce,
|
||||
diagnosticSnapshot: rtcDiagnosticSnapshot,
|
||||
disconnect: rtcDisconnect,
|
||||
disarmTrackTimeout: rtcDisarmTrackTimeout,
|
||||
disarmFirstFrameTimeout: rtcDisarmFirstFrameTimeout,
|
||||
@@ -1273,6 +1492,7 @@ window.CarrotVisionRtc = {
|
||||
hasLiveTrack: rtcHasLiveTrack,
|
||||
handleVisibilityChange: rtcHandleVisibilityChange,
|
||||
reportCameraRenderable: rtcReportCameraRenderable,
|
||||
rawStatsHistory: () => RTC_RAW_STATS_HISTORY.slice(-RTC_RAW_STATS_HISTORY_MAX),
|
||||
resetFailCount: rtcResetFailCount,
|
||||
scheduleResumeIfConnected: rtcScheduleResumeIfConnected,
|
||||
shouldConnect: rtcShouldConnect,
|
||||
@@ -1288,17 +1508,20 @@ Object.assign(window, {
|
||||
getRtcVideoElement,
|
||||
requestCarrotVisionRecovery,
|
||||
rtcBindVideoEvents,
|
||||
rtcCanResumeWithoutReconnect,
|
||||
rtcCancelRecovery,
|
||||
rtcCancelResumeCheck,
|
||||
rtcCancelRetry,
|
||||
rtcCaptureVideoHoldFrame,
|
||||
rtcConnectOnce,
|
||||
rtcDiagnosticSnapshot,
|
||||
rtcDisconnect,
|
||||
rtcDisarmFirstFrameTimeout,
|
||||
rtcDisarmTrackTimeout,
|
||||
rtcExitPictureInPicture,
|
||||
rtcHandleVisibilityChange,
|
||||
rtcHasLiveTrack,
|
||||
rtcRawStatsHistory: () => RTC_RAW_STATS_HISTORY.slice(-RTC_RAW_STATS_HISTORY_MAX),
|
||||
rtcResetFailCount,
|
||||
rtcScheduleResumeIfConnected,
|
||||
rtcShouldConnect,
|
||||
|
||||
@@ -145,6 +145,12 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) {
|
||||
|
||||
V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_height)
|
||||
: VideoEncoder(encoder_info, in_width, in_height) {
|
||||
const bool carrot_livestream_road = strcmp(encoder_info.publish_name, "livestreamRoadEncodeData") == 0;
|
||||
if (carrot_livestream_road) {
|
||||
out_width = 964;
|
||||
out_height = 604;
|
||||
}
|
||||
|
||||
fd = HANDLE_EINTR(open("/dev/v4l/by-path/platform-aa00000.qcom_vidc-video-index1", O_RDWR|O_NONBLOCK));
|
||||
assert(fd >= 0);
|
||||
|
||||
@@ -245,6 +251,38 @@ V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_hei
|
||||
}
|
||||
}
|
||||
|
||||
if (carrot_livestream_road) {
|
||||
LOGW("H264 compatibility profile enabled for %s: output=%dx%d bitrate=%d multi-slice-max-bytes=%d",
|
||||
encoder_info.publish_name, out_width, out_height, 600'000, 1200);
|
||||
|
||||
struct v4l2_control compatibility_ctrls[] = {
|
||||
{ .id = V4L2_CID_MPEG_VIDEO_BITRATE, .value = 600'000},
|
||||
{ .id = V4L2_CID_MPEG_VIDEO_H264_PROFILE, .value = V4L2_MPEG_VIDEO_H264_PROFILE_BASELINE},
|
||||
{ .id = V4L2_CID_MPEG_VIDEO_H264_ENTROPY_MODE, .value = V4L2_MPEG_VIDEO_H264_ENTROPY_MODE_CAVLC},
|
||||
};
|
||||
for (auto ctrl : compatibility_ctrls) {
|
||||
util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl);
|
||||
}
|
||||
|
||||
struct v4l2_control slice_size = {
|
||||
.id = V4L2_CID_MPEG_VIDEO_MULTI_SLICE_MAX_BYTES,
|
||||
.value = 1200,
|
||||
};
|
||||
const bool size_set = util::safe_ioctl(fd, VIDIOC_S_CTRL, &slice_size) == 0;
|
||||
struct v4l2_control slice_mode = {
|
||||
.id = V4L2_CID_MPEG_VIDEO_MULTI_SLICE_MODE,
|
||||
.value = size_set ? V4L2_MPEG_VIDEO_MULTI_SICE_MODE_MAX_BYTES : V4L2_MPEG_VIDEO_MULTI_SLICE_MODE_SINGLE,
|
||||
};
|
||||
const bool mode_set = size_set && util::safe_ioctl(fd, VIDIOC_S_CTRL, &slice_mode) == 0;
|
||||
if (mode_set) {
|
||||
LOGW("multi-slice enabled for %s: max-bytes=%d", encoder_info.publish_name, 1200);
|
||||
} else {
|
||||
slice_mode.value = V4L2_MPEG_VIDEO_MULTI_SLICE_MODE_SINGLE;
|
||||
util::safe_ioctl(fd, VIDIOC_S_CTRL, &slice_mode);
|
||||
LOGW("multi-slice unavailable for %s; falling back to single-slice H264", encoder_info.publish_name);
|
||||
}
|
||||
}
|
||||
|
||||
// allocate buffers
|
||||
request_buffers(fd, V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, BUF_OUT_COUNT);
|
||||
request_buffers(fd, V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, BUF_IN_COUNT);
|
||||
|
||||
Reference in New Issue
Block a user