vision-work (#392)

This commit is contained in:
jominki354
2026-06-04 20:39:36 +09:00
committed by ajouatom
parent b3fe1c61b0
commit 21fe09babc
27 changed files with 3069 additions and 222 deletions

View File

@@ -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)

View File

@@ -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"

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View 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)

View 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)

View 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(),
}

View 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())

View 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.

View File

@@ -0,0 +1,3 @@
from .bridge import META_COMMAND_PREFIX, translate_meta_command
__all__ = ["META_COMMAND_PREFIX", "translate_meta_command"]

View 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])

View 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())

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View 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)

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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") {

View File

@@ -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;

View File

@@ -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;
if (!resolutionLabel && bitrateMbps == null && fps == null && rttMs == null && lossPct == null && jitterMs == null) {
return hasFreeze ? "STALL" : "";
}
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(" ");
}
function drawHudRightCenterPerfText(stageWidth, stageHeight, viewportRect, pathMode) {
const label = shortText(formatRtcPerfLabel(), 48);
if (!label) return;
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 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 alpha = getHudLabelAlpha(pathMode, 0.0);
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}`;
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,
});
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(" | ")}`;
}
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 formatRtcPerfLabel() {
return buildRtcPerfHudModel().glance || "";
}
let _rtcPerfSummaryOpen = false;
let _rtcPerfSummaryCloseTimer = null;
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 });

View File

@@ -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;

View File

@@ -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) });
}
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);
}
}
return lines.join("\n");
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; },

View File

@@ -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,

View File

@@ -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);